Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 200 additions & 55 deletions test/aot/a-proxy-is-described-and-defined-on-by-its-traps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
// what the traps get and what they answer has to be what plain Bun hands them, and a program that does no more than this to a proxy
// uses no more of the built-ins than it names. JSON.stringify asks a proxy by get, ownKeys and getOwnPropertyDescriptor in the same way.
// console.log prints the target of a proxy and asks nothing. Each scene has a proxy and a handler of its own.
import { expect } from "bun:test";
import { bunEnv, bunExe, isWindows, tempDir } from "harness";
import { afterAll, expect } from "bun:test";
import { bunEnv, bunExe, isDebug, isWindows, normalizeBunSnapshot, tempDir } from "harness";
import { rmSync } from "node:fs";
import { join } from "node:path";
import { built, slot, spawned, told } from "./harness/commands";
import { locksEnvironment } from "./hardened";
import { readBuildReport } from "./harness/build-report";
import { built, limitWithASAN, slot, spawned, told } from "./harness/commands";
import { withReport } from "./harness/flags";
import { test } from "./supported";

const scenes: [name: string, does: string][] = [
Expand Down Expand Up @@ -369,25 +373,84 @@ await 0;
for (const line of lines) console.log(line);
`;

test("the two traps get and answer what they do in plain Bun", async () => {
using dir = tempDir("aot-own-property-traps", { "main.mjs": program });
const folder = String(dir);
const executable = join(folder, isWindows ? "main.exe" : "main");
await slot(async () => {
built(
await spawned([bunExe(), "build", "--compile", "--aot", "./main.mjs", "--outfile", executable], folder, bunEnv),
);
const plain = told(await spawned([bunExe(), "./main.mjs"], folder, bunEnv));
expect(plain.stdout.split("\n").length).toBeGreaterThan(scenes.length);
expect(told(await spawned([executable], folder, bunEnv))).toEqual(plain);
});
}, 240_000);
test.concurrent(
"the two traps get and answer what they do in plain Bun",
async () => {
using dir = tempDir("aot-own-property-traps", { "main.mjs": program });
const folder = String(dir);
const executable = join(folder, isWindows ? "main.exe" : "main");
await slot(async () => {
built(
await spawned([bunExe(), "build", "--compile", "--aot", "./main.mjs", "--outfile", executable], folder, bunEnv),
);
const plain = told(await spawned([bunExe(), "./main.mjs"], folder, bunEnv));
// The line of each scene. In front of them stands what console.log makes of a proxy.
expect(
normalizeBunSnapshot(
plain.stdout
.split("\n")
.slice(-scenes.length - 1)
.join("\n"),
),
).toMatchInlineSnapshot(`
"the answer holds what the trap's answer holds: returned: describe a | made by the trap false false true [value,writable,enumerable,configurable]
the answer holds the accessors that the trap answers: returned: set x | got by object undefined [get,set,enumerable,configurable]
a trap that answers nothing: returned: describe a | describe a | undefined false
a getter of the trap's answer runs: returned: value is read | from the getter
what was described is defined elsewhere under the same key: returned: described [a]
a field of the target that only the trap's built-in sees: returned: on the target true false
the trap gets a descriptor of the engine's own: returned: define a [value,enumerable] false given undefined true undefined | true false true undefined
a trap that writes to its descriptor leaves the program's alone: returned: given [value,configurable]
the value that is defined goes where the trap puts it: returned: through the trap through the trap
the accessors that are defined reach the trap: returned: the setter got y | [get,set,configurable] the getter ran undefined
the proxy that Object.defineProperty answers is the proxy: returned: get a | of the target
a trap that refuses: TypeError: false
a trap that throws: returned: thrown by define | thrown by describe | thrown by describe | thrown by describe
Reflect.set asks its receiver what is there and defines the value: returned: describe a | define a [value,writable,enumerable,configurable] first | true | describe a | define a [value] second | true | describe a | second
a setter that Reflect.set finds runs for the proxy: returned: get name | setter 1 true the target | true
a store on the proxy and on its heir: returned: STORED undefined STORED of the heir [b] [a] | the other defines c for another proxy | true
a store under a key of the command line: returned: 1 [a]
the three tests for an own property: returned: describe a | describe made | describe none | true true false | describe a | describe none | true false | describe a | describe made | true false | describe Symbol(Symbol.iterator) | false
a handler without the trap hands the question on to the target: returned: of the target true | defined through [a,b] | true set through
a trap that is put there later: returned: 1 | from the later trap | the later trap defines 2
traps that are built-ins: returned: by Reflect by Reflect true set
a proxy of a proxy: returned: outer defines a | inner defines a through both | inner describes a | outer describes a | inner describes a | inner describes a | through both
what the engine holds against the target: returned: TypeError | TypeError | TypeError | undefined | TypeError | TypeError
a key that is converted: returned: the key is converted | describe string made | the key is converted | define string made | describe string 1 | describe symbol Symbol(s) | define symbol Symbol(s)
JSON.stringify lists the proxy and reads what it lists: returned: get toJSON | ownKeys | describe a | describe b | describe made | get a | get b | get made | get toJSON | ownKeys | describe a | describe b | describe made | get a | get b | get made | {"a":{"mark":"of the target"},"b":2,"made":{"mark":"made by get"}} 111
JSON.stringify calls the toJSON that get answers, on the proxy: returned: get toJSON | toJSON true | get toJSON | toJSON 0 true | {"mark":"from toJSON"} [{"mark":"from toJSON"}]
JSON.stringify goes on to what the traps answer: returned: the inner toJSON runs | {"x":"inner"}
JSON.stringify of a proxy of a list: returned: get toJSON | get length | get 0 | get 1 | [{"mark":"first"},2]
JSON.stringify without a trap lists the target: returned: the getter runs for true | {"seen":{"mark":"only by JSON"},"computed":1}
JSON.stringify with a function or a list beside the proxy: returned: get toJSON | replacer true | ownKeys | get a | replacer a false | get b | replacer b false | {"a":1,"b":2} | get toJSON | get b | {"b":2}
a trap that throws while JSON.stringify lists: returned: thrown by ownKeys
console.log prints the target and asks no trap: returned: []
console.log calls what the target has for the formatter: returned: the target's own printer runs for the target true | the target's own printer runs for the target true
a text with a % converts the proxy behind it: returned: get Symbol(Symbol.toPrimitive) | get toString | get toJSON | ownKeys | describe a | get a | get Symbol(Symbol.toPrimitive) | get valueOf | get toString
a text of the command line in front of a proxy: returned: get Symbol(Symbol.toPrimitive) | get toString
console.dir and console.table of a proxy: returned: the options are asked for depth | the options are asked for colors | get Symbol(Symbol.iterator) | ownKeys | get a | get b
the methods of Object.prototype that only look at their receiver: returned: get a | true 1 | get Symbol(Symbol.toStringTag) | [object Tagged] | false true | get toString | get Symbol(Symbol.toStringTag) | [object Tagged] | get valueOf | get toString | get Symbol(Symbol.toStringTag) | true [object Tagged] object
__defineGetter__ and __defineSetter__ reach the trap that defines: TypeError: define a [get,enumerable,configurable] the getter ran undefined | the setter got given | define b [set,enumerable,configurable] undefined undefined | the getter ran
__lookupGetter__ and __lookupSetter__ ask the trap that describes and go on above: returned: describe made | describe own | describe inherited | made by the trap of the target from above | describe inherited | describe none | function undefined | getPrototypeOf | from above
the methods of Function.prototype refuse a proxy of what is no function: returned: TypeError | TypeError | TypeError | TypeError | false
a proxy of a function is bound, called and printed: returned: describe length | get length | get name | apply [the bound operand] | apply [x] | called with the bound operand bound named 1 called with x string
a get that answers under the name in upper case: returned: describe A | describe a | b true false {"A":"b","by":"toJSON"} [object Object] true
a trap's answer that is no object: returned: TypeError"
`);
expect(plain.exitCode).toBe(0);
expect(told(await spawned([executable], folder, bunEnv))).toEqual(plain);
});
},
240_000,
);

// One scene to a program, so that nothing another scene does to its proxy hides what this one's compiled code does.
test.concurrent.each(scenes)(
"alone: %s",
async (name, does) => {
const alone = `
// One scene to a program, so that nothing another scene does to its proxy hides what this one's compiled code does. A build is
// what costs, so a build holds several of them: --aot-separate-programs compiles each entry point as it is compiled when it is
// built alone, and BUN_AOT_START_AT says at which of them a run starts.
const alone = scenes.map(([name, does], index) => ({
name,
file: `scene-${String(index).padStart(2, "0")}.mjs`,
text: `
function show(value) {
if (typeof value === "symbol") return String(value);
if (Array.isArray(value)) return "[" + value.map(show).join(",") + "]";
Expand All @@ -399,18 +462,102 @@ ${does}
} catch (error) {
console.log(error.constructor.name);
}
`;
using dir = tempDir("aot-own-property-trap", { "main.mjs": alone });
const folder = String(dir);
const executable = join(folder, isWindows ? "main.exe" : "main");
await slot(async () => {
`,
}));
let aloneDir: ReturnType<typeof tempDir> | undefined;
afterAll(() => aloneDir?.[Symbol.dispose]());
/** The folder of the scenes. The first test that asks for it makes it. */
const aloneFolder = () =>
String(
(aloneDir ??= tempDir("aot-own-property-trap", Object.fromEntries(alone.map(({ file, text }) => [file, text])))),
);

// Only a canary build reads BUN_AOT_START_AT. A release says -canary in its revision. A debug build is a canary unless it is
// built otherwise, and says -debug. A build that locks its environment does not start where the variable is set.
const mayStartWhereTold =
!locksEnvironment &&
/-(canary|debug)\b/.test(Bun.spawnSync([bunExe(), "--revision"], { env: bunEnv }).stdout.toString());
let startsWhereTold: Promise<boolean> | undefined;

/** Removes an executable whose runs are over: it is as large as Bun. */
function remove(executable: string) {
try {
rmSync(executable, { force: true });
} catch {
// Windows can hold the image of a process that has just ended. The folder takes the file along when the tests are over.
}
}

// A build compiles its programs one after another, so it holds five scenes at the most, and the builds run beside one another.
const buildCount = Math.ceil(alone.length / 5);
type Run = Awaited<ReturnType<typeof spawned>>;
const runs: Promise<Map<string, Run> | undefined>[] = [];
/**
* How each scene of build `number` ran as a program of that build's executable, or nothing where this Bun does not start an
* executable where it is told. The build starts with the first test that asks for it. Its executable is as large as Bun, and
* is gone when its scenes have run.
*/
const runsOfBuild = (number: number) =>
mayStartWhereTold
? (runs[number] ??= slot(async () => {
const folder = aloneFolder();
const files = alone.filter((_, index) => index % buildCount === number).map(({ file }) => file);
const executable = join(folder, isWindows ? `build-${number}.exe` : `build-${number}`);
const report = join(folder, `build-${number}.json`);
const flags = ["--aot-separate-programs", "--root=.", ...withReport(report, "summary")];
const entryPoints = files.map(file => `./${file}`);
try {
built(
await spawned(
[bunExe(), "build", "--compile", "--aot", ...flags, ...entryPoints, "--outfile", executable],
folder,
bunEnv,
// A debug build is as slow without a sanitizer, and the harness gives the longer limit only to a Bun with one.
isDebug ? limitWithASAN : undefined,
),
);
// Scenes that a build had joined into one program would still print what they print in plain Bun.
expect(readBuildReport(report).statistics.WholeEngineRuns).toBe(files.length);
// The first executable is asked too: a Bun that reads the variable refuses a name that no entry point has.
startsWhereTold ??= spawned([executable], folder, { ...bunEnv, BUN_AOT_START_AT: "no-scene.mjs" }).then(
refused => refused.stderr.includes("BUN_AOT_START_AT names no entry point"),
);
if (!(await startsWhereTold)) return;
const ran = new Map<string, Run>();
for (const file of files)
ran.set(file, await spawned([executable], folder, { ...bunEnv, BUN_AOT_START_AT: file }));
return ran;
} finally {
remove(executable);
}
}))
: undefined;

/** How a scene ran as the one program of an executable: for a Bun that starts an executable at its first entry point alone. */
const ranAlone = (file: string) =>
slot(async () => {
const folder = aloneFolder();
const executable = join(folder, file.replace(".mjs", isWindows ? ".exe" : ""));
try {
built(
await spawned([bunExe(), "build", "--compile", "--aot", "./main.mjs", "--outfile", executable], folder, bunEnv),
);
expect(told(await spawned([executable], folder, bunEnv))).toEqual(
told(await spawned([bunExe(), "./main.mjs"], folder, bunEnv)),
await spawned([bunExe(), "build", "--compile", "--aot", `./${file}`, "--outfile", executable], folder, bunEnv),
);
});
return await spawned([executable], folder, bunEnv);
} finally {
remove(executable);
}
});

test.concurrent.each(alone.map(({ name }, index): [string, number] => [name, index]))(
"alone: %s",
async (_name, index) => {
const { file } = alone[index];
const compiled = (await runsOfBuild(index % buildCount))?.get(file) ?? (await ranAlone(file));
const plain = await slot(() => spawned([bunExe(), `./${file}`], aloneFolder(), bunEnv));
// Two runs that fail alike, or print nothing, would agree and show nothing.
expect(plain.stdout).not.toBe("");
expect({ exitCode: plain.exitCode, signal: plain.signal }).toEqual({ exitCode: 0, signal: null });
expect(told(compiled)).toEqual(told(plain));
},
240_000,
);
Expand Down Expand Up @@ -448,44 +595,42 @@ const answersInUpperCase = `{
getOwnPropertyDescriptor(target, key) { return Reflect.getOwnPropertyDescriptor(target, key); },
defineProperty(target, key, descriptor) { target[key] = String(descriptor.value); return true; },
}`;
/** What `bun check --aot --explain=trim` says of a program, line by line, and how it ends. */
async function explained(source: string) {
using dir = tempDir("aot-own-property-report", { "main.js": source });
await using proc = Bun.spawn({
cmd: [bunExe(), "check", "--aot", "--no-pretty", "--explain=trim", "main.js"],
cwd: String(dir),
env: { ...bunEnv, NO_COLOR: "1" },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { lines: (stdout + stderr).split("\n"), exitCode, signal: proc.signalCode };
}

for (const [what, text] of Object.entries({
"a method that the proxy inherits": `console.log(proxy.hasOwnProperty("A"));`,
"JSON.stringify": `console.log(JSON.stringify(proxy));`,
}))
test.concurrent(`${what}, found by a get that answers in upper case, gives no trap away`, async () => {
using dir = tempDir("aot-own-property-report", {
"main.js": `const proxy = new Proxy({ A: "a", toJSON: () => ({}) }, ${answersInUpperCase});\n${text}\n`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "check", "--aot", "--no-pretty", "--explain=trim", "main.js"],
cwd: String(dir),
env: { ...bunEnv, NO_COLOR: "1" },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const lines = (stdout + stderr).split("\n");
const { lines, ...end } = await explained(
`const proxy = new Proxy({ A: "a", toJSON: () => ({}) }, ${answersInUpperCase});\n${text}\n`,
);
expect(lines).toContain("aot: trim: the compiler can see every API this program uses");
expect(lines).toContain("aot: trim: uses global Proxy");
// What every object inherits, at the most.
expect(lines.filter(line => line.endsWith("(escapes, so any member may be used)")).length).toBeLessThanOrEqual(20);
expect(end).toEqual({ exitCode: 0, signal: null });
});

for (const [what, text] of Object.entries(closed))
test.concurrent(`${what} uses no built-in that the program does not name`, async () => {
using dir = tempDir("aot-own-property-report", {
"main.js": `const proxy = new Proxy({ A: "a" }, ${handler});\n${text}\n`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "check", "--aot", "--no-pretty", "--explain=trim", "main.js"],
cwd: String(dir),
env: { ...bunEnv, NO_COLOR: "1" },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const lines = (stdout + stderr).split("\n");
const { lines, ...end } = await explained(`const proxy = new Proxy({ A: "a" }, ${handler});\n${text}\n`);
expect(lines).toContain("aot: trim: the compiler can see every API this program uses");
expect(lines).toContain("aot: trim: uses global Proxy");
expect(
lines.flatMap(line => /trim: uses (.*) \(escapes, so any member may be used\)$/.exec(line)?.[1] ?? []),
).toEqual([]);
expect(end).toEqual({ exitCode: 0, signal: null });
});
Loading