Files
compiler-explorer/test/utils-tests.ts
Partouf 65f19ffeef Show the build steps of a project build
A CMake build runs cmake and then the build tool, but nothing in the UI
said what those steps actually were: the compiler pane's "All compilation
options" popover reports the per-file compile line, and the build system's
own invocations were invisible. When a project build fails in the configure
step, there was no way to see what was run.

Each build step already carried its arguments and exit code, so this adds
what was missing and puts it behind a button:

  - `BuildStep.command`: the binary. `doBuildstepAndAddToResult` received
    it but only ever stored the arguments, so `--build .` could not be
    shown as a cmake invocation.
  - `BuildStep.env`: what the step actually ran with. LD_LIBRARY_PATH is
    taken from `ldPath` rather than `env`, because exec() overwrites the
    inherited value with it before spawning -- reading `env` reports the
    server's own, which is empty. PATH and HOME are left out as host state
    rather than anything about the build, and empty values are dropped.

Both go through masking, which needed a new entry point. Env values hold
several paths in one string, and `-L/tmp/<prefix>X -Wl,-rpath,...` puts a
temp dir at the end of a token; maskRootdirKeepingAppPrefix() is built for
argv, where neither happens, so it masked one path in LDFLAGS and left
three. maskRootdirsInText() is global and treats a bare temp dir as a path.
The argv entry point is untouched.

The steps are shown in a modal rather than a popover: a command line plus
its environment is more than a popover holds, and a popover that dismisses
on outside mouseup is awkward to copy from. Step status comes from the exit
code alone -- calculateStatusIcon() reads any output as a warning, which
fits a compiler but would paint every successful cmake step orange.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168db5wLwZMU9TtcbN9ZD6v
2026-09-02 20:52:03 +02:00

966 lines
38 KiB
TypeScript

