mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
627 lines
16 KiB
JavaScript
627 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { execFile, spawn } from 'node:child_process';
|
|
import { createWriteStream } from 'node:fs';
|
|
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
|
import { createServer } from 'node:net';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import electronPath from 'electron';
|
|
import { WebSocket } from 'ws';
|
|
|
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
const packageDir = resolve(scriptDir, '..');
|
|
const repoRoot = resolve(packageDir, '../..');
|
|
const artifactRoot = join(
|
|
repoRoot,
|
|
'.qwen',
|
|
'e2e-tests',
|
|
'electron-desktop',
|
|
'artifacts',
|
|
);
|
|
|
|
const consoleErrors = [];
|
|
const failedRequests = [];
|
|
|
|
let appProcess;
|
|
let cdp;
|
|
let artifactDir;
|
|
let workspaceDir;
|
|
|
|
async function main() {
|
|
await assertBuiltDesktop();
|
|
artifactDir = await createArtifactDir();
|
|
workspaceDir = await createGitWorkspace();
|
|
const homeDir = await mkdtemp(join(tmpdir(), 'qwen-desktop-e2e-home-'));
|
|
const runtimeDir = await mkdtemp(join(tmpdir(), 'qwen-desktop-e2e-runtime-'));
|
|
const userDataDir = await mkdtemp(
|
|
join(tmpdir(), 'qwen-desktop-e2e-user-data-'),
|
|
);
|
|
const cdpPort = await getFreePort();
|
|
|
|
appProcess = launchDesktopApp({
|
|
cdpPort,
|
|
homeDir,
|
|
runtimeDir,
|
|
userDataDir,
|
|
workspaceDir,
|
|
});
|
|
|
|
const target = await waitForCdpTarget(cdpPort);
|
|
cdp = await CdpClient.connect(target.webSocketDebuggerUrl);
|
|
cdp.onEvent((event) => collectBrowserEvent(event));
|
|
|
|
await cdp.send('Page.enable');
|
|
await cdp.send('Runtime.enable');
|
|
await cdp.send('Network.enable');
|
|
await cdp.send('Log.enable');
|
|
await cdp.send('Page.bringToFront');
|
|
await waitForText('Qwen Code');
|
|
await assertWorkbenchLandmarks();
|
|
await saveScreenshot('initial-workspace.png');
|
|
|
|
await clickButton('Open Project');
|
|
await waitForText('desktop-e2e-workspace');
|
|
await waitForText('README.md');
|
|
await waitForText('Accept Hunk');
|
|
await clickButton('Accept Hunk');
|
|
await waitForText('Accepted');
|
|
await setFieldByAriaLabel(
|
|
'Review comment for README.md',
|
|
'Review note from E2E',
|
|
);
|
|
await clickButton('Add Comment');
|
|
await waitForText('Review note from E2E');
|
|
await waitForSelector('[data-testid="project-list"]');
|
|
|
|
await clickButton('New Thread');
|
|
await waitForText('session-e2e-1');
|
|
await waitForText('Connected to session-e2e-1');
|
|
await waitForSelector('[data-testid="thread-list"]');
|
|
|
|
await setFieldByAriaLabel('Message', 'Please exercise command approval.');
|
|
await clickButton('Send');
|
|
await waitForText('Approve Once');
|
|
await clickButton('Approve Once');
|
|
await waitForText('E2E fake ACP response received');
|
|
await waitForText('Turn complete: end_turn');
|
|
|
|
await setFieldByLabel('Model', 'qwen-e2e-cdp');
|
|
await setFieldByLabel('Base URL', 'https://example.invalid/v1');
|
|
await setFieldByLabel('API key', 'sk-desktop-e2e');
|
|
await clickButton('Save');
|
|
await waitForText('qwen-e2e-cdp');
|
|
|
|
await setFieldByAriaLabel('Terminal command', 'printf desktop-e2e-terminal');
|
|
await clickButton('Run');
|
|
await waitForText('desktop-e2e-terminal');
|
|
|
|
await saveScreenshot('completed-workspace.png');
|
|
await assertNoBrowserErrors();
|
|
await writeFile(
|
|
join(artifactDir, 'summary.json'),
|
|
`${JSON.stringify(
|
|
{
|
|
ok: true,
|
|
workspaceDir,
|
|
consoleErrors,
|
|
failedRequests,
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
'utf8',
|
|
);
|
|
|
|
console.log(`Desktop CDP smoke passed. Artifacts: ${artifactDir}`);
|
|
}
|
|
|
|
async function assertBuiltDesktop() {
|
|
try {
|
|
await Promise.all([
|
|
readFile(join(packageDir, 'dist', 'main', 'main.js')),
|
|
readFile(join(packageDir, 'dist', 'preload', 'index.cjs')),
|
|
readFile(join(packageDir, 'dist', 'renderer', 'index.html')),
|
|
]);
|
|
} catch {
|
|
throw new Error(
|
|
'Desktop build output is missing. Run npm run build --workspace=packages/desktop before e2e:cdp.',
|
|
);
|
|
}
|
|
}
|
|
|
|
async function createArtifactDir() {
|
|
const stamp = new Date().toISOString().replace(/[:.]/gu, '-');
|
|
const dir = join(artifactRoot, stamp);
|
|
await mkdir(dir, { recursive: true });
|
|
return dir;
|
|
}
|
|
|
|
async function createGitWorkspace() {
|
|
const dir = await mkdtemp(join(tmpdir(), 'desktop-e2e-workspace-'));
|
|
await writeFile(join(dir, 'README.md'), '# Desktop E2E\n\ninitial\n', 'utf8');
|
|
await writeFile(
|
|
join(dir, 'package.json'),
|
|
`${JSON.stringify({ name: 'desktop-e2e-workspace' }, null, 2)}\n`,
|
|
'utf8',
|
|
);
|
|
await execFileP('git', ['init'], { cwd: dir });
|
|
await execFileP('git', ['config', 'user.email', 'desktop-e2e@example.test'], {
|
|
cwd: dir,
|
|
});
|
|
await execFileP('git', ['config', 'user.name', 'Desktop E2E'], { cwd: dir });
|
|
await execFileP('git', ['add', '.'], { cwd: dir });
|
|
await execFileP('git', ['commit', '-m', 'initial commit'], { cwd: dir });
|
|
await writeFile(join(dir, 'README.md'), '# Desktop E2E\n\nchanged\n', 'utf8');
|
|
await writeFile(join(dir, 'notes.txt'), 'review me\n', 'utf8');
|
|
return dir;
|
|
}
|
|
|
|
function launchDesktopApp({
|
|
cdpPort,
|
|
homeDir,
|
|
runtimeDir,
|
|
userDataDir,
|
|
workspaceDir,
|
|
}) {
|
|
const logStream = createWriteStream(join(artifactDir, 'electron.log'));
|
|
const child = spawn(electronPath, ['.'], {
|
|
cwd: packageDir,
|
|
env: {
|
|
...process.env,
|
|
HOME: homeDir,
|
|
QWEN_RUNTIME_DIR: runtimeDir,
|
|
QWEN_DESKTOP_CDP_PORT: String(cdpPort),
|
|
QWEN_DESKTOP_E2E: '1',
|
|
QWEN_DESKTOP_E2E_FAKE_ACP: '1',
|
|
QWEN_DESKTOP_E2E_USER_DATA_DIR: userDataDir,
|
|
QWEN_DESKTOP_TEST_SELECT_DIRECTORY: workspaceDir,
|
|
ELECTRON_ENABLE_LOGGING: '1',
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
child.stdout?.pipe(logStream, { end: false });
|
|
child.stderr?.pipe(logStream, { end: false });
|
|
child.on('exit', (code, signal) => {
|
|
logStream.write(`\n[desktop exited] code=${code} signal=${signal}\n`);
|
|
logStream.end();
|
|
});
|
|
|
|
return child;
|
|
}
|
|
|
|
async function waitForCdpTarget(port) {
|
|
const deadline = Date.now() + 20_000;
|
|
let lastError;
|
|
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/json/list`);
|
|
const targets = await response.json();
|
|
const target = targets.find(
|
|
(entry) =>
|
|
entry.type === 'page' &&
|
|
typeof entry.webSocketDebuggerUrl === 'string' &&
|
|
(entry.title === 'Qwen Code' ||
|
|
entry.url.includes('/dist/renderer/index.html')),
|
|
);
|
|
if (target) {
|
|
return target;
|
|
}
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
|
|
await delay(250);
|
|
}
|
|
|
|
throw new Error(
|
|
`Timed out waiting for Electron CDP target on port ${port}: ${
|
|
lastError instanceof Error ? lastError.message : 'no response'
|
|
}`,
|
|
);
|
|
}
|
|
|
|
async function assertWorkbenchLandmarks() {
|
|
const landmarks = await evaluate(`(() => {
|
|
return [
|
|
'desktop-workspace',
|
|
'project-sidebar',
|
|
'workspace-topbar',
|
|
'workspace-grid',
|
|
'chat-thread',
|
|
'review-panel',
|
|
'terminal-drawer'
|
|
].filter((id) => !document.querySelector('[data-testid="' + id + '"]'));
|
|
})()`);
|
|
|
|
if (landmarks.length > 0) {
|
|
throw new Error(`Missing workbench landmarks: ${landmarks.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
async function waitForText(text, timeoutMs = 15_000) {
|
|
await waitFor(
|
|
`text "${text}"`,
|
|
async () =>
|
|
evaluate(`document.body.innerText.includes(${JSON.stringify(text)})`),
|
|
timeoutMs,
|
|
);
|
|
}
|
|
|
|
async function waitForSelector(selector, timeoutMs = 15_000) {
|
|
await waitFor(
|
|
`selector "${selector}"`,
|
|
async () =>
|
|
evaluate(`document.querySelector(${JSON.stringify(selector)}) !== null`),
|
|
timeoutMs,
|
|
);
|
|
}
|
|
|
|
async function clickButton(text) {
|
|
const clicked = await evaluate(`(() => {
|
|
const button = [...document.querySelectorAll('button')]
|
|
.find((candidate) =>
|
|
!candidate.disabled &&
|
|
candidate.textContent &&
|
|
candidate.textContent.trim().includes(${JSON.stringify(text)})
|
|
);
|
|
if (!button) {
|
|
return false;
|
|
}
|
|
button.click();
|
|
return true;
|
|
})()`);
|
|
|
|
if (!clicked) {
|
|
throw new Error(`Button not found or disabled: ${text}`);
|
|
}
|
|
}
|
|
|
|
async function setFieldByAriaLabel(label, value) {
|
|
const changed = await evaluate(`(() => {
|
|
const field = document.querySelector('[aria-label="${escapeSelector(
|
|
label,
|
|
)}"]');
|
|
if (!field) {
|
|
return false;
|
|
}
|
|
setNativeFieldValue(field, ${JSON.stringify(value)});
|
|
return true;
|
|
|
|
function setNativeFieldValue(element, nextValue) {
|
|
const descriptor = Object.getOwnPropertyDescriptor(
|
|
element.constructor.prototype,
|
|
'value'
|
|
);
|
|
descriptor?.set?.call(element, nextValue);
|
|
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
})()`);
|
|
|
|
if (!changed) {
|
|
throw new Error(`Field not found: ${label}`);
|
|
}
|
|
}
|
|
|
|
async function setFieldByLabel(label, value) {
|
|
const changed = await evaluate(`(() => {
|
|
const targetLabel = ${JSON.stringify(label)}.toLowerCase();
|
|
const labelElement = [...document.querySelectorAll('label')]
|
|
.find((candidate) =>
|
|
candidate.innerText.trim().toLowerCase().startsWith(targetLabel)
|
|
);
|
|
const field = labelElement?.querySelector('input, textarea, select');
|
|
if (!field) {
|
|
return false;
|
|
}
|
|
setNativeFieldValue(field, ${JSON.stringify(value)});
|
|
return true;
|
|
|
|
function setNativeFieldValue(element, nextValue) {
|
|
const descriptor = Object.getOwnPropertyDescriptor(
|
|
element.constructor.prototype,
|
|
'value'
|
|
);
|
|
descriptor?.set?.call(element, nextValue);
|
|
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
})()`);
|
|
|
|
if (!changed) {
|
|
throw new Error(`Labeled field not found: ${label}`);
|
|
}
|
|
}
|
|
|
|
async function saveScreenshot(fileName) {
|
|
const screenshot = await cdp.send('Page.captureScreenshot', {
|
|
format: 'png',
|
|
captureBeyondViewport: true,
|
|
});
|
|
await writeFile(
|
|
join(artifactDir, fileName),
|
|
Buffer.from(screenshot.data, 'base64'),
|
|
);
|
|
}
|
|
|
|
async function assertNoBrowserErrors() {
|
|
if (consoleErrors.length > 0 || failedRequests.length > 0) {
|
|
throw new Error(
|
|
`Renderer reported ${consoleErrors.length} console errors and ${failedRequests.length} failed requests.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function collectBrowserEvent(event) {
|
|
if (event.method === 'Runtime.consoleAPICalled') {
|
|
const type = event.params?.type;
|
|
if (type === 'error' || type === 'assert') {
|
|
consoleErrors.push(event.params);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (event.method === 'Log.entryAdded') {
|
|
const entry = event.params?.entry;
|
|
if (entry?.level === 'error') {
|
|
consoleErrors.push(entry);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (event.method === 'Network.loadingFailed') {
|
|
const params = event.params;
|
|
if (params?.errorText !== 'net::ERR_ABORTED') {
|
|
failedRequests.push(params);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (event.method === 'Network.responseReceived') {
|
|
const response = event.params?.response;
|
|
if (
|
|
response &&
|
|
response.url.startsWith('http://127.0.0.1:') &&
|
|
response.status >= 400
|
|
) {
|
|
failedRequests.push({
|
|
url: response.url,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
async function evaluate(expression) {
|
|
const result = await cdp.send('Runtime.evaluate', {
|
|
expression,
|
|
awaitPromise: true,
|
|
returnByValue: true,
|
|
});
|
|
|
|
if (result.exceptionDetails) {
|
|
throw new Error(
|
|
result.exceptionDetails.text ||
|
|
result.exceptionDetails.exception?.description ||
|
|
'Renderer evaluation failed.',
|
|
);
|
|
}
|
|
|
|
return result.result.value;
|
|
}
|
|
|
|
async function waitFor(description, predicate, timeoutMs) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastError;
|
|
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
if (await predicate()) {
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
await delay(150);
|
|
}
|
|
|
|
throw new Error(
|
|
`Timed out waiting for ${description}${
|
|
lastError instanceof Error ? `: ${lastError.message}` : ''
|
|
}`,
|
|
);
|
|
}
|
|
|
|
async function writeDiagnostics(error) {
|
|
if (!artifactDir) {
|
|
artifactDir = await createArtifactDir();
|
|
}
|
|
|
|
if (cdp) {
|
|
try {
|
|
await saveScreenshot('failure.png');
|
|
const domText = await evaluate('document.body.innerText');
|
|
await writeFile(join(artifactDir, 'dom.txt'), `${domText}\n`, 'utf8');
|
|
} catch (diagnosticError) {
|
|
await writeFile(
|
|
join(artifactDir, 'diagnostic-error.txt'),
|
|
`${diagnosticError instanceof Error ? diagnosticError.stack : diagnosticError}\n`,
|
|
'utf8',
|
|
);
|
|
}
|
|
}
|
|
|
|
if (workspaceDir) {
|
|
await writeCommandOutput('git-status.txt', 'git', [
|
|
'-C',
|
|
workspaceDir,
|
|
'status',
|
|
'--porcelain=v1',
|
|
'--branch',
|
|
]);
|
|
await writeCommandOutput('git-diff.txt', 'git', [
|
|
'-C',
|
|
workspaceDir,
|
|
'diff',
|
|
]);
|
|
}
|
|
|
|
await writeFile(
|
|
join(artifactDir, 'console-errors.json'),
|
|
`${JSON.stringify(consoleErrors, null, 2)}\n`,
|
|
'utf8',
|
|
);
|
|
await writeFile(
|
|
join(artifactDir, 'failed-requests.json'),
|
|
`${JSON.stringify(failedRequests, null, 2)}\n`,
|
|
'utf8',
|
|
);
|
|
await writeFile(
|
|
join(artifactDir, 'failure.txt'),
|
|
`${error instanceof Error ? error.stack : error}\n`,
|
|
'utf8',
|
|
);
|
|
console.error(`Desktop CDP smoke failed. Diagnostics: ${artifactDir}`);
|
|
}
|
|
|
|
async function writeCommandOutput(fileName, command, args) {
|
|
try {
|
|
const { stdout, stderr } = await execFileP(command, args);
|
|
await writeFile(join(artifactDir, fileName), `${stdout}${stderr}`, 'utf8');
|
|
} catch (error) {
|
|
await writeFile(
|
|
join(artifactDir, fileName),
|
|
`${error instanceof Error ? error.message : error}\n`,
|
|
'utf8',
|
|
);
|
|
}
|
|
}
|
|
|
|
async function getFreePort() {
|
|
const server = createServer();
|
|
await new Promise((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', resolve);
|
|
});
|
|
const address = server.address();
|
|
await new Promise((resolve) => server.close(resolve));
|
|
|
|
if (!address || typeof address === 'string') {
|
|
throw new Error('Unable to allocate a TCP port.');
|
|
}
|
|
|
|
return address.port;
|
|
}
|
|
|
|
function execFileP(command, args, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
execFile(command, args, options, (error, stdout, stderr) => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
|
|
resolve({ stdout, stderr });
|
|
});
|
|
});
|
|
}
|
|
|
|
function escapeSelector(value) {
|
|
return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
class CdpClient {
|
|
static async connect(webSocketUrl) {
|
|
const socket = new WebSocket(webSocketUrl);
|
|
const client = new CdpClient(socket);
|
|
await new Promise((resolve, reject) => {
|
|
socket.once('open', resolve);
|
|
socket.once('error', reject);
|
|
});
|
|
return client;
|
|
}
|
|
|
|
constructor(socket) {
|
|
this.socket = socket;
|
|
this.nextId = 1;
|
|
this.pending = new Map();
|
|
this.eventHandlers = new Set();
|
|
this.socket.on('message', (message) => {
|
|
this.handleMessage(message);
|
|
});
|
|
this.socket.on('close', () => {
|
|
for (const pending of this.pending.values()) {
|
|
pending.reject(new Error('CDP socket closed.'));
|
|
}
|
|
this.pending.clear();
|
|
});
|
|
}
|
|
|
|
send(method, params = {}) {
|
|
const id = this.nextId;
|
|
this.nextId += 1;
|
|
const payload = { id, method, params };
|
|
this.socket.send(JSON.stringify(payload));
|
|
|
|
return new Promise((resolve, reject) => {
|
|
this.pending.set(id, { resolve, reject });
|
|
});
|
|
}
|
|
|
|
onEvent(handler) {
|
|
this.eventHandlers.add(handler);
|
|
}
|
|
|
|
close() {
|
|
this.socket.close();
|
|
}
|
|
|
|
handleMessage(rawMessage) {
|
|
const message = JSON.parse(rawMessage.toString());
|
|
if (typeof message.id === 'number') {
|
|
const pending = this.pending.get(message.id);
|
|
if (!pending) {
|
|
return;
|
|
}
|
|
this.pending.delete(message.id);
|
|
if (message.error) {
|
|
pending.reject(new Error(message.error.message));
|
|
} else {
|
|
pending.resolve(message.result ?? {});
|
|
}
|
|
return;
|
|
}
|
|
|
|
for (const handler of this.eventHandlers) {
|
|
handler(message);
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
await main();
|
|
} catch (error) {
|
|
await writeDiagnostics(error);
|
|
throw error;
|
|
} finally {
|
|
cdp?.close();
|
|
if (appProcess && !appProcess.killed) {
|
|
appProcess.kill();
|
|
}
|
|
}
|