Skip to content

Commit a667b67

Browse files
test(review): mirror the sandbox runtime in fan-out script tests (#10119)
Address review feedback on the emit-workflow PR: - Run the generated fan-out script in a vm context that mirrors the workflow sandbox's execution shape: the meta block is stripped instead of executed, the body is wrapped in the runtime's strict-mode async IIFE, only the sandbox globals are bound, and the agent stub applies the runtime's option gates. - Extend the determinism guard to the sandbox's full Date surface (Date.parse, Date.UTC, bare Date calls). - Exercise the failed-write half of the temp-and-rename cleanup. - Cover the handler-level --rules happy path end to end.
1 parent 75a252b commit a667b67

2 files changed

Lines changed: 116 additions & 22 deletions

File tree

packages/cli/src/commands/review/emit-workflow.test.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -394,9 +394,19 @@ describe('emit-workflow — where it writes', () => {
394394
writeFileSync(plan, JSON.stringify(localPlan()), 'utf8');
395395
run(plan);
396396
const scriptDir = dirname(reviewWorkflowScriptPath(plan));
397-
expect(readdirSync(scriptDir).filter((n) => n.endsWith('.tmp'))).toEqual(
398-
[],
399-
);
397+
const tempFiles = () =>
398+
readdirSync(scriptDir).filter((n) => n.endsWith('.tmp'));
399+
expect(tempFiles()).toEqual([]);
400+
401+
// The failed-write half: the rename throws AFTER the temp file exists,
402+
// the case the finally cleanup exists for. A non-empty directory at the
403+
// target makes renameSync throw on every platform.
404+
const scriptPath = reviewWorkflowScriptPath(plan);
405+
rmSync(scriptPath);
406+
mkdirSync(scriptPath);
407+
writeFileSync(join(scriptPath, 'blocker'), 'keep me out', 'utf8');
408+
expect(() => run(plan)).toThrow();
409+
expect(tempFiles()).toEqual([]);
400410
});
401411

402412
it('names a script per plan, so concurrent reviews do not overwrite each other', () => {
@@ -427,6 +437,34 @@ describe('emit-workflow — where it writes', () => {
427437
expect(readRecordedPrompts(plan).size).toBe(0);
428438
expect(existsSync(reviewWorkflowScriptPath(plan))).toBe(false);
429439
});
440+
441+
it('threads --rules through the handler into every reviewing brief', () => {
442+
const plan = join(dir, 'plan.json');
443+
writeFileSync(plan, JSON.stringify(localPlan()), 'utf8');
444+
const rulesPath = join(dir, 'rules.md');
445+
writeFileSync(
446+
rulesPath,
447+
'RULE: every brief must carry this — MARKER-rules-7c1e',
448+
'utf8',
449+
);
450+
451+
(emitWorkflowCommand.handler as (a: unknown) => void)({
452+
plan,
453+
rules: rulesPath,
454+
});
455+
456+
expect(existsSync(reviewWorkflowScriptPath(plan))).toBe(true);
457+
const keys = [...readRecordedPrompts(plan).keys()];
458+
// Agent 7 runs deterministic build and test commands, not a review, so
459+
// its brief carries no rules; every reviewing role's does.
460+
const reviewing = keys.filter((key) => key !== '7');
461+
expect(reviewing.length).toBeGreaterThan(1);
462+
for (const key of reviewing) {
463+
expect(readFileSync(briefPath(plan, key), 'utf8')).toContain(
464+
'MARKER-rules-7c1e',
465+
);
466+
}
467+
});
430468
});
431469

432470
describe('emit-workflow — residue parity with the hand-launched path', () => {

packages/cli/src/commands/review/workflow-script.test.ts

Lines changed: 75 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import { describe, it, expect } from 'vitest';
8+
import * as vm from 'node:vm';
89
import { REVIEW_BUILTIN_SUBAGENT_TYPE } from '@qwen-code/qwen-code-core';
910
import {
1011
buildReviewWorkflowScript,
@@ -17,13 +18,36 @@ import {
1718
// Every case below runs the REAL output of `buildReviewWorkflowScript`, so a
1819
// roster that serialized wrong would fail these as surely as a broken loop.
1920
//
20-
// The harness is an analogue of the sandbox, not the sandbox itself:
21-
// `createWorkflowSandbox` is not exported from the core package, and importing
22-
// it would make this a cross-package change for no gain in what is being
23-
// checked — this script's own behaviour. The one assumption is the documented
24-
// one: `export const meta = {...}` is stripped and the rest runs as an async
25-
// function body. The real runtime executes it end-to-end when the skill is
26-
// wired to it.
21+
// The harness mirrors the runtime's execution shape (workflow-sandbox.ts)
22+
// rather than importing it — `createWorkflowSandbox` is not exported from
23+
// the core package, and exporting it for this test would be a cross-package
24+
// change for no gain in what is being checked:
25+
// - the meta block is STRIPPED, never executed: the sandbox parses it as
26+
// a pure literal, so no live `meta` binding may reach the body;
27+
// - the body runs inside the runtime's strict-mode async IIFE wrapper, so
28+
// an undeclared assignment throws here like it throws at dispatch;
29+
// - the body runs in a vm context binding only the globals this harness
30+
// stands in for — a host-only global like `setTimeout` fails here like
31+
// it fails in the sandbox;
32+
// - the `agent` stub applies the runtime's option gates.
33+
// What it does not mirror: the sandbox's throwing Date/Math replacements
34+
// (a fresh vm context has a real Date, so the determinism case asserts on
35+
// the source instead) and the meta literal parser (not exported from the
36+
// core package; the meta block's purity is asserted on the source below).
37+
//
38+
// The runtime's option allowlist (workflow-sandbox.ts): a key outside it is
39+
// a typo the sandbox refuses at dispatch, so it must be refused here.
40+
const KNOWN_AGENT_OPTS = [
41+
'label',
42+
'phase',
43+
'schema',
44+
'model',
45+
'isolation',
46+
'agentType',
47+
'stallMs',
48+
'workingDir',
49+
];
50+
2751
async function runScript(
2852
script: string,
2953
dispatch: (prompt: string, opts: unknown) => Promise<unknown>,
@@ -37,24 +61,51 @@ async function runScript(
3761
const logs: string[] = [];
3862
const phases: string[] = [];
3963

40-
const agent = async (prompt: string, opts: unknown) => {
41-
dispatched.push({ prompt, opts });
42-
return dispatch(prompt, opts);
64+
// The runtime's gates: an unknown option is refused, an empty workingDir
65+
// is not "no pin", and workingDir together with isolation is a
66+
// contradiction about who owns the directory's lifetime.
67+
const agent = async (prompt: string, opts: Record<string, unknown>) => {
68+
const options = opts ?? {};
69+
for (const key of Object.keys(options)) {
70+
if (!KNOWN_AGENT_OPTS.includes(key)) {
71+
throw new Error(`agent({${key}}): unknown option.`);
72+
}
73+
}
74+
if (options['workingDir'] !== undefined) {
75+
if (
76+
typeof options['workingDir'] !== 'string' ||
77+
options['workingDir'].trim().length === 0
78+
) {
79+
throw new Error('agent({workingDir}): must be a non-empty string.');
80+
}
81+
if (options['isolation'] !== undefined) {
82+
throw new Error(
83+
'agent({workingDir, isolation}): incompatible options.',
84+
);
85+
}
86+
}
87+
dispatched.push({ prompt, opts: options });
88+
return dispatch(prompt, options);
4389
};
4490
// Mirrors the runtime's errors-as-data contract: a thunk that rejects
4591
// becomes a `null` element, and the call itself never rejects.
4692
const parallel = async (thunks: Array<() => Promise<unknown>>) =>
4793
Promise.all(thunks.map((t) => t().catch(() => null)));
94+
const phase = (title: string): void => {
95+
phases.push(title);
96+
};
97+
const log = (message: string): void => {
98+
logs.push(message);
99+
};
48100

49-
const body = script.replace('export const meta =', 'const meta =');
50-
const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor;
51-
const fn = new AsyncFunction('agent', 'parallel', 'phase', 'log', body);
52-
const result = await fn(
53-
agent,
54-
parallel,
55-
(t: string) => phases.push(t),
56-
(m: string) => logs.push(m),
57-
);
101+
// Strip the meta block exactly like the runtime does — it is parsed as a
102+
// pure literal there, never executed, so the body must run with no `meta`
103+
// binding either.
104+
const body = script.slice(script.indexOf('\n};') + '\n};'.length);
105+
// The runtime's wrapper: an async IIFE under 'use strict'.
106+
const wrapped = `(async () => {'use strict';\n${body}\n})()`;
107+
const context = vm.createContext({ agent, parallel, phase, log });
108+
const result: unknown = await new vm.Script(wrapped).runInContext(context);
58109
return { result, dispatched, logs, phases };
59110
}
60111

@@ -79,6 +130,11 @@ describe('the generated Step 3A fan-out script', () => {
79130
expect(script).not.toContain('Date.now');
80131
expect(script).not.toContain('Math.random');
81132
expect(script).not.toContain('new Date');
133+
// The sandbox's safeDate throws on these forms too, and the vm harness
134+
// cannot stand in for it — a fresh context carries a working Date.
135+
expect(script).not.toContain('Date.parse');
136+
expect(script).not.toContain('Date.UTC');
137+
expect(script).not.toMatch(/\bDate\s*\(/);
82138
});
83139

84140
it('dispatches every agent in the roster, once each, in one phase', async () => {

0 commit comments

Comments
 (0)