// Copyright (c) 2017, Compiler Explorer Authors
// All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// * Redistributions of source code must retain the above copyright notice,
// this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above copyright
// notice, this list of conditions and the following disclaimer in the
// documentation and/or other materials provided with the distribution.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE.
import fs from 'node:fs/promises';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {describe, expect, it} from 'vitest';
import winston from 'winston';
import {makeLogStream} from '../lib/logger.js';
import * as utils from '../lib/utils.js';
import {newTempDir} from './utils.js';
describe('Splits lines', () => {
it('handles empty input', () => {
expect(utils.splitLines('')).toEqual([]);
});
it('handles a single line with no newline', () => {
expect(utils.splitLines('A line')).toEqual(['A line']);
});
it('handles a single line with a newline', () => {
expect(utils.splitLines('A line\n')).toEqual(['A line']);
});
it('handles multiple lines', () => {
expect(utils.splitLines('A line\nAnother line\n')).toEqual(['A line', 'Another line']);
});
it('handles multiple lines ending on a non-newline', () => {
expect(utils.splitLines('A line\nAnother line\nLast line')).toEqual(['A line', 'Another line', 'Last line']);
});
it('handles empty lines', () => {
expect(utils.splitLines('A line\n\nA line after an empty')).toEqual(['A line', '', 'A line after an empty']);
});
it('handles a single empty line', () => {
expect(utils.splitLines('\n')).toEqual(['']);
});
it('handles multiple empty lines', () => {
expect(utils.splitLines('\n\n\n')).toEqual(['', '', '']);
});
it('handles \\r\\n lines', () => {
expect(utils.splitLines('Some\r\nLines\r\n')).toEqual(['Some', 'Lines']);
});
});
describe('Expands tabs', () => {
it('leaves non-tabs alone', () => {
expect(utils.expandTabs('This has no tabs at all')).toEqual('This has no tabs at all');
});
it('at beginning of line', () => {
expect(utils.expandTabs('\tOne tab')).toEqual(' One tab');
expect(utils.expandTabs('\t\tTwo tabs')).toEqual(' Two tabs');
});
it('mid-line', () => {
expect(utils.expandTabs('0\t1234567A')).toEqual('0 1234567A');
expect(utils.expandTabs('01\t234567A')).toEqual('01 234567A');
expect(utils.expandTabs('012\t34567A')).toEqual('012 34567A');
expect(utils.expandTabs('0123\t4567A')).toEqual('0123 4567A');
expect(utils.expandTabs('01234\t567A')).toEqual('01234 567A');
expect(utils.expandTabs('012345\t67A')).toEqual('012345 67A');
expect(utils.expandTabs('0123456\t7A')).toEqual('0123456 7A');
expect(utils.expandTabs('01234567\tA')).toEqual('01234567 A');
});
});
describe('Parses compiler output', () => {
it('handles simple cases', () => {
expect(utils.parseOutput('Line one\nLine two', 'bob.cpp')).toEqual([{text: 'Line one'}, {text: 'Line two'}]);
expect(utils.parseOutput('Line one\nbob.cpp:1 Line two', 'bob.cpp')).toEqual([
{text: 'Line one'},
{
tag: {column: 0, line: 1, text: 'Line two', severity: 3, file: 'bob.cpp'},
text: '<source>:1 Line two',
},
]);
expect(utils.parseOutput('Line one\nbob.cpp:1:5: Line two', 'bob.cpp')).toEqual([
{text: 'Line one'},
{
tag: {column: 5, line: 1, text: 'Line two', severity: 3, file: 'bob.cpp'},
text: '<source>:1:5: Line two',
},
]);
});
it('handles windows output', () => {
expect(utils.parseOutput('bob.cpp(1) Oh noes', 'bob.cpp')).toEqual([
{
tag: {column: 0, line: 1, text: 'Oh noes', severity: 3, file: 'bob.cpp'},
text: '<source>(1) Oh noes',
},
]);
});
it('does not treat c2rust AST node lines as source locations', () => {
expect(utils.parseOutput('CTypeId(42): Located {', 'example.c')).toEqual([{text: 'CTypeId(42): Located {'}]);
});
it('replaces all references to input source', () => {
expect(utils.parseOutput('bob.cpp:1 error in bob.cpp', 'bob.cpp')).toEqual([
{
tag: {column: 0, line: 1, text: 'error in <source>', severity: 3, file: 'bob.cpp'},
text: '<source>:1 error in <source>',
},
]);
});
it('treats warnings and notes as the correct severity', () => {
expect(utils.parseOutput('Line one\nbob.cpp:1:5: warning Line two', 'bob.cpp')).toEqual([
{text: 'Line one'},
{
tag: {column: 5, line: 1, text: 'warning Line two', severity: 2, file: 'bob.cpp'},
text: '<source>:1:5: warning Line two',
},
]);
expect(utils.parseOutput('Line one\nbob.cpp:1:5: note Line two', 'bob.cpp')).toEqual([
{text: 'Line one'},
{
tag: {column: 5, line: 1, text: 'note Line two', severity: 1, file: 'bob.cpp'},
text: '<source>:1:5: note Line two',
},
]);
});
it('treats <stdin> as if it were the compiler source', () => {
expect(
utils.parseOutput("<stdin>:120:25: error: variable or field 'transform_data' declared void", 'bob.cpp'),
).toEqual([
{
tag: {
column: 25,
line: 120,
text: "error: variable or field 'transform_data' declared void",
severity: 3,
file: 'bob.cpp',
},
text: "<source>:120:25: error: variable or field 'transform_data' declared void",
},
]);
});
it('parser error with full path', () => {
expect(utils.parseOutput("/app/example.cl:5:30: error: use of undeclared identifier 'ad'")).toEqual([
{
tag: {
file: 'example.cl',
column: 30,
line: 5,
text: "error: use of undeclared identifier 'ad'",
severity: 3,
},
text: "example.cl:5:30: error: use of undeclared identifier 'ad'",
},
]);
});
it('removes hyperlink escape sequences', () => {
expect(
utils.parseOutput(
't.c:3:1: warning: control reaches end of non-void function [\x1B]8;;https://gcc.gnu.org/onlinedocs/gcc-14.2.0/gcc/Warning-Options.html#index-Wno-return-type\x1B\\-Wreturn-type\x1B]8;;\x1B\\]',
),
).toEqual([
{
tag: {
file: 't.c',
line: 3,
column: 1,
text: 'warning: control reaches end of non-void function [-Wreturn-type]',
severity: 2,
},
text: 't.c:3:1: warning: control reaches end of non-void function [\x1B]8;;https://gcc.gnu.org/onlinedocs/gcc-14.2.0/gcc/Warning-Options.html#index-Wno-return-type\x1B\\-Wreturn-type\x1B]8;;\x1B\\]',
},
]);
});
});
describe('Pascal compiler output', () => {
it('recognize fpc identifier not found error', () => {
expect(utils.parseOutput('output.pas(13,23) Error: Identifier not found "adsadasd"', 'output.pas')).toEqual([
{
tag: {
column: 23,
line: 13,
text: 'Error: Identifier not found "adsadasd"',
severity: 3,
file: 'output.pas',
},
text: '<source>(13,23) Error: Identifier not found "adsadasd"',
},
]);
});
it('recognize fpc exiting error', () => {
expect(
utils.parseOutput('output.pas(17) Fatal: There were 1 errors compiling module, stopping', 'output.pas'),
).toEqual([
{
tag: {
column: 0,
line: 17,
text: 'Fatal: There were 1 errors compiling module, stopping',
severity: 3,
file: 'output.pas',
},
text: '<source>(17) Fatal: There were 1 errors compiling module, stopping',
},
]);
});
it('removes the temp path', () => {
expect(
utils.parseOutput(
'Compiling /tmp/path/prog.dpr\noutput.pas(17) Fatal: There were 1 errors compiling module, stopping',
'output.pas',
'/tmp/path/',
),
).toEqual([
{
text: 'Compiling prog.dpr',
},
{
tag: {
column: 0,
line: 17,
text: 'Fatal: There were 1 errors compiling module, stopping',
severity: 3,
file: 'output.pas',
},
text: '<source>(17) Fatal: There were 1 errors compiling module, stopping',
},
]);
});
});
describe('Rust compiler output', () => {
it('handles simple cases', () => {
expect(utils.parseRustOutput('Line one\nLine two', 'bob.rs')).toEqual([{text: 'Line one'}, {text: 'Line two'}]);
expect(utils.parseRustOutput('Unrelated\nLine one\n --> bob.rs:1:0\nUnrelated', 'bob.rs')).toEqual([
{text: 'Unrelated'},
{
tag: {file: 'bob.rs', column: 0, line: 1, text: 'Line one', severity: 3},
text: 'Line one',
},
{
tag: {file: 'bob.rs', column: 0, line: 1, text: '', severity: 3},
text: ' --> <source>:1:0',
},
{text: 'Unrelated'},
]);
expect(utils.parseRustOutput('Line one\n --> bob.rs:1:5', 'bob.rs')).toEqual([
{
tag: {file: 'bob.rs', column: 5, line: 1, text: 'Line one', severity: 3},
text: 'Line one',
},
{
tag: {file: 'bob.rs', column: 5, line: 1, text: '', severity: 3},
text: ' --> <source>:1:5',
},
]);
expect(utils.parseRustOutput('Multiple spaces\n --> bob.rs:1:5', 'bob.rs')).toEqual([
{
tag: {file: 'bob.rs', column: 5, line: 1, text: 'Multiple spaces', severity: 3},
text: 'Multiple spaces',
},
{
tag: {file: 'bob.rs', column: 5, line: 1, text: '', severity: 3},
text: ' --> <source>:1:5',
},
]);
});
it('replaces all references to input source', () => {
expect(utils.parseRustOutput('error: Error in bob.rs\n --> bob.rs:1:42', 'bob.rs')).toEqual([
{
tag: {file: 'bob.rs', column: 42, line: 1, text: 'error: Error in <source>', severity: 3},
text: 'error: Error in <source>',
},
{
tag: {file: 'bob.rs', column: 42, line: 1, text: '', severity: 3},
text: ' --> <source>:1:42',
},
]);
});
it('treats <stdin> as if it were the compiler source', () => {
expect(utils.parseRustOutput('error: <stdin> is sad\n --> <stdin>:120:25', 'bob.rs')).toEqual([
{
tag: {file: 'bob.rs', column: 25, line: 120, text: 'error: <source> is sad', severity: 3},
text: 'error: <source> is sad',
},
{
tag: {file: 'bob.rs', column: 25, line: 120, text: '', severity: 3},
text: ' --> <source>:120:25',
},
]);
});
it('removes hyperlink escape sequences', () => {
expect(
utils.parseRustOutput(
'error[\x1B]8;;https://doc.rust-lang.org/error_codes/E0425.html\x07E0425\x1B]8;;\x07]: cannot find value `x` in this scope\n --> <source>:42:27',
),
).toEqual([
{
tag: {
line: 42,
column: 27,
text: 'error[E0425]: cannot find value `x` in this scope',
severity: 3,
},
text: 'error[\x1B]8;;https://doc.rust-lang.org/error_codes/E0425.html\x07E0425\x1B]8;;\x07]: cannot find value `x` in this scope',
},
{
tag: {
line: 42,
column: 27,
text: '',
severity: 3,
},
text: ' --> <source>:42:27',
},
]);
});
it('emits quickfixes', () => {
expect(utils.parseRustOutput('error\n --> <source>:42:27\n15 + use std::collections::HashMap;')).toEqual([
{
tag: {
line: 42,
column: 27,
text: 'error',
severity: 3,
},
text: 'error',
},
{
tag: {
line: 42,
column: 27,
text: '',
severity: 3,
},
text: ' --> <source>:42:27',
},
{
text: '15 + use std::collections::HashMap;',
tag: {
line: 42,
column: 27,
text: 'error',
severity: 3,
fixes: [
{
title: 'Add import for `std::collections::HashMap`',
edits: [
{
line: 15,
column: 1,
endline: 15,
endcolumn: 1,
text: 'use std::collections::HashMap;\n',
},
],
},
],
},
},
]);
expect(
utils.parseRustOutput(
'error\n --> <source>:42:27\n = help: add `#![feature(num_midpoint_signed)]` to the crate attributes to enable',
),
).toEqual([
{
tag: {
line: 42,
column: 27,
text: 'error',
severity: 3,
},
text: 'error',
},
{
tag: {
line: 42,
column: 27,
text: '',
severity: 3,
},
text: ' --> <source>:42:27',
},
{
text: ' = help: add `#![feature(num_midpoint_signed)]` to the crate attributes to enable',
tag: {
line: 42,
column: 27,
text: 'error',
severity: 3,
fixes: [
{
title: 'Add feature flag `num_midpoint_signed`',
edits: [
{
line: 1,
column: 1,
endline: 1,
endcolumn: 1,
text: '#![feature(num_midpoint_signed)]\n',
},
],
},
],
},
},
]);
});
it('attaches filenames to errors from multiple files', () => {
// https://godbolt.org/z/nWE3PTeGf
expect(
utils.parseRustOutput(
`warning: function \`f1\` is never used
--> <source>:3:4
warning: function \`f2\` is never used
--> /app/m.rs:1:4
warning: 2 warnings emitted`,
'example.rs',
),
).toEqual([
{
text: 'warning: function `f1` is never used',
tag: {
line: 3,
column: 4,
file: 'example.rs',
severity: 2,
text: 'warning: function `f1` is never used',
},
},
{
text: ' --> <source>:3:4',
tag: {
line: 3,
column: 4,
file: 'example.rs',
severity: 3,
text: '',
},
},
{text: ''},
{
text: 'warning: function `f2` is never used',
tag: {
line: 1,
column: 4,
file: 'm.rs',
severity: 2,
text: 'warning: function `f2` is never used',
},
},
{
text: ' --> /app/m.rs:1:4',
tag: {
line: 1,
column: 4,
file: 'm.rs',
severity: 3,
text: '',
},
},
{text: ''},
{text: 'warning: 2 warnings emitted'},
]);
});
});
describe('Tool output', () => {
it('removes the relative path', () => {
expect(
utils.parseOutput(
'./example.cpp:1:1: Fatal: There were 1 errors compiling module, stopping',
'./example.cpp',
),
).toEqual([
{
tag: {
column: 1,
line: 1,
text: 'Fatal: There were 1 errors compiling module, stopping',
severity: 3,
file: 'example.cpp',
},
text: '<source>:1:1: Fatal: There were 1 errors compiling module, stopping',
},
]);
});
it('removes fortran relative path', () => {
expect(
utils.parseOutput("./example.f90:5:22: error: No explicit type declared for 'y'", './example.f90'),
).toEqual([
{
tag: {
column: 22,
line: 5,
text: "error: No explicit type declared for 'y'",
severity: 3,
file: 'example.f90',
},
text: "<source>:5:22: error: No explicit type declared for 'y'",
},
]);
});
it('removes the jailed path', () => {
expect(
utils.parseOutput(
'/home/ubuntu/example.cpp:1:1: Fatal: There were 1 errors compiling module, stopping',
'./example.cpp',
),
).toEqual([
{
tag: {
column: 1,
line: 1,
text: 'Fatal: There were 1 errors compiling module, stopping',
severity: 3,
file: 'example.cpp',
},
text: '<source>:1:1: Fatal: There were 1 errors compiling module, stopping',
},
]);
});
});
describe('Anonymizes all kind of IPs', () => {
it('Ignores localhost', () => {
expect(utils.anonymizeIp('localhost')).toEqual('localhost');
expect(utils.anonymizeIp('localhost:42')).toEqual('localhost:42');
});
it('Removes last octet from IPv4 addresses', () => {
expect(utils.anonymizeIp('127.0.0.0')).toEqual('127.0.0.0');
expect(utils.anonymizeIp('127.0.0.10')).toEqual('127.0.0.0');
expect(utils.anonymizeIp('127.0.0.255')).toEqual('127.0.0.0');
});
it('Removes last 3 hextets from IPv6 addresses', () => {
// Not necessarily valid addresses, we're interested in the format
expect(utils.anonymizeIp('ffff:aaaa:dead:beef')).toEqual('ffff:0:0:0');
expect(utils.anonymizeIp('bad:c0de::')).toEqual('bad:0:0:0');
expect(utils.anonymizeIp(':1d7e::c0fe')).toEqual(':0:0:0');
});
});
describe('Logger functionality', () => {
it('correctly logs streams split over lines', () => {
const logs: {level: string; msg: string}[] = [];
const fakeLog = {log: (level: string, msg: string) => logs.push({level, msg})} as any as winston.Logger;
const infoStream = makeLogStream('info', fakeLog);
infoStream.write('first\n');
infoStream.write('part');
infoStream.write('ial\n');
expect(logs).toEqual([
{
level: 'info',
msg: 'first',
},
{
level: 'info',
msg: 'partial',
},
]);
});
it('correctly logs streams to the right destination', () => {
const logs: {level: string; msg: string}[] = [];
const fakeLog = {log: (level: string, msg: string) => logs.push({level, msg})} as any as winston.Logger;
const infoStream = makeLogStream('warn', fakeLog);
infoStream.write('ooh\n');
expect(logs).toEqual([
{
level: 'warn',
msg: 'ooh',
},
]);
});
});
describe('Hash interface', () => {
it('correctly hashes strings', () => {
const version = 'Compiler Explorer Tests Version 0';
expect(utils.getHash('cream cheese', version)).toEqual(
'cfff2d1f7a213e314a67cce8399160ae884f794a3ee9d4a01cd37a8c22c67d94',
);
expect(utils.getHash('large eggs', version)).toEqual(
'9144dec50b8df5bc5cc24ba008823cafd6616faf2f268af84daf49ac1d24feb0',
);
expect(utils.getHash('sugar', version)).toEqual(
'afa3c89d0f6a61de6805314c9bd7c52d020425a3a3c7bbdfa7c0daec594e5ef1',
);
});
it('correctly hashes objects', () => {
expect(
utils.getHash({
toppings: [
{name: 'raspberries', optional: false},
{name: 'ground cinnamon', optional: true},
],
}),
).toEqual('e205d63abd5db363086621fdc62c4c23a51b733bac5855985a8b56642d570491');
});
});
describe('GoldenLayout utils', () => {
it('finds every editor & compiler', async () => {
const state = JSON.parse(await fs.readFile('test/example-states/default-state.json', 'utf-8'));
const contents = utils.glGetMainContents(state.content);
expect(contents).toEqual({
editors: [
{source: 'Editor 1', language: 'c++'},
{source: 'Editor 2', language: 'c++'},
{source: 'Editor 3', language: 'c++'},
{source: 'Editor 4', language: 'c++'},
],
compilers: [
{compiler: 'clang_trunk'},
{compiler: 'gsnapshot'},
{compiler: 'clang_trunk'},
{compiler: 'gsnapshot'},
{compiler: 'rv32-clang'},
],
});
});
});
describe('squashes horizontal whitespace', () => {
it('handles empty input', () => {
expect(utils.squashHorizontalWhitespace('')).toEqual('');
expect(utils.squashHorizontalWhitespace(' ')).toEqual('');
expect(utils.squashHorizontalWhitespace(' ')).toEqual('');
});
it('handles leading spaces', () => {
expect(utils.squashHorizontalWhitespace(' abc')).toEqual(' abc');
expect(utils.squashHorizontalWhitespace(' abc')).toEqual(' abc');
expect(utils.squashHorizontalWhitespace(' abc')).toEqual(' abc');
});
it('handles interline spaces', () => {
expect(utils.squashHorizontalWhitespace('abc abc')).toEqual('abc abc');
expect(utils.squashHorizontalWhitespace('abc abc')).toEqual('abc abc');
expect(utils.squashHorizontalWhitespace('abc abc')).toEqual('abc abc');
});
it('handles leading and interline spaces', () => {
expect(utils.squashHorizontalWhitespace(' abc abc')).toEqual(' abc abc');
expect(utils.squashHorizontalWhitespace(' abc abc')).toEqual(' abc abc');
expect(utils.squashHorizontalWhitespace(' abc abc')).toEqual(' abc abc');
expect(utils.squashHorizontalWhitespace(' abc abc')).toEqual(' abc abc');
});
});
describe('encodes in our version of base32', () => {
function doTest(original: string, expected: string) {
expect(utils.base32Encode(Buffer.from(original))).toEqual(expected);
}
// Done by hand to check that they are valid
it('works for empty strings', () => {
doTest('', '');
});
it('works for lengths multiple of 5 bits', () => {
doTest('aaaaa', '3Mn4ha7P');
});
it('works for lengths not multiple of 5 bits', () => {
// 3
doTest('a', '35');
// 1
doTest('aa', '3Mn1');
// 4
doTest('aaa', '3Mn48');
// 2
doTest('aaaa', '3Mn4ha3');
});
it('works for some random strings', () => {
// I also calculated this ones so lets put them
doTest('foo', '8rrx8');
doTest('foobar', '8rrx8b7Pc5');
});
});
const thisFilename = fileURLToPath(import.meta.url);
describe('fileExists', () => {
it('Returns true for files that exists', async () => {
await expect(utils.fileExists(thisFilename)).resolves.toBe(true);
});
it("Returns false for files that don't exist", async () => {
await expect(utils.fileExists('./ABC-FileThatDoesNotExist.extension')).resolves.toBe(false);
});
it('Returns false for directories that exist', async () => {
await expect(utils.fileExists(path.resolve(path.dirname(thisFilename)))).resolves.toBe(false);
});
});
describe('safe semver', () => {
it('should understand most kinds of semvers', () => {
expect(utils.asSafeVer('0')).toEqual('0.0.0');
expect(utils.asSafeVer('1')).toEqual('1.0.0');
expect(utils.asSafeVer('1.0')).toEqual('1.0.0');
expect(utils.asSafeVer('1.1')).toEqual('1.1.0');
expect(utils.asSafeVer('1.1.0')).toEqual('1.1.0');
expect(utils.asSafeVer('1.1.1')).toEqual('1.1.1');
expect(utils.asSafeVer('trunk')).toEqual(utils.magic_semver.trunk);
expect(utils.asSafeVer('(trunk)')).toEqual(utils.magic_semver.trunk);
expect(utils.asSafeVer('(123.456.789 test)')).toEqual(utils.magic_semver.non_trunk);
expect(utils.asSafeVer('0..0')).toEqual(utils.magic_semver.non_trunk);
expect(utils.asSafeVer('0.0.')).toEqual(utils.magic_semver.non_trunk);
expect(utils.asSafeVer('0.')).toEqual(utils.magic_semver.non_trunk);
expect(utils.asSafeVer('.0.0')).toEqual(utils.magic_semver.non_trunk);
expect(utils.asSafeVer('.0..')).toEqual(utils.magic_semver.non_trunk);
expect(utils.asSafeVer('0..')).toEqual(utils.magic_semver.non_trunk);
expect(utils.asSafeVer('123 TEXT')).toEqual('123.0.0');
expect(utils.asSafeVer('123.456 TEXT')).toEqual('123.456.0');
expect(utils.asSafeVer('123.456.789 TEXT')).toEqual('123.456.789');
});
});
describe('tries to load text file', () => {
it('should load files that exist', async () => {
const selfText = await utils.tryReadTextFile(thisFilename);
expect(selfText).toContain('should load files that exist');
});
it('should be undefined for non-existent files', async () => {
const selfText = await utils.tryReadTextFile(thisFilename + '.doesntexist');
expect(selfText).to.be.undefined;
});
});
describe('output files', async () => {
const tmpDir = newTempDir();
it('should work in simple cases', async () => {
const filepath = path.join(tmpDir, 'file.txt');
await utils.outputTextFile(filepath, 'hello');
expect(await utils.tryReadTextFile(filepath)).toEqual('hello');
});
it('should create subddirectories ok', async () => {
const filepath = path.join(tmpDir, 'a', 'b', 'file.txt');
await utils.outputTextFile(filepath, 'hello');
expect(await utils.tryReadTextFile(filepath)).toEqual('hello');
});
it('should ensure files exist', async () => {
const filepath = path.join(tmpDir, 'moo', 'foo', 'file.txt');
await utils.ensureFileExists(filepath);
expect(await utils.tryReadTextFile(filepath)).toEqual('');
await utils.outputTextFile(filepath, 'hello');
expect(await utils.tryReadTextFile(filepath)).toEqual('hello');
await utils.ensureFileExists(filepath);
expect(await utils.tryReadTextFile(filepath)).toEqual('hello');
});
});
describe('maskRootdirKeepingAppPrefix', () => {
it('keeps the /app/ prefix, which is the path the compiler is given', () => {
expect(utils.maskRootdirKeepingAppPrefix('/tmp/compiler-explorer-compiler123-4-abc/example.cpp')).toEqual(
'/app/example.cpp',
);
});
it('masks an embedded temp path the same way maskRootdir does', () => {
expect(utils.maskRootdirKeepingAppPrefix('-I/tmp/compiler-explorer-compiler123-4-abc/include')).toEqual(
'-I/app/include',
);
});
it('leaves non-temp paths untouched', () => {
expect(utils.maskRootdirKeepingAppPrefix('/usr/include/stdio.h')).toEqual('/usr/include/stdio.h');
});
it('is idempotent, so masking an already-masked path is a no-op', () => {
const once = utils.maskRootdirKeepingAppPrefix('/tmp/compiler-explorer-compiler123-4-abc/example.cpp');
expect(utils.maskRootdirKeepingAppPrefix(once)).toEqual(once);
const embedded = utils.maskRootdirKeepingAppPrefix('-I/tmp/compiler-explorer-compiler123-4-abc/include');
expect(utils.maskRootdirKeepingAppPrefix(embedded)).toEqual(embedded);
});
});
describe('maskRootdirsInText', () => {
const tmp = '/tmp/compiler-explorer-compiler123-4-abc';
it('masks every temp path, not just the first', () => {
expect(utils.maskRootdirsInText(`-I${tmp}/include -I${tmp}/other/include`)).toEqual(
'-I/app/include -I/app/other/include',
);
});
it('masks a bare temp dir with no trailing slash, as -L and -rpath produce', () => {
expect(utils.maskRootdirsInText(`-L${tmp}`)).toEqual('-L/app');
});
// The value that motivated this: a single env var holding four temp paths in two shapes.
it('masks a realistic LDFLAGS value completely', () => {
expect(
utils.maskRootdirsInText(`-L${tmp} -Wl,-rpath,${tmp} -Wl,-rpath,${tmp}/fmt/lib -L${tmp}/fmt/lib`),
).toEqual('-L/app -Wl,-rpath,/app -Wl,-rpath,/app/fmt/lib -L/app/fmt/lib');
});
it('leaves text with no temp paths untouched', () => {
expect(utils.maskRootdirsInText('-L/opt/compiler-explorer/gcc-15.2.0/lib64')).toEqual(
'-L/opt/compiler-explorer/gcc-15.2.0/lib64',
);
});
it('is idempotent', () => {
const once = utils.maskRootdirsInText(`-L${tmp} -I${tmp}/include`);
expect(utils.maskRootdirsInText(once)).toEqual(once);
});
it.each([undefined, null, ''])('hands %p straight back', input => {
expect(utils.maskRootdirsInText(input as unknown as string)).toEqual(input);
});
});
describe('maskRootdir', () => {
it('masks a CE temp path down to the user-facing filename', () => {
expect(utils.maskRootdir('/tmp/compiler-explorer-compiler123-4-abc/example.cpp')).toEqual('example.cpp');
});
it('keeps /app/ when the temp path is embedded (e.g. a -I include flag)', () => {
expect(utils.maskRootdir('-I/tmp/compiler-explorer-compiler123-4-abc/include')).toEqual('-I/app/include');
});
// The recorded path's tmpdir root need not match this process's os.tmpdir(), so
// masking keys off the ce_temp_prefix marker, not the root.
it.each([
['/tmp', '/tmp/compiler-explorer-compilerXYZ/example.cpp'],
['/usr/tmp', '/usr/tmp/compiler-explorer-compilerXYZ/example.cpp'],
['macOS /private/var', '/private/var/folders/ab/xyz/T/compiler-explorer-compilerXYZ/example.cpp'],
['Windows', 'C:/Users/ce/AppData/Local/Temp/compiler-explorer-compilerXYZ/example.cpp'],
])('masks a temp path rooted at %s regardless of the local tmpdir', (_root, input) => {
expect(utils.maskRootdir(input)).toEqual('example.cpp');
});
it('leaves non-temp paths untouched', () => {
expect(utils.maskRootdir('/usr/include/stdio.h')).toEqual('/usr/include/stdio.h');
});
// Callers reach here through type holes that hand over an undefined, which is what the
// falsy guard is for. Both entry points have to survive it, not just one.
it.each([undefined, null, ''])('hands %p straight back', input => {
expect(utils.maskRootdir(input as unknown as string)).toEqual(input);
expect(utils.maskRootdirKeepingAppPrefix(input as unknown as string)).toEqual(input);
});
// The marker segment must be followed by `/`, so a bare dir with no trailing slash
// is left alone. (Real inputs always name a file inside the dir.)
it('leaves a bare temp dir with no trailing slash untouched', () => {
expect(utils.maskRootdir('/tmp/compiler-explorer-compiler123-4-abc')).toEqual(
'/tmp/compiler-explorer-compiler123-4-abc',
);
});
it('masks a bare temp dir with a trailing slash to empty', () => {
expect(utils.maskRootdir('/tmp/compiler-explorer-compiler123-4-abc/')).toEqual('');
});
it('leaves paths with invalid marker suffix chars untouched', () => {
expect(utils.maskRootdir('/tmp/compiler-explorer-compiler+bad/example.cpp')).toEqual(
'/tmp/compiler-explorer-compiler+bad/example.cpp',
);
});
it('handles long non-matching path-like inputs without altering them', () => {
const input = `-I/${'segment/'.repeat(4000)}not-temp/include`;
expect(utils.maskRootdir(input)).toEqual(input);
});
// The regex stops at whitespace (so multi-token output lines aren't over-masked),
// so a temp root containing a space is only masked from the last space onward.
it('masks only from the last space in a spaced temp root (documented limitation)', () => {
expect(utils.maskRootdir('/tmp/with space/compiler-explorer-compilerXXX/example.cpp')).toEqual(
'/tmp/with space/app/example.cpp',
);
});
it('passes empty input through unchanged', () => {
expect(utils.maskRootdir('')).toEqual('');
});
});
describe('resolveWithinDir', () => {
it('joins a filename onto the directory', () => {
if (process.platform === 'win32') {
expect(utils.resolveWithinDir('c:/tmp/somefolder', 'test.h')).toEqual('c:\\tmp\\somefolder\\test.h');
expect(utils.resolveWithinDir('c:/tmp/somefolder', 'test.txt')).toEqual('c:\\tmp\\somefolder\\test.txt');
} else {
expect(utils.resolveWithinDir('/tmp/somefolder', 'test.h')).toEqual('/tmp/somefolder/test.h');
expect(utils.resolveWithinDir('/tmp/somefolder', 'test.txt')).toEqual('/tmp/somefolder/test.txt');
}
});
it('allows a subdirectory of the directory', () => {
expect(utils.resolveWithinDir('/tmp/somefolder', 'subfolder/hello.h')).toEqual(
path.normalize('/tmp/somefolder/subfolder/hello.h'),
);
});
it('rejects a filename that traverses out of the directory', () => {
expect(() => utils.resolveWithinDir('/tmp/somefolder', '../test.h')).toThrow(Error);
expect(() => utils.resolveWithinDir('/tmp/somefolder', './../test.h')).toThrow(Error);
expect(() => utils.resolveWithinDir('/tmp/somefolder', 'test_hello/../../etc/passwd')).toThrow(Error);
});
it('rejects a sibling that shares the directory name as a prefix', () => {
expect(() => utils.resolveWithinDir('/tmp/somefolder', '../somefolder-evil/test.h')).toThrow(Error);
expect(() => utils.resolveWithinDir('/tmp/somefolder', '../somefolder.txt')).toThrow(Error);
});
it('rejects a filename that resolves to the directory itself', () => {
expect(() => utils.resolveWithinDir('/tmp/somefolder', '')).toThrow(Error);
expect(() => utils.resolveWithinDir('/tmp/somefolder', '.')).toThrow(Error);
});
it('reports containment without joining', () => {
expect(utils.isPathInside('/tmp/somefolder', '/tmp/somefolder/test.h')).toBe(true);
expect(utils.isPathInside('/tmp/somefolder', '/tmp/somefolder/sub/test.h')).toBe(true);
expect(utils.isPathInside('/tmp/somefolder', '/tmp/somefolder')).toBe(false);
expect(utils.isPathInside('/tmp/somefolder', '/tmp/somefolder-evil/test.h')).toBe(false);
expect(utils.isPathInside('/tmp/somefolder', '/tmp/test.h')).toBe(false);
});
it('anchors an absolute filename inside the directory rather than honouring it', () => {
expect(utils.resolveWithinDir('/tmp/somefolder', '/tmp/someotherfolder/test.h')).toEqual(
path.normalize('/tmp/somefolder/tmp/someotherfolder/test.h'),
);
if (process.platform === 'win32') {
expect(utils.resolveWithinDir('/tmp/somefolder', '\\test.h')).toEqual('\\tmp\\somefolder\\test.h');
}
});
});