Files
scriptc/tests/harness/errors.test.ts
T
Chris Tate 982ebac139 Accept typed-ref arguments in createCipheriv and the cipher methods
createCipheriv, createDecipheriv, update(), setAAD() and setAuthTag()
now read the value a typed-ref box holds when it is a string, view,
ArrayBuffer or KeyObject. ERR_INVALID_ARG_TYPE names the class of a
class instance that arrives through an untyped value, as Node does,
instead of Object. An unknown output encoding that is not a string is
rendered as Node's %s formats it (util.inspect at depth 0, or the
object's own toString); a function, or an instance of a class that
defines toString, throws a coded SC2020 error. Cipher programs link the
inspect unit for that rendering.

5250 passes each argument through `any`, including class instances,
records and arrays that Node rejects, and 5254 covers output encodings
that are not strings from JavaScript.
2026-10-15 18:47:02 -05:00

902 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Error-object behavior Node cannot oracle — scriptc-only.
*
* The differential corpus (1300–1306) covers everything observable in
* lockstep with Node: construction/fields/toString, subclasses, typed-catch
* narrowing, async rejections, runtime failures caught as instances. What
* lives here is the uncaught STDERR contract: Node prints a source excerpt
* and a stack trace (and its display shows the CONSTRUCTOR's name where it
* differs from .name); scriptc prints one `Uncaught <toString>` line —
* SEMANTICS.md divergence 11. These pins keep that line's shape exact.
*
* SCRIPTC_SAN=1 builds these with ASan + the RC audit like every harness lane.
*/
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { describe, expect, test } from "vitest";
import { compile } from "@scriptc/compiler";
const execFileAsync = promisify(execFile);
const repoRoot = join(import.meta.dirname, "../..");
const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests");
const sanitize = process.env["SCRIPTC_SAN"] === "1";
interface RunResult {
stdout: string;
stderr: string;
exitCode: number;
}
/** Compiles an inline program and runs the binary, tolerating nonzero exit.
* `ext` picks the frontend lane — "cjs" for JS-only shapes (deferred
* fences that tsc's arity families would reject in TS). `extraFiles`
* materialize beside the entry (multi-module shapes: the CJS link-error
* pins import sibling .cjs modules). */
async function compileAndRun(
name: string,
source: string,
ext = "ts",
extraFiles: Record<string, string> = {},
): Promise<RunResult> {
const key = createHash("sha256")
.update(source)
.update(JSON.stringify(extraFiles))
.update(sanitize ? "san" : "plain")
.digest("hex")
.slice(0, 16);
// Multi-module programs live OUTSIDE node_modules: the cache dir sits
// under it, where the resolver classifies sibling modules as npm files
// (correctly — for real packages) and the relative import stops
// resolving into the program.
const outDir =
Object.keys(extraFiles).length > 0
? join(tmpdir(), `scriptc-errors-${key}`)
: join(cacheDir, `errors-${key}`);
mkdirSync(outDir, { recursive: true });
const file = join(outDir, `${name}.${ext}`);
writeFileSync(file, source);
for (const [extraName, text] of Object.entries(extraFiles)) {
writeFileSync(join(outDir, extraName), text);
}
const result = await compile(file, {
outPath: join(outDir, name),
outDir,
sanitize,
backend: "llvm",
});
if (!result.ok) {
throw new Error(
"errors program failed to compile:\n" +
result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"),
);
}
try {
const { stdout, stderr } = await execFileAsync(result.binaryPath, [], { encoding: "utf8" });
return { stdout, stderr: stripAsanFiberWarning(stderr), exitCode: 0 };
} catch (err) {
const e = err as { code?: unknown; stdout?: string; stderr?: string };
if (typeof e.code !== "number") throw err;
return {
stdout: e.stdout ?? "",
stderr: stripAsanFiberWarning(e.stderr ?? ""),
exitCode: e.code,
};
}
}
/** Linux ASan prints a once-per-process warning to stderr the first time a
* fiber swapcontext()s (the interceptor has no off switch; Apple's ASan
* never intercepts ucontext, so the macOS lanes never see it). Sanitizer
* diagnostic noise, never program output — dropped before any stderr
* expectation, the RC-audit-skip-notice pattern from differential.test.ts. */
function stripAsanFiberWarning(s: string): string {
return s.replace(
/^==\d+==WARNING: ASan doesn't fully support makecontext\/swapcontext.*\n/gm,
"",
);
}
describe(`uncaught error objects (scriptc-only${sanitize ? ", sanitized" : ""})`, () => {
test("an uncaught builtin error prints Uncaught name: message", async () => {
const r = await compileAndRun(
"uncaught-builtin",
`console.log("before");
throw new TypeError("boom");
`,
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe("before\n");
expect(r.stderr).toBe("Uncaught TypeError: boom\n");
});
test("a subclass prints its name PROPERTY — 'Error' when never assigned", async () => {
// Node's uncaught display would show the constructor name (PlainSub)
// even though .name is "Error"; we print the property — divergence 11.
const r = await compileAndRun(
"uncaught-subclass",
`class PlainSub extends Error {}
throw new PlainSub("unnamed sub");
`,
);
expect(r.exitCode).toBe(1);
expect(r.stderr).toBe("Uncaught Error: unnamed sub\n");
});
test("toString's empty-half rules shape the line", async () => {
const empty = await compileAndRun("uncaught-empty", `throw new Error();\n`);
expect(empty.exitCode).toBe(1);
expect(empty.stderr).toBe("Uncaught Error\n");
const nameless = await compileAndRun(
"uncaught-nameless",
`const e = new Error("just the message");
e.name = "";
throw e;
`,
);
expect(nameless.exitCode).toBe(1);
expect(nameless.stderr).toBe("Uncaught just the message\n");
});
test("an uncaught checked-dynamic primitive prints its value, like Node", async () => {
// Node prints the thrown primitive itself (after its source excerpt);
// the value must never be lost behind a placeholder.
const text = await compileAndRun(
"uncaught-unknown-string",
`console.log("before");
const v: unknown = process.argv.length > 99 ? 0 : "plain";
throw v;
`,
);
expect(text.exitCode).toBe(1);
expect(text.stdout).toBe("before\n");
expect(text.stderr).toBe("Uncaught plain\n");
const num = await compileAndRun(
"uncaught-unknown-number",
`const v: unknown = process.argv.length > 99 ? "x" : 42;
throw v;
`,
);
expect(num.exitCode).toBe(1);
expect(num.stderr).toBe("Uncaught 42\n");
});
test("an unhandled ERROR rejection renders name: message too", async () => {
const r = await compileAndRun(
"unhandled-error-rejection",
`async function fail(): Promise<void> {
throw new RangeError("nobody awaits this");
}
fail();
console.log("sync done");
`,
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe("sync done\n");
expect(r.stderr).toBe("Unhandled promise rejection: RangeError: nobody awaits this\n");
});
});
// `x as Sub` on a class value is a CHECKED downcast, and `x!` / `u as Arm`
// on a union value is a checked extraction: a stored value outside the
// target throws a catchable TypeError where Node's erasure would let the
// value flow on. The type and message are scriptc's contract (corpus 4432
// pins the Node-observable part: the failure is a TypeError raised before
// any later read). Plain operands are tested inline at the use and call
// operands through the interned helper; both must fail identically.
describe("checked class downcasts and extractions", () => {
test("failed downcasts throw the TypeError naming the target class", async () => {
const r = await compileAndRun(
"checked-downcast-messages",
`class Base {
kind = 0;
}
class Sub extends Base {
text = "sub";
}
class Other {
tag = "other";
}
function direct(b: Base): string {
return (b as Sub).text;
}
function optional(b: Base | undefined): string {
return (b as Sub).text;
}
function mixed(v: Base | Other): string {
return (v as Sub).text;
}
function make(b: Base | undefined): Base | undefined {
return b;
}
function called(b: Base | undefined): string {
return (make(b) as Sub).text;
}
function asserted(b: Base | undefined): number {
return b!.kind;
}
function assertedCall(b: Base | undefined): number {
return make(b)!.kind;
}
const runs: Array<() => unknown> = [
() => direct(new Sub()),
() => direct(new Base()),
() => optional(undefined),
() => optional(new Base()),
() => mixed(new Other()),
() => called(new Base()),
() => called(undefined),
() => asserted(new Base()),
() => asserted(undefined),
() => assertedCall(undefined),
];
for (const run of runs) {
try {
console.log("ok", run());
} catch (e) {
console.log(e instanceof TypeError, String(e));
}
}
direct(new Base());
`,
);
expect(r.stdout).toBe(
[
"ok sub",
"true TypeError: Value is not an instance of 'Sub'",
"true TypeError: Value is not an instance of 'Sub'",
"true TypeError: Value is not an instance of 'Sub'",
"true TypeError: Value is not an instance of 'Sub'",
"true TypeError: Value is not an instance of 'Sub'",
"true TypeError: Value is not an instance of 'Sub'",
"ok 0",
"true TypeError: undefined is not representable in the target union (a value narrowed or asserted past it still held it)",
"true TypeError: undefined is not representable in the target union (a value narrowed or asserted past it still held it)",
"",
].join("\n"),
);
expect(r.stderr).toBe("Uncaught TypeError: Value is not an instance of 'Sub'\n");
expect(r.exitCode).toBe(1);
});
});
// `e as C` on a catch binding is a CHECKED cast: a payload outside C's
// hierarchy throws a catchable TypeError where Node's erasure would read
// undefined off the value — dynCheck's documented trust-but-verify stance
// extended to exception payloads. This is exactly the case that cannot be
// a differential test; pinned so the divergence stays deliberate.
describe("checked catch-binding casts", () => {
test("a mismatched `as Error` throws the catchable TypeError", async () => {
const r = await compileAndRun(
"caught-check-mismatch",
`try {
throw "stringy";
} catch (err) {
try {
console.log((err as Error).message);
} catch (inner) {
if (inner instanceof TypeError) console.log("checked:", inner.message);
}
}
`,
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe("checked: caught value is not an instance of Error (checked cast)\n");
expect(r.stderr).toBe("");
});
test("an uncaught mismatch propagates as the TypeError", async () => {
const r = await compileAndRun(
"caught-check-uncaught",
`try {
throw 42;
} catch (err) {
console.log((err as Error).message);
}
`,
);
expect(r.exitCode).toBe(1);
expect(r.stderr).toBe(
"Uncaught TypeError: caught value is not an instance of Error (checked cast)\n",
);
});
test("an EFFECTFUL surplus argument is a named deferred fence (JS lane)", async () => {
// Effect-free surplus args drop at compile time, JS-exact (corpus
// 1760 — the test-string-decoder writeSequences shape). An effectful
// one has no evaluation slot in the completed call, so instead of
// skipping it without a diagnostic the build defers a named SC1090 to the
// call site. JS-only: tsc's arity errors gate the TS lane.
const r = await compileAndRun(
"surplus-args-effectful",
`'use strict';
function two(a, b) {
return a + b;
}
function effectful() {
console.log('side effect JS would run');
return 0;
}
console.log(two(1, 2, effectful()));
`,
"cjs",
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe("");
expect(r.stderr).toMatch(
/^Uncaught Error: surplus arguments with side effects .* are not supported yet \[SC1090 at .*surplus-args-effectful\.cjs:9\]\n$/,
);
});
test.each([
{
label: "Array.from",
name: "array-from-length-elements",
source: `const arr = Array.from({ length: 2 }, () => new Date(0));`,
},
{
label: "flatMap",
name: "flatmap-elements",
source: `const arr = [1].flatMap(() => new Date(0));`,
},
{
label: "tuple map",
name: "tuple-map-elements",
source: `/** @type {[number]} */
const tuple = [1];
const arr = tuple.map(() => new Date(0));`,
},
])("$label preserves checked callback results (JS lane)", async ({ name, source }) => {
const program = source + "\nconsole.log(arr.length, arr[0].getTime());\n";
const r = await compileAndRun(name, program, "js");
const reference = await execFileAsync(process.execPath, ["--eval", program], {
encoding: "utf8",
});
expect(r).toEqual({ stdout: reference.stdout, stderr: reference.stderr, exitCode: 0 });
});
test("extending a property-assigned class ABOVE its assignment is a named deferred fence (JS lane)", async () => {
// The property-assigned class-expression family (corpus 2032/2033
// pins what lowers): `exports.O = class extends exports.I {}` with
// the base assigned BELOW would extend undefined in Node (TypeError
// at the derived statement) — the source-order guard defers a named
// fence instead of pinning a base that doesn't exist yet.
const r = await compileAndRun(
"extends-above-assignment",
`'use strict';
exports.O = class extends exports.I {};
exports.I = class {};
console.log('unreached');
`,
"cjs",
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe("");
expect(r.stderr).toMatch(
/^Uncaught Error: extending 'exports\.I' above the statement that assigns it .* \[SC1090 at .*extends-above-assignment\.cjs:2\]\n$/,
);
});
test("a REASSIGNED property-assigned class stays fenced instead of extending the wrong base (JS lane)", async () => {
// Two `exports.I = class …` assignments make the binding dynamic (the
// runtime base is whichever ran last). The second assignment's
// class-value flow fence fires first (nominal classval slots), and the
// extends resolver refuses to pin either class — the program fences
// instead of extending the first assignment's class.
const r = await compileAndRun(
"extends-reassigned-property",
`'use strict';
exports.I = class { a() { return 1; } };
exports.I = class { a() { return 2; } };
exports.O = class extends exports.I {};
console.log(new exports.O().a());
`,
"cjs",
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe("");
expect(r.stderr).toMatch(
/^Uncaught Error: .* \[SC1090 at .*extends-reassigned-property\.cjs:\d+\]\n$/,
);
});
test("a non-callable constructor field shadows its method and throws at the call (JS lane)", async () => {
const r = await compileAndRun(
"leaf-shadowing-class-defers",
`'use strict';
console.log("before");
class X {
constructor() {
this.m = "not callable";
}
m() {
console.log("method");
}
}
const x = new X();
x.m();
console.log("unreachable");
`,
"cjs",
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe("before\n");
expect(r.stderr).toBe("Uncaught TypeError: x.m is not a function\n");
});
test("a callable constructor field shadows an inherited method (JS lane)", async () => {
const r = await compileAndRun(
"derived-shadowing-class-defers",
`'use strict';
console.log("before");
class A {
foo() {
return 4;
}
}
class B extends A {
constructor() {
super();
this.foo = () => 3;
}
}
const i = new B();
console.log(i.foo());
`,
"cjs",
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe("before\n3\n");
expect(r.stderr).toBe("");
});
test("destructuring a SURFACED builtin global's member is a named fence at the pattern (JS lane)", async () => {
// `const { max } = Math` would detach a member whose lowering is
// keyed to its receiver (Math.max) — the pattern fences by member
// and global name instead of binding a token whose calls fail late.
// Corpus 2558 pins the OPAQUE globals, where members DO bind tokens.
const r = await compileAndRun(
"destructure-surfaced-global",
`'use strict';
const { max } = Math;
console.log(max(1, 2));
`,
"cjs",
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe("");
expect(r.stderr).toMatch(
/^Uncaught Error: destructuring the member 'max' of the builtin global 'Math' \(a detached member loses its receiver-keyed lowering — call it through the global\) is not supported yet \[SC1031 at .*destructure-surfaced-global\.cjs:2\]\n$/,
);
});
test("an OPAQUE global's destructured member binds a token; uses fence lazily per site (JS lane)", async () => {
// The node-suite webcrypto prologue: `const { subtle } =
// globalThis.crypto` binds the member identity token, so the program
// RUNS past the destructure (the eager pattern fence used to kill it
// at that line) and each use meets its own named fence.
const r = await compileAndRun(
"destructure-opaque-global",
`'use strict';
const { subtle } = globalThis.crypto;
console.log('bound', !!subtle);
subtle.digest('SHA-256');
`,
"cjs",
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe("bound true\n");
expect(r.stderr).toMatch(/^Uncaught TypeError: subtle\.digest is not a function\n$/);
});
test("a 7000-level expression is a named deferred fence, not a stack overflow (JS lane)", async () => {
// The binderBinaryExpressionStress depth: an AST deep enough that the
// PREFLIGHT walks (the checker facade's prefetch sweep, the require
// scans) used to overflow the JS stack as a RangeError ICE before the
// expression lowering's 200-level SC1090 fence could fire. Every
// whole-file walk is iterative now (walkPreorder), so the JS lane
// compiles and defers the named fence to the statement — the TS lane's
// compile-time twin is the deep-nesting-preflight diagnostics fixture.
// Parens carry the depth (a real 6500-term chain spends minutes in
// per-node type queries; the walk that used to crash is shape-blind).
const r = await compileAndRun(
"deep-nesting-chain",
`'use strict';
let x = ${"(".repeat(7000)}1${")".repeat(7000)};
console.log('after');
`,
"cjs",
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe("");
expect(r.stderr).toMatch(
/^Uncaught Error: expressions nested deeper than 200 levels are not supported yet \[SC1090 at .*deep-nesting-chain\.cjs:2\]\n$/,
);
});
test("process.on with a prototype-member event name fences by NAME", async () => {
// '__proto__' used to fall into the compiler's own signal table —
// `{ SIGINT: 2 }["__proto__"]` answered Object.prototype, which rode
// a numLit into the C as `[object Object]` (invalid C, cc.js death).
// The own() lookup makes it what every other unsupported process
// event is: a named deferred fence. Corpus 1761 pins the EMITTER
// side, where these names work and match Node byte-exactly.
const r = await compileAndRun(
"process-on-proto",
`'use strict';
process.on('__proto__', () => {});
`,
"cjs",
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe("");
expect(r.stderr).toMatch(
/^Uncaught Error: 'process\.on\("__proto__", \.\.\.\)' is part of the standard library types but has no scriptc lowering yet \[SC2020 at .*process-on-proto\.cjs:2\]\n$/,
);
});
});
// Node's CJS named-import LINK error: a named import of an export the CJS
// lexer cannot detect kills the graph before ANY module evaluates, and
// scriptc compiles the program to exactly that startup throw. The corpus
// (1613/1616/1618/1619) pins stdout+exit differentially; what lives here
// is the MESSAGE — Node's exact SyntaxError text, both flavors, hint
// construction included — which the exit-1 stdout-only contract cannot see.
describe("CJS named-import link SyntaxError messages", () => {
test("an entry-level import gets Node's CommonJS hint form, ` as ` rewritten", async () => {
const r = await compileAndRun(
"cjs-link-flavored",
`import { a as z, vis } from './table.cjs';
console.log('never runs', z, vis);
`,
"mjs",
{ "table.cjs": "'use strict';\nconst v = 5;\nmodule.exports = { vis: v, a: 7 };\n" },
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe("");
expect(r.stderr).toBe(
"Uncaught SyntaxError: Named export 'a' not found. The requested module './table.cjs' is a CommonJS module, which may not support all module.exports as named exports.\n" +
"CommonJS modules can always be imported via the default export, for example using:\n" +
"\n" +
"import pkg from './table.cjs';\n" +
"const { a: z, vis } = pkg;\n" +
"\n",
);
});
test("a failure below the entry keeps V8's generic wording", async () => {
// module_job.js rewrites the message only when the failing specifier
// is one of the ROOT module's own requests — mid.mjs's is not.
const r = await compileAndRun(
"cjs-link-generic",
`import { a } from './mid.mjs';
console.log('never runs', a);
`,
"mjs",
{
"mid.mjs": "export { a } from './table.cjs';\n",
"table.cjs": "'use strict';\nmodule.exports = { a: 7 };\n",
},
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe("");
expect(r.stderr).toBe(
"Uncaught SyntaxError: The requested module './table.cjs' does not provide an export named 'a'\n",
);
});
});
// Node's strip-only TypeScript loader keeps ordinary named imports in the
// ESM request graph. If the requested declaration is type-only, Node refuses
// during linking even though the TypeScript checker accepts the import. The
// corpus pins stdout and exit; these assertions pin scriptc's exact uncaught
// SyntaxError message and the local-name ordering used to choose the first
// failure.
describe("native ESM type-import link SyntaxError messages", () => {
test("an unqualified interface import fails before any module evaluates", async () => {
const r = await compileAndRun(
"esm-type-link",
`import { value, Shape as MissingShape } from './types.ts';
console.log('never runs', value);
`,
"ts",
{
"types.ts": `export interface Shape { value: number }
export const value = 7;
console.log('types evaluated');
`,
},
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe("");
expect(r.stderr).toBe(
"Uncaught SyntaxError: The requested module './types.ts' does not provide an export named 'Shape'\n",
);
});
test("a child re-export failure wins before the parent import is linked", async () => {
const r = await compileAndRun(
"esm-type-link-reexport",
`import { Zed as zed, Aa as aaa, value } from './middle.ts';
console.log('never runs', value);
`,
"ts",
{
"middle.ts": `export { Shape as Zed, Other as Aa, value } from './types.ts';
console.log('middle evaluated');
`,
"types.ts": `export interface Shape { value: number }
export type Other = string;
export const value = 9;
console.log('types evaluated');
`,
},
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe("");
expect(r.stderr).toBe(
"Uncaught SyntaxError: The requested module './types.ts' does not provide an export named 'Shape'\n",
);
});
test("direct imports choose the first missing binding by local name", async () => {
const r = await compileAndRun(
"esm-type-link-order",
`import { Shape as zed, Other as aaa } from './types.ts';
console.log('never runs');
`,
"ts",
{
"types.ts": `export interface Shape { value: number }
export type Other = string;
`,
},
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe("");
expect(r.stderr).toBe(
"Uncaught SyntaxError: The requested module './types.ts' does not provide an export named 'Other'\n",
);
});
test("native compilation retains the ESM link failure", async () => {
const r = await compileAndRun(
"esm-type-link-dynamic",
`// @dynamic
import { Shape as MissingShape } from './types.ts';
console.log('never runs');
`,
"ts",
{ "types.ts": "export interface Shape { value: number }\nconsole.log('types evaluated');\n" },
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe("");
expect(r.stderr).toBe(
"Uncaught SyntaxError: The requested module './types.ts' does not provide an export named 'Shape'\n",
);
});
test("default interface exports satisfy Node's strip-only default link", async () => {
const r = await compileAndRun(
"esm-default-interface-link",
`import DefaultShape from './types.ts';
type LocalShape = DefaultShape;
console.log('ok');
`,
"ts",
{ "types.ts": "export default interface DefaultShape { value: number }\n" },
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe("ok\n");
expect(r.stderr).toBe("");
});
test("explicit module packages do not expose default-interface placeholders", async () => {
const r = await compileAndRun(
"esm-default-interface-explicit-module",
`import DefaultShape from './types.ts';
type LocalShape = DefaultShape;
console.log('never runs');
`,
"ts",
{
"package.json": `{ "type": "module" }\n`,
"types.ts": "export default interface DefaultShape { value: number }\n",
},
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe("");
expect(r.stderr).toBe(
"Uncaught SyntaxError: The requested module './types.ts' does not provide an export named 'default'\n",
);
});
test("typeless packages preserve default-interface placeholders through re-exports", async () => {
const r = await compileAndRun(
"esm-default-interface-reexport",
`import DefaultShape from './reexport.ts';
type LocalShape = DefaultShape;
console.log('ok');
`,
"ts",
{
"reexport.ts": `export { default } from './types.ts';\n`,
"types.ts": "export default interface DefaultShape { value: number }\n",
},
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe("ok\n");
expect(r.stderr).toBe("");
});
test("a child native-ESM failure wins over a later parent CJS failure", async () => {
const r = await compileAndRun(
"esm-type-link-before-cjs",
`import './middle.ts';
import { missing } from './table.cjs';
console.log('never runs', missing);
`,
"ts",
{
"middle.ts": `import { Shape } from './types.ts';
console.log('middle never runs');
`,
"types.ts": "export interface Shape { value: number }\nconsole.log('types evaluated');\n",
"table.cjs": "module.exports = { missing: 7 };\n",
},
);
expect(r.exitCode).toBe(1);
expect(r.stdout).toBe("");
expect(r.stderr).toBe(
"Uncaught SyntaxError: The requested module './types.ts' does not provide an export named 'Shape'\n",
);
});
});
describe("checked-dynamic boundary behavior (scriptc-only)", () => {
test("a checked argument into a call through 'unknown' matches Node", async () => {
// `this` in a plain JS function is the checked-dynamic ambient
// receiver; a member CALL through it with an 'any'-typed argument
// 383(d)'s "one unbridgeable mix"). The crossing exists now
// (dynFromJsval, SEMANTICS 394): the argument wraps by reference, the
// statement compiles for real, and the unbound-`this` member read
// throws Node's OWN TypeError — the divergence this pin used to own
// is retired.
const r = await compileAndRun(
"checked-arg-dyn-call",
`// @dynamic
'use strict';
function register(item) {
try {
this.registry(item);
return "unreachable";
} catch (e) {
return "caught: " + e.message;
}
}
console.log(\`\${register(7)}\`);
`,
"mjs",
{ "tsconfig.json": '{"compilerOptions":{"strict":true,"noImplicitAny":false}}\n' },
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe("caught: Cannot read properties of undefined (reading 'registry')\n");
expect(r.stderr).toBe("");
});
});
// A cipher name Node lists in crypto.getCiphers() but the runtime does not
// implement throws a coded SC2020 error naming the cipher when the name is
// computed; literal names are refused at compile time (node-types-fenced).
describe("unimplemented cipher names", () => {
test("a computed cipher name throws SC2020 naming the cipher", async () => {
const r = await compileAndRun(
"cipher-computed-name",
`import { createCipheriv } from "node:crypto";
const mode = ["c", "c", "m"].join("");
try {
createCipheriv("aes-128-" + mode, Buffer.alloc(16), Buffer.alloc(12));
} catch (error) {
const e = error as Error & { code?: string };
console.log(e.name, e.code, e.message);
}
try {
createCipheriv("aes-128-" + mode.slice(1), Buffer.alloc(16), Buffer.alloc(12));
} catch (error) {
const e = error as Error & { code?: string };
console.log(e.name, e.code, e.message);
}
`,
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe(
"Error SC2020 The cipher 'aes-128-ccm' has no native implementation; the supported ciphers are " +
"aes-128-cbc, aes-192-cbc, aes-256-cbc, aes-128-ctr, aes-192-ctr, aes-256-ctr, aes-128-gcm, " +
"aes-192-gcm, aes-256-gcm and chacha20-poly1305\n" +
"Error ERR_CRYPTO_UNKNOWN_CIPHER Unknown cipher\n",
);
expect(r.stderr).toBe("");
});
});
// Node formats an unknown output encoding with %s, which prints a
// function's source text and calls a toString the encoding's class
// defines. The cipher runtime has neither, so both throw a coded SC2020
// error.
describe("cipher output encodings that print through source text or a class toString", () => {
test("update() and final() throw SC2020", async () => {
const r = await compileAndRun(
"cipher-encoding-class-to-string",
`import { createCipheriv } from "node:crypto";
class Encoding {
name: string;
constructor(name: string) {
this.name = name;
}
toString(): string {
return this.name;
}
}
const instance: any = new Encoding("nope");
const fn: any = () => "nope";
try {
createCipheriv("aes-128-ctr", Buffer.alloc(16), Buffer.alloc(16)).final(instance);
} catch (error) {
const e = error as Error & { code?: string };
console.log(e.name, e.code, e.message);
}
try {
createCipheriv("aes-128-ctr", Buffer.alloc(16), Buffer.alloc(16)).update("abc", "utf8", fn);
} catch (error) {
const e = error as Error & { code?: string };
console.log(e.name, e.code, e.message);
}
`,
);
expect(r.exitCode).toBe(0);
const message =
"Error SC2020 An output encoding that is a function or an instance of a class defining toString has no lowering; " +
"pass the encoding name as a string\n";
expect(r.stdout).toBe(message + message);
expect(r.stderr).toBe("");
});
});
// Node 24.22 skips the chacha20-poly1305 tag check when neither
// setAuthTag() nor authTagLength was used, and returns unverified
// plaintext. scriptc keeps Node 24.15's behavior: final() fails.
describe("chacha20-poly1305 decryption without a tag", () => {
test("final() fails instead of returning unverified plaintext", async () => {
const r = await compileAndRun(
"cipher-chacha-no-tag",
`import { createCipheriv, createDecipheriv } from "node:crypto";
const key = Buffer.alloc(32, 1);
const iv = Buffer.alloc(12, 2);
const cipher = createCipheriv("chacha20-poly1305", key, iv);
const ciphertext = Buffer.concat([cipher.update("secret"), cipher.final()]);
for (const input of [ciphertext, Buffer.from("forged data"), Buffer.alloc(0)]) {
const decipher = createDecipheriv("chacha20-poly1305", key, iv);
const text = decipher.update(input);
try {
decipher.final();
console.log("accepted", text.toString("hex"));
} catch (error) {
const e = error as Error & { code?: string };
console.log(e.name, e.code, e.message);
}
}
`,
);
expect(r.exitCode).toBe(0);
expect(r.stdout).toBe(
"Error undefined Unsupported state or unable to authenticate data\n".repeat(3),
);
expect(r.stderr).toBe("");
});
});