mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-04 08:04:37 +08:00
- Record CommonJS builtin reads through bracket notation and destructuring, so a native consumer of a patched member is islanded or reported with SC8020. A computed key or rest element reads the whole module and matches any patch of it. - Keep the named-data walk of an island module inside its directory: a symbolic link that leads out of it is not followed, and a link loop ends the walk. - Skip the extension check for import type and export type, which Node erases without resolving the specifier. - Scan TypeScript island modules for the program modules they import, so a stateful module used by both sides is shared instead of copied. - Export the Test262 assertion form of compareArray as assertCompareArray, so the harness no longer declares compareArray twice, and give island tests the bare compareArray as native ones have.
410 lines
20 KiB
JavaScript
410 lines
20 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { readFileSync, readdirSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { load, JSON_SCHEMA } from "js-yaml";
|
|
import ts from "typescript";
|
|
|
|
export const directory = fileURLToPath(new URL(".", import.meta.url));
|
|
export const vendorRoot = join(directory, "vendor");
|
|
export const pin = JSON.parse(readFileSync(join(directory, "upstream.json"), "utf8"));
|
|
export const completion = "__scriptc_test262_complete__";
|
|
export const harnessSource = readFileSync(join(directory, "harness.ts"), "utf8");
|
|
export const assertThrowsSource = readFileSync(join(directory, "assert-throws.js"), "utf8");
|
|
export const propertyHelperSource = readFileSync(join(directory, "property-helper.js"), "utf8");
|
|
export const typedArrayHelperSource = readFileSync(join(directory, "test-typed-array.js"), "utf8");
|
|
const adaptedIncludes = new Set(["compareArray.js", "propertyHelper.js", "testTypedArray.js"]);
|
|
export const expectations = JSON.parse(readFileSync(join(directory, "expectations.json"), "utf8"));
|
|
export const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
|
|
export function filesBelow(root, prefix) {
|
|
const paths = [];
|
|
for (const entry of readdirSync(join(root, prefix), { withFileTypes: true })) {
|
|
const path = `${prefix}/${entry.name}`;
|
|
if (entry.isDirectory()) paths.push(...filesBelow(root, path));
|
|
else if (entry.isFile()) paths.push(path);
|
|
else throw new Error(`Test262 snapshot contains a non-regular entry: ${path}`);
|
|
}
|
|
return paths.sort();
|
|
}
|
|
|
|
// Covers fixtures and harness files too; a checkout with edited tests cannot
|
|
// silently claim the pinned revision. Paths are POSIX on every host.
|
|
export function snapshotDigest(root) {
|
|
const paths = ["LICENSE", ...filesBelow(root, "harness"), ...filesBelow(root, "test")].sort();
|
|
const hash = createHash("sha256");
|
|
for (const path of paths) {
|
|
hash.update(path).update("\0").update(sha256(readFileSync(join(root, path)))).update("\n");
|
|
}
|
|
return hash.digest("hex");
|
|
}
|
|
|
|
export function verifyVendor() {
|
|
for (const [path, expected] of Object.entries(pin.files)) {
|
|
if (sha256(readFileSync(join(vendorRoot, path))) !== expected) {
|
|
throw new Error(`Pinned Test262 file changed: ${path}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function testPaths(root) {
|
|
return filesBelow(root, "test").filter((path) => path.endsWith(".js") && !path.includes("_FIXTURE"));
|
|
}
|
|
|
|
const knownFlags = new Set([
|
|
"onlyStrict", "noStrict", "module", "raw", "async", "generated",
|
|
"CanBlockIsFalse", "CanBlockIsTrue", "non-deterministic",
|
|
]);
|
|
|
|
const unsupportedPropertyHelpers = new Set([
|
|
"verifyCallableProperty", "verifyAccessorProperty", "verifyEqualTo", "verifyWritable", "verifyNotWritable",
|
|
"verifyEnumerable", "verifyNotEnumerable", "verifyConfigurable", "verifyNotConfigurable",
|
|
"verifyPrimordialProperty", "verifyPrimordialCallableProperty", "verifyPrimordialAccessorProperty",
|
|
"isWritable", "isEnumerable", "isConfigurable", "isSameValue",
|
|
]);
|
|
const descriptorFields = new Set(["value", "writable", "enumerable", "configurable", "get", "set"]);
|
|
|
|
function inlinePropertyDescriptor(node) {
|
|
if (!node) return false;
|
|
if (ts.isIdentifier(node) && node.text === "undefined") return true;
|
|
return ts.isObjectLiteralExpression(node) && node.properties.every((field) =>
|
|
ts.isPropertyAssignment(field) && ts.isIdentifier(field.name) && descriptorFields.has(field.name.text));
|
|
}
|
|
|
|
export function metadata(source, path = "test.js") {
|
|
const match = /\/\*---([\s\S]*?)---\*\//.exec(source);
|
|
if (!match) throw new Error(`${path}: missing Test262 frontmatter`);
|
|
const value = load(match[1], { schema: JSON_SCHEMA });
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path}: invalid metadata`);
|
|
for (const key of ["flags", "includes", "features"]) {
|
|
value[key] ??= [];
|
|
if (!Array.isArray(value[key]) || value[key].some((item) => typeof item !== "string")) {
|
|
throw new Error(`${path}: invalid ${key}`);
|
|
}
|
|
}
|
|
for (const flag of value.flags) {
|
|
if (!knownFlags.has(flag)) throw new Error(`${path}: unknown flag ${flag}`);
|
|
}
|
|
if (value.flags.includes("onlyStrict") && value.flags.includes("noStrict")) {
|
|
throw new Error(`${path}: conflicting execution flags`);
|
|
}
|
|
if (value.negative && (!['parse', 'resolution', 'runtime'].includes(value.negative.phase) || typeof value.negative.type !== "string")) {
|
|
throw new Error(`${path}: invalid negative metadata`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function variants(meta) {
|
|
if (meta.flags.includes("module")) return ["module"];
|
|
if (meta.flags.includes("raw")) return ["raw"];
|
|
if (meta.flags.includes("onlyStrict")) return ["strict"];
|
|
if (meta.flags.includes("noStrict")) return ["sloppy"];
|
|
return ["sloppy", "strict"];
|
|
}
|
|
|
|
function hasOwnThisBinding(node) {
|
|
let child = node;
|
|
for (let parent = node.parent; parent; child = parent, parent = parent.parent) {
|
|
if (ts.isArrowFunction(parent)) continue;
|
|
if (ts.isFunctionLike(parent) && child === parent.body) return true;
|
|
if (ts.isPropertyDeclaration(parent) && child === parent.initializer) return true;
|
|
if (ts.isClassStaticBlockDeclaration(parent) && child === parent.body) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function argumentsOwner(node) {
|
|
for (let parent = node.parent; parent; parent = parent.parent) {
|
|
if (ts.isArrowFunction(parent)) continue;
|
|
if (ts.isFunctionLike(parent) && parent.body) return parent;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// The only exclusions the island lane lifts (decision S4): each of these
|
|
// names a construct the standalone strict adapter cannot express but Node's
|
|
// own engine can, and an island variant runs under Node. Everything else —
|
|
// the harness includes, the assertion surface, `$262` — stays excluded,
|
|
// because the adapter still has to run for an island too.
|
|
const LIFTED_IN_ISLAND = new Set([
|
|
"host:eval",
|
|
"host:Function",
|
|
"host:arguments",
|
|
"host:script-environment",
|
|
"host:globalThis",
|
|
]);
|
|
|
|
// The first static profile adapts scripts to standalone strict modules. Be
|
|
// conservative about observable global-script semantics and helper reflection.
|
|
// Exclusions are runner limitations, never implementation support claims.
|
|
// `islands` reports which variants the island lane could measure: it never
|
|
// widens the native profile, and it lifts the five host reasons on sloppy
|
|
// variants only.
|
|
export function exclusion(source, meta, variant, islands = false) {
|
|
if (meta.negative) {
|
|
if (meta.negative.phase !== "parse" || meta.negative.type !== "SyntaxError" || (variant !== "strict" && variant !== "sloppy")) {
|
|
return `negative-phase:${meta.negative.phase}`;
|
|
}
|
|
return undefined;
|
|
}
|
|
if (variant !== "strict" && variant !== "sloppy") return `execution:${variant}`;
|
|
if (meta.flags.some((flag) => flag.startsWith("CanBlock"))) return "host:agents";
|
|
const unsupportedIncludes = meta.includes.filter((name) => !adaptedIncludes.has(name));
|
|
if (unsupportedIncludes.length) return `harness-includes:${unsupportedIncludes.join(",")}`;
|
|
const sf = ts.createSourceFile("test.js", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
|
|
let reason;
|
|
const propertyHelper = meta.includes.includes("propertyHelper.js");
|
|
const typedArrayHelper = meta.includes.includes("testTypedArray.js");
|
|
const forbidden = new Set(["$262", "$DONE", "$DONOTEVALUATE", "globalThis", "eval", "Function", "print", "process", "require"]);
|
|
if (variant === "sloppy") for (const name of ["module", "exports", "__dirname", "__filename"]) forbidden.add(name);
|
|
if (meta.flags.includes("async")) forbidden.delete("$DONE");
|
|
const liftable = (item) => islands && variant === "sloppy" && LIFTED_IN_ISLAND.has(item);
|
|
const refuse = (item) => { if (reason === undefined && !liftable(item)) reason = item; };
|
|
const visit = (node) => {
|
|
if (reason !== undefined) return;
|
|
if ((node.kind === ts.SyntaxKind.ThisKeyword && !hasOwnThisBinding(node)) ||
|
|
node.kind === ts.SyntaxKind.ImportKeyword || ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
|
|
refuse("host:script-environment");
|
|
} else if (ts.isIdentifier(node) && forbidden.has(node.text)) {
|
|
refuse(`host:${node.text}`);
|
|
} else if (ts.isIdentifier(node) && node.text === "arguments" &&
|
|
(!argumentsOwner(node) ||
|
|
!(ts.isPropertyAccessExpression(node.parent) && node.parent.expression === node && node.parent.name.text === "length") &&
|
|
!(ts.isElementAccessExpression(node.parent) && node.parent.expression === node))) {
|
|
refuse("host:arguments");
|
|
} else if (ts.isIdentifier(node) && node.text === "assert") {
|
|
const parent = node.parent;
|
|
if (ts.isCallExpression(parent) && parent.expression === node) {
|
|
// assert(value, message)
|
|
} else if (ts.isPropertyAccessExpression(parent) && parent.expression === node &&
|
|
ts.isCallExpression(parent.parent) && parent.parent.expression === parent &&
|
|
(["sameValue", "notSameValue", "compareArray"].includes(parent.name.text) ||
|
|
(parent.name.text === "throws" &&
|
|
parent.parent.arguments[0] !== undefined &&
|
|
ts.isIdentifier(parent.parent.arguments[0]) &&
|
|
["Error", "TypeError", "RangeError", "SyntaxError", "ReferenceError", "EvalError", "URIError"].includes(parent.parent.arguments[0].text)))
|
|
) {
|
|
// Supported assertion calls; aliases, mutations, and reflection stay out.
|
|
} else refuse("harness:assert-surface");
|
|
} else if (meta.includes.includes("compareArray.js") && ts.isIdentifier(node) && node.text === "compareArray" &&
|
|
!(ts.isCallExpression(node.parent) && node.parent.expression === node) &&
|
|
!(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node && ts.isIdentifier(node.parent.expression) && node.parent.expression.text === "assert")) {
|
|
// assert.js defines the bare compareArray; only its direct call form is
|
|
// hosted (a value reference or property read would need the upstream
|
|
// function object and its format expando).
|
|
reason = "harness:compareArray-surface";
|
|
} else if (ts.isIdentifier(node) && node.text === "Test262Error") {
|
|
if (!ts.isNewExpression(node.parent) || node.parent.expression !== node) refuse("harness:Test262Error-surface");
|
|
} else if (typedArrayHelper && ts.isIdentifier(node) && node.text === "TypedArray") {
|
|
// The adapted testTypedArray.js leaves out %TypedArray%.
|
|
refuse("harness:TypedArray-intrinsic");
|
|
} else if (propertyHelper && ts.isIdentifier(node)) {
|
|
if (unsupportedPropertyHelpers.has(node.text)) refuse("harness:propertyHelper-surface");
|
|
else if (node.text === "verifyProperty") {
|
|
const call = node.parent;
|
|
if (!ts.isCallExpression(call) || call.expression !== node || call.arguments.length < 3 || !inlinePropertyDescriptor(call.arguments[2])) {
|
|
refuse("harness:propertyHelper-surface");
|
|
}
|
|
}
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
visit(sf);
|
|
return reason;
|
|
}
|
|
|
|
export function prepare(source, asyncTest = false, marker = completion, variant = "strict", includes = []) {
|
|
const names = asyncTest
|
|
? "assert, compareArray, Test262Error, $DONE"
|
|
: "assert, compareArray, Test262Error";
|
|
const end = asyncTest ? "" : `;console.log(${JSON.stringify(marker)});\n`;
|
|
const helpers =
|
|
(includes.includes("propertyHelper.js") ? `${propertyHelperSource}\n` : "") +
|
|
(includes.includes("testTypedArray.js") ? `${typedArrayHelperSource}\n` : "");
|
|
if (variant === "sloppy") return `const { ${names} } = require("./harness.ts");\n${helpers}${source}\n${end}`;
|
|
return `"use strict";\nimport { ${names} } from "./harness.ts";\n${helpers}${source}\n${end}`;
|
|
}
|
|
|
|
// The adapted test of the island lane (decision S4). It runs under Node, so
|
|
// `assert` is a JavaScript object built there: a native function crosses the
|
|
// bridge as a callable, but the namespace members attached to it do not (they
|
|
// are properties of the native function object). The harness exports each
|
|
// helper on its own for exactly this.
|
|
//
|
|
// The body normally runs as the island module itself. A lifted
|
|
// `host:script-environment` variant keeps its script shape instead: the
|
|
// module carries the body between these markers only as a dead copy the
|
|
// pre-scan can island, and replays it as script code inside the island.
|
|
const scriptBegin = "//@@SCRIPTC_TEST262_SCRIPT_BEGIN@@";
|
|
const scriptEnd = "//@@SCRIPTC_TEST262_SCRIPT_END@@";
|
|
|
|
export function prepareIsland(source, asyncTest = false, marker = completion, includes = [], script = false) {
|
|
const helpers =
|
|
(includes.includes("propertyHelper.js") ? `${propertyHelperSource}\n` : "") +
|
|
(includes.includes("testTypedArray.js") ? `${typedArrayHelperSource}\n` : "");
|
|
const end = asyncTest ? "" : `;console.log(${JSON.stringify(marker)});\n`;
|
|
const lines = [
|
|
'const harness = require("./harness.ts");',
|
|
"const assert = (value, message) => harness.assert(value, message);",
|
|
"assert.sameValue = harness.sameValue;",
|
|
"assert.notSameValue = harness.notSameValue;",
|
|
"assert.throws = harness.assertThrows;",
|
|
"assert.compareArray = harness.assertCompareArray;",
|
|
"const compareArray = harness.compareArray;",
|
|
"const Test262Error = harness.Test262Error;",
|
|
...(asyncTest ? ["const $DONE = harness.$DONE;"] : []),
|
|
];
|
|
if (!script) return `${lines.join("\n")}\n${helpers}${source}\n${end}`;
|
|
// A lifted `host:script-environment` variant is a script, not a module: its
|
|
// top-level `this` is the global object and its top-level `var` bindings are
|
|
// global properties. The island therefore replays the body as script code,
|
|
// and the module carries it only as a dead copy the pre-scan can island.
|
|
const globals = [
|
|
"globalThis.assert = assert;",
|
|
"globalThis.compareArray = compareArray;",
|
|
"globalThis.Test262Error = Test262Error;",
|
|
...(asyncTest ? ["globalThis.$DONE = $DONE;"] : []),
|
|
];
|
|
return [
|
|
'const { readFileSync } = require("node:fs");',
|
|
'const vm = require("node:vm");',
|
|
...lines,
|
|
...globals,
|
|
`const begin = ${JSON.stringify(scriptBegin)};`,
|
|
`const stop = ${JSON.stringify(scriptEnd)};`,
|
|
'const text = readFileSync(__filename, "utf8");',
|
|
// The markers also appear in the `begin`/`stop` definitions, so the body
|
|
// is the last block that starts with the begin marker.
|
|
"const from = text.lastIndexOf(begin);",
|
|
"const to = text.indexOf(stop, from + begin.length);",
|
|
'if (from < 0 || to < 0) throw new Error("the adapted script markers are missing");',
|
|
`vm.runInThisContext(text.slice(from + begin.length, to) + ${JSON.stringify(end)}, { filename: __filename });`,
|
|
"if (false) { (function () {",
|
|
scriptBegin,
|
|
`${helpers}${source}`,
|
|
scriptEnd,
|
|
"}); }",
|
|
].join("\n");
|
|
}
|
|
|
|
/** `host:script-environment` also covers ESM syntax; only a CommonJS-shaped
|
|
* test can be replayed as a script inside its island. */
|
|
export function scriptEnvironment(source) {
|
|
const sf = ts.createSourceFile("test.js", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
|
|
return !ts.isExternalModule(sf);
|
|
}
|
|
|
|
function parseDiagnostics(source, variant) {
|
|
if (variant !== "strict" && variant !== "sloppy") return [];
|
|
const prepared = prepare(source, false, completion, variant);
|
|
const sourceStart = prepared.indexOf(source);
|
|
const sourceEnd = sourceStart + source.length;
|
|
const file = ts.createSourceFile(variant === "sloppy" ? "main.cjs" : "main.js", prepared, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
|
|
const raw = variant === "sloppy"
|
|
? ts.createSourceFile("test.js", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS).parseDiagnostics
|
|
: [];
|
|
return file.parseDiagnostics.filter((diagnostic) =>
|
|
diagnostic.start >= sourceStart && diagnostic.start < sourceEnd &&
|
|
(variant === "strict" || raw.some((item) =>
|
|
item.start === diagnostic.start - sourceStart &&
|
|
ts.flattenDiagnosticMessageText(item.messageText, "\n") ===
|
|
ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"))));
|
|
}
|
|
|
|
// TypeScript reports these JavaScript grammar and early errors in its semantic
|
|
// pass. Matching the compiler's diagnostic at the original source location
|
|
// keeps type-checking errors and the harness's $DONOTEVALUATE error out.
|
|
const earlyErrorCodes = new Set([
|
|
1005, 1013, 1048, 1091, 1107, 1108, 1115, 1116, 1136, 1155, 1156, 1186,
|
|
1190, 1213, 1214, 1215, 1325, 1346, 1347, 1358, 1359,
|
|
1499, 1500, 1502, 1504, 1505, 1506, 1507, 1508, 1509, 1510, 1511, 1512,
|
|
1513, 1514, 1515, 1516, 1517, 1518, 1519, 1520, 1521, 1522, 1523, 1524,
|
|
1525, 1526, 1527, 1528, 1529, 1530, 1531, 1532, 1533, 1534, 1535,
|
|
18006, 18011, 18012, 18016, 2300, 2337, 2364, 2451, 2462, 2491, 2523,
|
|
2779, 2804, 17012, 18061,
|
|
]);
|
|
|
|
function earlyErrorDiagnostics(source, variant) {
|
|
const prepared = prepare(source, false, completion, variant);
|
|
const start = prepared.indexOf(source);
|
|
const end = start + source.length;
|
|
const suffix = variant === "sloppy" ? "main.cjs" : "main.js";
|
|
const fileName = join(directory, "__test262_parse__", suffix);
|
|
const isMain = (path) => path.replaceAll("\\", "/").endsWith(`/__test262_parse__/${suffix}`);
|
|
const options = {
|
|
allowJs: true, checkJs: true, noEmit: true, noLib: true, types: [],
|
|
strict: true, target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext,
|
|
};
|
|
const host = ts.createCompilerHost(options);
|
|
host.fileExists = isMain;
|
|
host.readFile = (path) => isMain(path) ? prepared : undefined;
|
|
host.getSourceFile = (path, languageVersion) =>
|
|
isMain(path) ? ts.createSourceFile(path, prepared, languageVersion, true) : undefined;
|
|
const program = ts.createProgram([fileName], options, host);
|
|
const file = program.getSourceFile(fileName);
|
|
if (!file) return [];
|
|
return program.getSemanticDiagnostics(file).filter((diagnostic) =>
|
|
earlyErrorCodes.has(diagnostic.code) && diagnostic.start >= start && diagnostic.start < end &&
|
|
!(variant === "sloppy" && (diagnostic.code === 1214 || diagnostic.code === 1215)));
|
|
}
|
|
|
|
// The compiler's OWN early errors (frontend/early-errors.ts): static
|
|
// semantics tsc does not model, reported at preflight under their own codes.
|
|
// A negative-parse test only asks for SOME SyntaxError, so one of these
|
|
// inside the test's source is a match — the same standard as the tsc
|
|
// diagnostics above, minus tsc's ability to tell us where it points.
|
|
const ownEarlyErrorCodes = new Set(["SC0005", "SC0006", "SC0007"]);
|
|
|
|
export function matchesParseNegative(outcome, source, variant = "strict") {
|
|
if (outcome.status !== "compile-refusal") return false;
|
|
{
|
|
const prepared = prepare(source, false, completion, variant);
|
|
const start = prepared.indexOf(source);
|
|
const end = start + source.length;
|
|
const suffix = variant === "sloppy" ? "/main.cjs" : "/main.js";
|
|
const own = outcome.diagnostics?.some(
|
|
(reported) =>
|
|
ownEarlyErrorCodes.has(reported.code) &&
|
|
reported.loc?.file?.endsWith(suffix) &&
|
|
reported.loc.start >= start &&
|
|
reported.loc.start < end,
|
|
);
|
|
if (own) return true;
|
|
}
|
|
const matches = (parsed) => outcome.diagnostics?.some((reported) =>
|
|
reported.code === "SC0001" &&
|
|
reported.loc?.file?.endsWith(variant === "sloppy" ? "/main.cjs" : "/main.js") &&
|
|
reported.loc.start === parsed.start &&
|
|
reported.message === ts.flattenDiagnosticMessageText(parsed.messageText, "\n"));
|
|
return parseDiagnostics(source, variant).some(matches) || earlyErrorDiagnostics(source, variant).some(matches);
|
|
}
|
|
|
|
export function summarize(results) {
|
|
const counts = {};
|
|
const exclusions = {};
|
|
for (const result of results) {
|
|
counts[result.status] = (counts[result.status] ?? 0) + 1;
|
|
if (result.status === "excluded") exclusions[result.reason] = (exclusions[result.reason] ?? 0) + 1;
|
|
}
|
|
return { counts, exclusions };
|
|
}
|
|
|
|
export function compileFailureStatus(diagnostics) {
|
|
if (diagnostics.some((d) => d.code === "SC0004" || /^SC9/.test(d.code))) return "compiler-error";
|
|
if (diagnostics.some((d) => d.code === "SC3003" || d.code === "SC3004")) return "build-error";
|
|
if (diagnostics.some((d) => d.loc?.file?.endsWith("harness.ts"))) return "harness-refusal";
|
|
return "compile-refusal";
|
|
}
|
|
|
|
export function matchesExpectation(id, outcome) {
|
|
// Decision S4: a variant whose entry module ran in a Node island passes
|
|
// for the profile — it proved Node's semantics, which is what the
|
|
// expectation asserts — but it stays a separate status, so the counts
|
|
// never report it as a native pass.
|
|
const status = outcome.status === "island-pass" ? "pass" : outcome.status;
|
|
const expected = expectations[id];
|
|
if (!expected) return status === "pass";
|
|
return status === expected.status && outcome.reason === expected.reason &&
|
|
outcome.stderr?.includes(expected.detail) === true;
|
|
}
|