mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-23 07:19:52 +00:00
* 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
316 lines
11 KiB
JavaScript
316 lines
11 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { readFile, stat } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const {
|
|
spawnMock,
|
|
execSyncMock,
|
|
platformMock,
|
|
existsSyncMock,
|
|
readFileSyncMock,
|
|
writeFileSyncMock,
|
|
copyBrowserUseAssetsMock,
|
|
} = vi.hoisted(() => ({
|
|
spawnMock: vi.fn(() => ({ on: vi.fn() })),
|
|
execSyncMock: vi.fn(),
|
|
platformMock: vi.fn(() => 'darwin'),
|
|
existsSyncMock: vi.fn(() => false),
|
|
readFileSyncMock: vi.fn(() => JSON.stringify({ version: '0.0.0-test' })),
|
|
writeFileSyncMock: vi.fn(),
|
|
copyBrowserUseAssetsMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../copy-browser-use-assets.js', () => ({
|
|
copyBrowserUseAssets: copyBrowserUseAssetsMock,
|
|
}));
|
|
|
|
vi.mock('node:child_process', () => ({
|
|
spawn: spawnMock,
|
|
execSync: execSyncMock,
|
|
}));
|
|
|
|
vi.mock('node:os', async (importOriginal) => {
|
|
const actual = await importOriginal();
|
|
return {
|
|
...actual,
|
|
platform: platformMock,
|
|
tmpdir: vi.fn(() => '/tmp'),
|
|
};
|
|
});
|
|
|
|
vi.mock('node:fs', () => ({
|
|
writeFileSync: writeFileSyncMock,
|
|
mkdtempSync: vi.fn(() => '/tmp/qwen-dev-test'),
|
|
rmSync: vi.fn(),
|
|
existsSync: existsSyncMock,
|
|
symlinkSync: vi.fn(),
|
|
mkdirSync: vi.fn(),
|
|
readFileSync: readFileSyncMock,
|
|
readdirSync: vi.fn(() => []),
|
|
}));
|
|
|
|
const normalizePath = (path) => String(path).replaceAll('\\', '/');
|
|
|
|
describe('scripts/dev.js launcher', () => {
|
|
const originalArgv = process.argv;
|
|
const execPathDescriptor = Object.getOwnPropertyDescriptor(
|
|
process,
|
|
'execPath',
|
|
);
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.clearAllMocks();
|
|
process.argv = ['node', 'scripts/dev.js'];
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.argv = originalArgv;
|
|
if (execPathDescriptor) {
|
|
Object.defineProperty(process, 'execPath', execPathDescriptor);
|
|
}
|
|
});
|
|
|
|
it('spawns Node without a shell on Windows when local tsx cli.mjs exists', async () => {
|
|
platformMock.mockReturnValue('win32');
|
|
existsSyncMock.mockImplementation((filePath) =>
|
|
normalizePath(filePath).endsWith('node_modules/tsx/dist/cli.mjs'),
|
|
);
|
|
Object.defineProperty(process, 'execPath', {
|
|
configurable: true,
|
|
value: 'C:\\Program Files\\nodejs\\node.exe',
|
|
});
|
|
process.argv = ['node', 'scripts/dev.js', '--help'];
|
|
|
|
await import('../dev.js?direct-node');
|
|
|
|
const [command, args, options] = spawnMock.mock.calls[0];
|
|
expect(command).toBe('C:\\Program Files\\nodejs\\node.exe');
|
|
expect(args.map(normalizePath)).toEqual([
|
|
expect.stringContaining('node_modules/tsx/dist/cli.mjs'),
|
|
'--tsconfig',
|
|
expect.stringContaining('packages/cli/tsconfig.json'),
|
|
expect.stringContaining('packages/cli/index.ts'),
|
|
'--help',
|
|
]);
|
|
expect(options).toEqual(expect.objectContaining({ shell: false }));
|
|
});
|
|
|
|
it.each(['--version', '--help', '-p'])(
|
|
'launches %s without building or staging retired skill assets',
|
|
async (flag) => {
|
|
process.argv = ['node', 'scripts/dev.js', flag];
|
|
|
|
await import('../dev.js?browser-use');
|
|
|
|
expect(execSyncMock).not.toHaveBeenCalled();
|
|
expect(copyBrowserUseAssetsMock).not.toHaveBeenCalled();
|
|
expect(spawnMock).toHaveBeenCalledOnce();
|
|
expect(spawnMock.mock.calls[0][1]).toContain(flag);
|
|
},
|
|
);
|
|
|
|
it('keeps shell fallback for Windows tsx.cmd resolution', async () => {
|
|
platformMock.mockReturnValue('win32');
|
|
existsSyncMock.mockImplementation((filePath) =>
|
|
normalizePath(filePath).endsWith('node_modules/.bin/tsx.cmd'),
|
|
);
|
|
|
|
await import('../dev.js?cmd-fallback');
|
|
|
|
const [command, args, options] = spawnMock.mock.calls[0];
|
|
expect(normalizePath(command)).toContain('tsx.cmd');
|
|
expect(args.map(normalizePath)).toEqual([
|
|
'--tsconfig',
|
|
expect.stringContaining('packages/cli/tsconfig.json'),
|
|
expect.stringContaining('packages/cli/index.ts'),
|
|
]);
|
|
expect(options).toEqual(expect.objectContaining({ shell: true }));
|
|
});
|
|
|
|
it('re-raises a child signal instead of exiting 0 — close(null, SIGKILL) is not success', async () => {
|
|
// `code ?? 0` read a signal-killed child as green. This launcher is a
|
|
// QWEN_CODE_CLI entry now: an OOM-killed review gate command must not come
|
|
// back as a passing exit.
|
|
const exitSpy = vi
|
|
.spyOn(process, 'exit')
|
|
.mockImplementation(() => undefined);
|
|
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
|
try {
|
|
await import('../dev.js?signal-close');
|
|
const child = spawnMock.mock.results[0].value;
|
|
const close = child.on.mock.calls.find(([ev]) => ev === 'close')[1];
|
|
close(null, 'SIGKILL');
|
|
expect(killSpy).toHaveBeenCalledWith(process.pid, 'SIGKILL');
|
|
expect(exitSpy).not.toHaveBeenCalledWith(0);
|
|
} finally {
|
|
exitSpy.mockRestore();
|
|
killSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('stamps QWEN_CODE_CLI with its own path, overriding an inherited one', async () => {
|
|
// A dev CLI started from inside another qwen session's shell inherits that
|
|
// session's QWEN_CODE_CLI. Honouring it points every `qwen …` subprocess of
|
|
// THIS session at the OUTER session's build — the exact version skew the
|
|
// variable exists to prevent, one level up and silent. Each entry stamps
|
|
// itself; nested sessions each call their own build.
|
|
const inherited = process.env.QWEN_CODE_CLI;
|
|
process.env.QWEN_CODE_CLI = '/somewhere/else/entirely/qwen';
|
|
try {
|
|
await import('../dev.js?stamps-own-cli');
|
|
|
|
const [, , options] = spawnMock.mock.calls[0];
|
|
expect(normalizePath(options.env.QWEN_CODE_CLI)).toMatch(
|
|
/scripts\/dev\.js$/,
|
|
);
|
|
} finally {
|
|
if (inherited === undefined) delete process.env.QWEN_CODE_CLI;
|
|
else process.env.QWEN_CODE_CLI = inherited;
|
|
}
|
|
});
|
|
|
|
it.skipIf(process.platform === 'win32')(
|
|
'keeps the dev entry executable for QWEN_CODE_CLI subprocesses',
|
|
async () => {
|
|
const fs = await vi.importActual('node:fs');
|
|
expect(() =>
|
|
fs.accessSync(new URL('../dev.js', import.meta.url), fs.constants.X_OK),
|
|
).not.toThrow();
|
|
},
|
|
);
|
|
|
|
it('resolves core subpaths to packages/core/src, not the exports map dist', async () => {
|
|
// Intercepting only the package root leaves a named subpath to Node's
|
|
// `exports` map, which resolves into packages/core/dist while the root
|
|
// loads packages/core/src — one dev process holding two instances of the
|
|
// same module. Config binds the debug session on the src copy, so the dist
|
|
// copy's REMOTE_INPUT logger reads an empty session and every
|
|
// debugLogger(...) call in RemoteInputWatcher silently no-ops.
|
|
const repoRoot = fileURLToPath(new URL('../../', import.meta.url));
|
|
const coreDir = join(repoRoot, 'packages', 'core');
|
|
const corePackageJson = await readFile(
|
|
join(coreDir, 'package.json'),
|
|
'utf-8',
|
|
);
|
|
const existsOnDisk = async (p) => {
|
|
try {
|
|
await stat(p);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// Ground truth independent of the implementation's derivation rule.
|
|
const expectedSources = {
|
|
'@qwen-code/qwen-code-core': 'packages/core/index.ts',
|
|
'@qwen-code/qwen-code-core/debugLogger':
|
|
'packages/core/src/utils/debugLogger.ts',
|
|
'@qwen-code/qwen-code-core/storage':
|
|
'packages/core/src/config/storage.ts',
|
|
'@qwen-code/qwen-code-core/atomicFileWrite':
|
|
'packages/core/src/utils/atomicFileWrite.ts',
|
|
'@qwen-code/qwen-code-core/utils/debugLogger.js':
|
|
'packages/core/src/utils/debugLogger.ts',
|
|
};
|
|
|
|
// Every named subpath the exports map publishes, so the interception
|
|
// cannot silently fall behind as subpaths are added.
|
|
const namedSubpaths = [];
|
|
for (const [subpath, conditions] of Object.entries(
|
|
JSON.parse(corePackageJson).exports ?? {},
|
|
)) {
|
|
const distEntry = conditions?.import;
|
|
if (subpath === '.' || typeof distEntry !== 'string') continue;
|
|
if (!distEntry.startsWith('./dist/')) continue;
|
|
const sourcePath = join(
|
|
coreDir,
|
|
distEntry.slice('./dist/'.length).replace(/\.js$/, '.ts'),
|
|
);
|
|
if (await existsOnDisk(sourcePath)) {
|
|
namedSubpaths.push(`@qwen-code/qwen-code-core/${subpath.slice(2)}`);
|
|
}
|
|
}
|
|
expect(namedSubpaths.length).toBeGreaterThan(0);
|
|
|
|
const defaultRead = readFileSyncMock.getMockImplementation();
|
|
const defaultExists = existsSyncMock.getMockImplementation();
|
|
try {
|
|
readFileSyncMock.mockImplementation((filePath, ...rest) =>
|
|
normalizePath(filePath).endsWith('packages/core/package.json')
|
|
? corePackageJson
|
|
: defaultRead(filePath, ...rest),
|
|
);
|
|
// The launcher only probes the source files it is about to map, so answer
|
|
// from the real tree: a mapping whose source is missing must be dropped
|
|
// rather than emitted.
|
|
existsSyncMock.mockImplementation((filePath) => {
|
|
const normalized = normalizePath(filePath);
|
|
return (
|
|
normalized.includes('/packages/core/') &&
|
|
(Object.values(expectedSources).some((s) => normalized.endsWith(s)) ||
|
|
namedSubpaths.some((specifier) => {
|
|
const sub = specifier.slice('@qwen-code/qwen-code-core/'.length);
|
|
const distEntry =
|
|
JSON.parse(corePackageJson).exports[`./${sub}`]?.import;
|
|
return normalized.endsWith(
|
|
distEntry.slice('./dist/'.length).replace(/\.js$/, '.ts'),
|
|
);
|
|
}))
|
|
);
|
|
});
|
|
|
|
await import('../dev.js?subpath-source-map');
|
|
|
|
const loaderCall = writeFileSyncMock.mock.calls.find(([filePath]) =>
|
|
normalizePath(filePath).endsWith('loader.mjs'),
|
|
);
|
|
expect(loaderCall).toBeDefined();
|
|
// Execute the hook the launcher actually generates instead of asserting
|
|
// on its source text.
|
|
const loader = await import(
|
|
`data:text/javascript;base64,${Buffer.from(loaderCall[1]).toString('base64')}`
|
|
);
|
|
const nextResolve = (specifier) => ({
|
|
url: `fallthrough:${specifier}`,
|
|
format: 'module',
|
|
shortCircuit: false,
|
|
});
|
|
|
|
for (const [specifier, expected] of Object.entries(expectedSources)) {
|
|
const resolved = loader.resolve(specifier, {}, nextResolve);
|
|
expect(resolved.shortCircuit, specifier).toBe(true);
|
|
const resolvedPath = normalizePath(fileURLToPath(resolved.url));
|
|
expect(resolvedPath, specifier).toContain(expected);
|
|
expect(resolvedPath, specifier).not.toContain('/dist/');
|
|
}
|
|
|
|
// Completeness: no published named subpath may fall through to Node,
|
|
// which is the lane that reaches dist.
|
|
for (const specifier of namedSubpaths) {
|
|
const resolved = loader.resolve(specifier, {}, nextResolve);
|
|
expect(resolved.shortCircuit, specifier).toBe(true);
|
|
expect(resolved.url, specifier).not.toContain('fallthrough:');
|
|
expect(normalizePath(fileURLToPath(resolved.url)), specifier).toContain(
|
|
'/packages/core/src/',
|
|
);
|
|
}
|
|
|
|
// Unrelated specifiers still reach Node's resolver.
|
|
expect(loader.resolve('node:fs', {}, nextResolve).url).toBe(
|
|
'fallthrough:node:fs',
|
|
);
|
|
} finally {
|
|
readFileSyncMock.mockImplementation(defaultRead);
|
|
existsSyncMock.mockImplementation(defaultExists);
|
|
}
|
|
});
|
|
});
|