qwen-code/packages/node-repl/src/node-repl.semantics.test.ts
顾盼 a791e47a54
feat(cua): streamline computer use and port native reliability fixes (#14846)
* feat(cua): preload the JavaScript computer use runtime

* refactor(cua): remove the native Wayland backend

* feat(cua): move app computer use into the driver

* feat(cua): preserve background delivery and port platform fixes

* feat(cua): add explicit desktop input and browser runtime facade

* feat(cua): package the unified 0.26.0 computer use runtime

* fix(cua): finalize cross-platform native reliability

* fix(cua): isolate runtime releases from CLI versioning

* fix(cua): keep Electron out of root workspace installs

* fix(cua): synchronize consumers and async cleanup tests

* fix(browser): migrate smoke to preloaded cua runtime

* docs(cua): document MCP setup for Qwen Code Claude Code and Codex
2026-10-21 14:03:31 +08:00

518 lines
19 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
import { afterEach, describe, expect, it } from 'vitest';
import { NodeReplKernelManager } from './kernel-manager.js';
import { NodeReplSecurityPolicy } from './security-policy.js';
const managers: NodeReplKernelManager[] = [];
function makeManager(): NodeReplKernelManager {
const manager = new NodeReplKernelManager({
cwd: process.cwd(),
homeDir: os.homedir(),
tmpRootDir: fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-node-repl-sem-')),
policy: NodeReplSecurityPolicy.default(),
readableRoots: [process.cwd()],
});
managers.push(manager);
return manager;
}
afterEach(async () => {
await Promise.all(managers.splice(0).map((manager) => manager.dispose()));
});
function textOf(events: Array<{ type: string; text?: string }>): string {
return events
.filter((e) => e.type === 'text')
.map((e) => e.text ?? '')
.join('');
}
describe('binding semantics through the real kernel', () => {
it('persists a var declared inside a block across cells', async () => {
const manager = makeManager();
const first = await manager.exec({
code: 'if (true) { var hoisted = "kept"; }',
timeoutMs: 30_000,
});
expect(first.status).toBe('ok');
const second = await manager.exec({
code: 'nodeRepl.write(String(hoisted));',
timeoutMs: 30_000,
});
expect(second.status).toBe('ok');
expect(textOf(second.events).trim()).toBe('kept');
});
it('does not leak a var from an inner function scope', async () => {
const manager = makeManager();
await manager.exec({
code: 'function fn() { var innerOnly = 1; }',
timeoutMs: 30_000,
});
const probe = await manager.exec({
code: 'nodeRepl.write(typeof innerOnly);',
timeoutMs: 30_000,
});
expect(textOf(probe.events).trim()).toBe('undefined');
});
it('rejects a binding that would shadow the nodeRepl global, and stays usable', async () => {
const manager = makeManager();
const shadow = await manager.exec({
code: 'const nodeRepl = 42;',
timeoutMs: 30_000,
});
expect(shadow.status).toBe('error');
expect(shadow.error?.message ?? '').toMatch(/shadow/i);
// The output channel must still work afterwards.
const after = await manager.exec({
code: 'nodeRepl.write("still working");',
timeoutMs: 30_000,
});
expect(after.status).toBe('ok');
expect(textOf(after.events).trim()).toBe('still working');
});
it('matches real JS scoping for every hoisting construct', async () => {
const manager = makeManager();
// [setup, probe, expected typeof in a LATER cell]
const cases: Array<[string, string, string]> = [
// `var` hoists out of blocks / control flow to module scope:
['if (true) { var h_a = 1; }', 'h_a', 'number'],
['try { var h_b = 2; } catch {}', 'h_b', 'number'],
['try { null.x; } catch (e) { var h_c = 3; }', 'h_c', 'number'],
['switch (1) { case 1: var h_d = 4; }', 'h_d', 'number'],
['do { var h_e = 5; } while (false);', 'h_e', 'number'],
['lbl: { var h_f = 6; }', 'h_f', 'number'],
['{ { { var h_g = 7; } } }', 'h_g', 'number'],
['for await (const x of [1]) { var h_h = 8; }', 'h_h', 'number'],
// `var` must NOT escape an inner function scope:
['function hFn() { var h_i = 9; }', 'h_i', 'undefined'],
['const hArrow = () => { var h_j = 10; };', 'h_j', 'undefined'],
['class HK { m() { var h_k = 11; } }', 'h_k', 'undefined'],
['class HS { static { var h_l = 12; } }', 'h_l', 'undefined'],
[
'const hO = { p: (() => { var h_m = 13; return 1; })() };',
'h_m',
'undefined',
],
// const/let stay block-scoped:
['if (true) { const h_n = 14; }', 'h_n', 'undefined'],
['if (true) { let h_o = 15; }', 'h_o', 'undefined'],
];
for (const [setup, probe, expected] of cases) {
const s = await manager.exec({ code: setup, timeoutMs: 30_000 });
expect(s.status, `setup failed: ${setup}`).toBe('ok');
const r = await manager.exec({
code: `nodeRepl.write(typeof ${probe});`,
timeoutMs: 30_000,
});
expect(textOf(r.events).trim(), `case: ${setup}`).toBe(expected);
}
});
it('rejects every tool-runtime global name, and stays usable', async () => {
const manager = makeManager();
// Must match kernel.mjs's intrinsicObjectDefineProperties bootstrap.
const reserved = [
'nodeRepl',
'console',
'setTimeout',
'setInterval',
'clearTimeout',
'clearInterval',
];
for (const name of reserved) {
const r = await manager.exec({
code: `const ${name} = 42;`,
timeoutMs: 30_000,
});
expect(r.status, `${name} should be rejected`).toBe('error');
expect(r.error?.message ?? '').toMatch(/shadow/i);
}
// The output channel must survive all of those rejections.
const alive = await manager.exec({
code: 'nodeRepl.write("session-alive");',
timeoutMs: 30_000,
});
expect(alive.status).toBe('ok');
expect(textOf(alive.events).trim()).toBe('session-alive');
});
it('allows shadowing ordinary JS/Web globals, matching plain Node', async () => {
const manager = makeManager();
// `const Buffer = 42` makes Buffer a number in plain Node too. A persistent
// REPL shares one scope across cells, so the shadow persisting is correct;
// rejecting it would diverge from JS semantics.
const shadow = await manager.exec({
code: 'const Buffer = 42;',
timeoutMs: 30_000,
});
expect(shadow.status).toBe('ok');
const probe = await manager.exec({
code: 'nodeRepl.write(typeof Buffer);',
timeoutMs: 30_000,
});
expect(textOf(probe.events).trim()).toBe('number');
});
it('does not hoist var out of any nested function form', async () => {
const manager = makeManager();
// Over-collection guards: none of these may reach module scope.
const cases: Array<[string, string]> = [
['class O1 { f = (() => { var o1 = 1; return 2; })(); }', 'o1'],
['function o2f(p = (() => { var o2 = 1; return 2; })()) {}', 'o2'],
['const o3o = { get g() { var o3 = 1; return 2; } };', 'o3'],
['const o4o = { set s(v) { var o4 = 1; } };', 'o4'],
['const o5o = { m() { var o5 = 1; } };', 'o5'],
['async function o6f() { var o6 = 1; }', 'o6'],
['function* o7f() { var o7 = 1; }', 'o7'],
['const o8a = async () => { var o8 = 1; };', 'o8'],
['function o9f(cb = function(){ var o9 = 1; }) {}', 'o9'],
];
for (const [setup, probe] of cases) {
const s = await manager.exec({ code: setup, timeoutMs: 30_000 });
expect(s.status, `setup failed: ${setup}`).toBe('ok');
const r = await manager.exec({
code: `nodeRepl.write(typeof ${probe});`,
timeoutMs: 30_000,
});
expect(textOf(r.events).trim(), `case: ${setup}`).toBe('undefined');
}
});
it('hoists var out of every non-function nesting form', async () => {
const manager = makeManager();
// Under-collection guards: all of these must reach module scope.
const cases: Array<[string, string, string]> = [
['try { } finally { var u1 = 1; }', 'u1', 'number'],
['switch(1){case 1: switch(2){case 2: var u2=1;}}', 'u2', 'number'],
['if(false){} else if(true){ var u3 = 1; }', 'u3', 'number'],
['for (var u4 of [7]) {}', 'u4', 'number'],
['for (var u5 in {a:1}) {}', 'u5', 'string'],
['while(true){ var u6 = 1; break; }', 'u6', 'number'],
['if(true){ for(;;){ try{ var u7=1; break; }catch{} } }', 'u7', 'number'],
];
for (const [setup, probe, want] of cases) {
const s = await manager.exec({ code: setup, timeoutMs: 30_000 });
expect(s.status, `setup failed: ${setup}`).toBe('ok');
const r = await manager.exec({
code: `nodeRepl.write(typeof ${probe});`,
timeoutMs: 30_000,
});
expect(textOf(r.events).trim(), `case: ${setup}`).toBe(want);
}
});
it('gives loader-originated failures cell-realm Error identity', async () => {
const manager = makeManager();
// Block-scoped so nothing persists between cells.
const probe = (spec: string, expr: string) =>
[
'{',
' let out = "NO-CATCH";',
' try {',
` await import(${JSON.stringify(spec)});`,
' } catch (e) {',
` out = ${expr};`,
' }',
' nodeRepl.write(out);',
'}',
].join('\n');
for (const [spec, pattern] of [
['./definitely-missing.mjs', 'not found'],
['node:process', 'not allowed'],
['process', 'not allowed'],
['definitely-not-installed-xyz', 'cannot resolve'],
] as Array<[string, string]>) {
const r = await manager.exec({
code: probe(
spec,
`[e instanceof Error, /${pattern}/.test(e.message)].join("|")`,
),
timeoutMs: 30_000,
});
expect(r.status, `spec ${spec}`).toBe('ok');
expect(textOf(r.events).trim(), `spec ${spec}`).toBe('true|true');
}
});
it('keeps a hoisted var assigned before a throw (partial commit)', async () => {
const manager = makeManager();
// Plain Node keeps y === 42: `var` hoists, so the assignment ran before the
// throw. The statement-boundary snapshot must carry it too.
const failed = await manager.exec({
code: 'yh = 42; throw new Error("boom"); var yh;',
timeoutMs: 30_000,
});
expect(failed.status).toBe('error');
const probe = await manager.exec({
code: 'nodeRepl.write(typeof yh + ":" + String(yh));',
timeoutMs: 30_000,
});
expect(textOf(probe.events).trim()).toBe('number:42');
});
it('preserves the carried value when a var redeclares an inherited binding', async () => {
const manager = makeManager();
await manager.exec({ code: 'var xr = 5;', timeoutMs: 30_000 });
const r = await manager.exec({
code: 'const seenBefore = xr; var xr = xr * 2; nodeRepl.write(seenBefore + "|" + xr);',
timeoutMs: 30_000,
});
expect(r.status).toBe('ok');
expect(textOf(r.events).trim()).toBe('5|10');
});
it('keeps const-ness across cells', async () => {
const manager = makeManager();
await manager.exec({ code: 'const cc = 1;', timeoutMs: 30_000 });
const r = await manager.exec({
code: 'cc = 2;',
timeoutMs: 30_000,
});
expect(r.status).toBe('error');
expect(r.error?.name).toBe('TypeError');
});
it('surfaces an unhandled rejection that settles after the cell returns', async () => {
const manager = makeManager();
const first = await manager.exec({
code: 'Promise.reject(new Error("lateboom"));',
timeoutMs: 30_000,
});
expect(first.status).toBe('ok');
// Reported on the next cell rather than vanishing.
const next = await manager.exec({
code: 'nodeRepl.write("next");',
timeoutMs: 30_000,
});
expect(textOf(next.events)).toMatch(/Uncaught \(in promise\).*lateboom/s);
});
it('treats image MIME types case-insensitively for bytes and data URLs', async () => {
const manager = makeManager();
const png =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
const viaBytes = await manager.exec({
code: `await nodeRepl.emitImage({ bytes: Uint8Array.from(atob("${png}"), (c) => c.charCodeAt(0)), mimeType: "Image/PNG" });`,
timeoutMs: 30_000,
});
expect(viaBytes.status).toBe('ok');
expect(viaBytes.events.some((e) => e.type === 'image')).toBe(true);
});
it('carries structured image metadata across the kernel boundary', async () => {
const manager = makeManager();
const png =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
const result = await manager.exec({
code: `await nodeRepl.emitImage({ bytes: Uint8Array.from(atob("${png}"), (c) => c.charCodeAt(0)), mimeType: "image/png", metadata: { width: 1, height: 1, coordinateSpace: "css-pixels" } });`,
timeoutMs: 30_000,
});
expect(result.status).toBe('ok');
expect(result.events.find((event) => event.type === 'image')).toMatchObject(
{
metadata: '{"width":1,"height":1,"coordinateSpace":"css-pixels"}',
},
);
});
it('preserves large metadata without a separate size cap', async () => {
const manager = makeManager();
const png =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
const result = await manager.exec({
code: `await nodeRepl.emitImage({ bytes: Uint8Array.from(atob("${png}"), c => c.charCodeAt(0)), mimeType: "image/png", metadata: "界".repeat(20000) });`,
timeoutMs: 30_000,
});
expect(result.status).toBe('ok');
expect(result.events.find((event) => event.type === 'image')).toMatchObject(
{ metadata: JSON.stringify('界'.repeat(20000)) },
);
});
it.each([
['function', '() => {}', /metadata must be JSON-serializable/],
['bigint', '1n', /BigInt/],
[
'circular',
'(() => { const value = {}; value.self = value; return value; })()',
/circular/i,
],
])(
'rejects %s metadata without emitting its image',
async (_, value, error) => {
const manager = makeManager();
const png =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
const result = await manager.exec({
code: `await nodeRepl.emitImage({ bytes: Uint8Array.from(atob("${png}"), c => c.charCodeAt(0)), mimeType: "image/png", metadata: ${value} });`,
timeoutMs: 30_000,
});
expect(result.status).toBe('error');
expect(result.error?.message).toMatch(error);
expect(result.events.some((event) => event.type === 'image')).toBe(false);
const next = await manager.exec({
code: 'nodeRepl.write("still usable");',
timeoutMs: 30_000,
});
expect(next.status).toBe('ok');
expect(textOf(next.events)).toBe('still usable');
},
);
it('caps live sandbox timers instead of letting one cell saturate the loop', async () => {
const manager = makeManager();
const r = await manager.exec({
code: 'for (let i = 0; i < 5000; i++) setInterval(() => {}, 1000);',
timeoutMs: 30_000,
});
expect(r.status).toBe('error');
expect(r.error?.message ?? '').toMatch(/live timers/);
// The session must still work after hitting the cap.
const after = await manager.exec({
code: 'nodeRepl.write("still-usable");',
timeoutMs: 30_000,
});
expect(textOf(after.events)).toContain('still-usable');
});
it('resolves bare packages from a symlinked cwd node_modules', async () => {
// pnpm / monorepo hoisting / shared CI caches make node_modules a symlink;
// the implicit cwd root must still resolve through it.
const store = fs.mkdtempSync(
path.join(os.tmpdir(), 'qwen-node-repl-store-'),
);
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-node-repl-cwd-'));
const pkg = path.join(store, 'demo-symlinked');
fs.mkdirSync(pkg, { recursive: true });
fs.writeFileSync(
path.join(pkg, 'package.json'),
JSON.stringify({
name: 'demo-symlinked',
version: '1.0.0',
type: 'module',
exports: { import: './index.mjs' },
}),
);
fs.writeFileSync(
path.join(pkg, 'index.mjs'),
'export const value = "via-symlink";',
);
fs.symlinkSync(store, path.join(work, 'node_modules'), 'dir');
const manager = new NodeReplKernelManager({
cwd: work,
homeDir: os.homedir(),
tmpRootDir: fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-node-repl-sl-')),
policy: NodeReplSecurityPolicy.default(),
readableRoots: [work],
});
managers.push(manager);
try {
const r = await manager.exec({
code: 'const d = await import("demo-symlinked"); nodeRepl.write(d.value);',
timeoutMs: 30_000,
});
expect(r.status).toBe('ok');
expect(textOf(r.events).trim()).toBe('via-symlink');
} finally {
// The kernel child's cwd is `work`; on Windows a directory that is any
// process's cwd cannot be removed (EBUSY). Dispose first, then retry
// the removal across the child's asynchronous teardown.
await manager.dispose();
fs.rmSync(store, {
recursive: true,
force: true,
maxRetries: 10,
retryDelay: 200,
});
fs.rmSync(work, {
recursive: true,
force: true,
maxRetries: 10,
retryDelay: 200,
});
}
});
it('keeps the error code of a host builtin failure inside imported code', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-node-repl-dep-'));
const dep = path.join(dir, 'dep.mjs');
fs.writeFileSync(
dep,
[
"const fs = (await import('node:fs')).default;",
"fs.readFileSync('/no/such/file/here.json');",
'export const x = 1;',
].join('\n'),
);
const manager = makeManager();
try {
const r = await manager.exec({
code: [
'{ let out = "none";',
` try { await import(${JSON.stringify(dep)}); }`,
' catch (e) { out = [e instanceof Error, e.code].join("|"); }',
' nodeRepl.write(out); }',
].join('\n'),
timeoutMs: 30_000,
});
expect(r.status).toBe('ok');
// ENOENT and instanceof must survive the loader's error wrapping.
expect(textOf(r.events).trim()).toBe('true|ENOENT');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('preserves the class and properties of an error thrown by an imported module', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-node-repl-err-'));
const modulePath = path.join(dir, 'thrower.mjs');
fs.writeFileSync(
modulePath,
[
'export class CustomError extends Error {',
' constructor(m) { super(m); this.name = "CustomError"; this.code = "E_CUSTOM"; }',
'}',
'throw new CustomError("from module");',
].join('\n'),
);
const manager = makeManager();
try {
const outcome = await manager.exec({
code: [
'let seen = "none";',
'try {',
` await import(${JSON.stringify(modulePath)});`,
'} catch (e) {',
' seen = [e.name, e.code, e instanceof Error].join("|");',
'}',
'nodeRepl.write(seen);',
].join('\n'),
timeoutMs: 30_000,
});
expect(outcome.status).toBe('ok');
// Class, custom `code`, and realm-correct instanceof must all survive.
expect(textOf(outcome.events).trim()).toBe('CustomError|E_CUSTOM|true');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});