From a8dfa185984eee47708a2a8099ff0edecc78ede8 Mon Sep 17 00:00:00 2001 From: DragonnZhang <731557579@qq.com> Date: Sat, 25 Apr 2026 10:57:14 +0800 Subject: [PATCH] test(desktop): add electron cdp e2e harness --- .../cdp-renderer-observability.md | 52 +- ...de-electron-desktop-implementation-plan.md | 60 +- packages/desktop/package.json | 1 + packages/desktop/scripts/e2e-cdp-smoke.mjs | 618 ++++++++++++++++++ .../src/main/acp/createE2eAcpClient.ts | 268 ++++++++ packages/desktop/src/main/main.ts | 24 +- packages/desktop/src/main/native/dialogs.ts | 5 + 7 files changed, 1019 insertions(+), 9 deletions(-) create mode 100644 packages/desktop/scripts/e2e-cdp-smoke.mjs create mode 100644 packages/desktop/src/main/acp/createE2eAcpClient.ts diff --git a/.qwen/e2e-tests/electron-desktop/cdp-renderer-observability.md b/.qwen/e2e-tests/electron-desktop/cdp-renderer-observability.md index 4d5d6ffd9..f3ece854a 100644 --- a/.qwen/e2e-tests/electron-desktop/cdp-renderer-observability.md +++ b/.qwen/e2e-tests/electron-desktop/cdp-renderer-observability.md @@ -60,8 +60,54 @@ Slice 10: Renderer Asset Loading and CDP Port. the npm lifecycle therefore reported a termination error, which was expected for this manual smoke. +## Iteration 9 Automated CDP Harness + +Slice 14 added `npm run e2e:cdp --workspace=packages/desktop`, implemented in +`packages/desktop/scripts/e2e-cdp-smoke.mjs`. + +The harness launches Electron with: + +- `QWEN_DESKTOP_CDP_PORT=` bound to `127.0.0.1`; +- a temporary HOME, runtime directory, and Electron userData directory; +- a temporary Git workspace with one modified file and one untracked file; +- `QWEN_DESKTOP_E2E_FAKE_ACP=1` so session, prompt, and permission UI can be + exercised without external credentials; +- `QWEN_DESKTOP_TEST_SELECT_DIRECTORY=` so the normal preload + directory-selection path can be driven without a native dialog. + +Additional assertions now covered: + +- renderer target is reachable through CDP; +- first workspace screen has stable DOM landmarks and screenshots; +- renderer console errors and failed network requests are collected; +- Open Project registers the temporary Git workspace and shows changed files; +- New Thread creates a fake ACP session and connects WebSocket chat; +- sending a prompt shows a command approval request and approval response; +- settings save updates the visible model summary; +- terminal drawer runs a harmless project-scoped command and shows output. + +Diagnostics on failure now include screenshot PNGs, DOM text, renderer console +errors, failed network requests, Electron stdout/stderr, and Git status/diff +under ignored +`.qwen/e2e-tests/electron-desktop/artifacts//`. + +## Iteration 9 Execution Results + +- `npm run typecheck --workspace=packages/desktop` passed. +- `npm run build --workspace=packages/desktop` passed. +- `npm run e2e:cdp --workspace=packages/desktop` passed. +- `npm run typecheck` passed. +- `npm run build` passed, with existing VS Code companion warnings only. +- `npm run bundle && npm run package:dir --workspace=packages/desktop && + npm run smoke:package --workspace=packages/desktop` passed. +- `npm run smoke:package --workspace=packages/desktop -- --launch` passed. +- Passing run artifacts: + `.qwen/e2e-tests/electron-desktop/artifacts/2026-04-25T02-54-48-799Z/`. +- The passing run reported `consoleErrors: []` and `failedRequests: []`. + ## Remaining Risk -This slice proves the CDP endpoint exists, but it does not yet automate DOM -text checks, console/network collection, or screenshot validation. Slice 14 must -connect through Playwright or Chrome DevTools MCP and persist those diagnostics. +This harness covers renderer/CDP observability and the main P0 workbench paths, +but it is a development E2E smoke using fake ACP. Final MVP verification still +needs the remaining review/terminal polish called out in the implementation +plan. diff --git a/design/qwen-code-electron-desktop-implementation-plan.md b/design/qwen-code-electron-desktop-implementation-plan.md index 585b8e8fe..98d20d44a 100644 --- a/design/qwen-code-electron-desktop-implementation-plan.md +++ b/design/qwen-code-electron-desktop-implementation-plan.md @@ -269,9 +269,15 @@ scope before a DONE marker can be created. ### Slice 14: Desktop E2E Harness -- Status: pending +- Status: complete in iteration 9 - Goal: add repeatable Electron E2E harness with fake ACP, temporary HOME and workspace, screenshot/console/network diagnostics, and CDP renderer access. +- Files: + - `packages/desktop/package.json` + - `packages/desktop/scripts/e2e-cdp-smoke.mjs` + - `packages/desktop/src/main/acp/createE2eAcpClient.ts` + - `packages/desktop/src/main/main.ts` + - `packages/desktop/src/main/native/dialogs.ts` - Acceptance criteria: - `QWEN_DESKTOP_CDP_PORT` is used by the harness to inspect the renderer on `127.0.0.1`. @@ -280,6 +286,32 @@ scope before a DONE marker can be created. works, and package smoke still passes. - Failures write screenshots, console errors, failed requests, and main logs under `.qwen/e2e-tests/electron-desktop/`. +- Completed: + - Added `npm run e2e:cdp --workspace=packages/desktop`. + - Harness launches Electron with `QWEN_DESKTOP_CDP_PORT`, a temporary HOME, + temporary runtime/userData directories, a temporary Git workspace, and a + fake ACP client enabled only through E2E environment variables. + - CDP checks stable workbench landmarks, opens the test project through the + preload dialog path, creates a fake local thread, sends a prompt, responds + to a command approval request, saves model settings, runs a scoped terminal + command, and captures initial/final screenshots. + - Failure diagnostics include screenshots, DOM text, renderer console errors, + failed network requests, Electron stdout/stderr, and Git status/diff. +- Verification: + - `npm run typecheck --workspace=packages/desktop` passed. + - `npm run build --workspace=packages/desktop` passed. + - `npm run e2e:cdp --workspace=packages/desktop` passed. Success artifacts + were written under ignored + `.qwen/e2e-tests/electron-desktop/artifacts/2026-04-25T02-54-48-799Z/`. + - `npm run typecheck` passed. + - `npm run build` passed. + - Bundle/package smoke passed: + `npm run bundle && npm run package:dir --workspace=packages/desktop && npm run smoke:package --workspace=packages/desktop`. + - After tightening the E2E fake ACP gate, package dir, package smoke, and + packaged launch smoke passed again. +- E2E coverage: + - Recorded in + `.qwen/e2e-tests/electron-desktop/cdp-renderer-observability.md`. ## Decision Log @@ -314,6 +346,10 @@ scope before a DONE marker can be created. extract the visible workspace regions into layout components. This preserves server-backed behavior while making the workbench structure testable through stable DOM landmarks. +- 2026-04-25: Add a fake ACP client only behind + `QWEN_DESKTOP_E2E_FAKE_ACP=1` so the Electron E2E harness can cover session, + prompt, and permission UI without credentials or network calls. Production + startup still creates the real `AcpProcessClient`. ## Verification Log @@ -358,6 +394,20 @@ scope before a DONE marker can be created. - `npm run build` passed across the configured build order. Existing VS Code companion lint warnings were reported by its build script, with no errors. - `npm run typecheck` passed across workspaces. +- 2026-04-25 Slice 14 desktop E2E harness: + - `npm run typecheck --workspace=packages/desktop` passed. + - `npm run build --workspace=packages/desktop` passed. + - `npm run e2e:cdp --workspace=packages/desktop` passed and reported no + renderer console errors or failed network requests. + - `npm run typecheck` passed across workspaces. + - `npm run build` passed across workspaces. Existing VS Code companion lint + warnings were reported by its build script, with no errors. + - Bundle/package smoke passed: + `npm run bundle && npm run package:dir --workspace=packages/desktop && npm run smoke:package --workspace=packages/desktop`. + Electron builder reported non-fatal metadata/dependency warnings + consistent with prior package runs. + - After tightening the E2E fake ACP gate, package dir, package smoke, and + packaged launch smoke passed again. ## Self Review Notes @@ -390,10 +440,14 @@ scope before a DONE marker can be created. - Slice 11 did not broaden preload or IPC. The renderer shell split is a pure component refactor with stable DOM landmarks for the future Electron/CDP harness. +- Slice 14 E2E hooks are gated by explicit environment variables: + `QWEN_DESKTOP_E2E`, `QWEN_DESKTOP_E2E_FAKE_ACP`, + `QWEN_DESKTOP_E2E_USER_DATA_DIR`, and + `QWEN_DESKTOP_TEST_SELECT_DIRECTORY`. Normal desktop startup still uses the + native directory picker and real ACP process. ## Remaining Work - Implement hunk-level diff review, terminal PTY/write/send-output-to-AI - refinements, Electron E2E harness, DevTools MCP - DOM/console/network/screenshot checks, and final package smoke before + refinements, final package smoke, and any remaining MVP polish before creating the DONE marker. diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 4b9b9888d..702115776 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -11,6 +11,7 @@ "build:preload": "esbuild src/preload/index.ts --bundle --platform=node --format=cjs --external:electron --outfile=dist/preload/index.cjs", "build:renderer": "vite build", "dev:renderer": "vite --host 127.0.0.1", + "e2e:cdp": "node scripts/e2e-cdp-smoke.mjs", "lint": "eslint src --ext .ts,.tsx", "package": "electron-builder --config electron-builder.yml", "package:dir": "electron-builder --dir --config electron-builder.yml", diff --git a/packages/desktop/scripts/e2e-cdp-smoke.mjs b/packages/desktop/scripts/e2e-cdp-smoke.mjs new file mode 100644 index 000000000..94045dba4 --- /dev/null +++ b/packages/desktop/scripts/e2e-cdp-smoke.mjs @@ -0,0 +1,618 @@ +#!/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 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(); + } +} diff --git a/packages/desktop/src/main/acp/createE2eAcpClient.ts b/packages/desktop/src/main/acp/createE2eAcpClient.ts new file mode 100644 index 000000000..956e0b46e --- /dev/null +++ b/packages/desktop/src/main/acp/createE2eAcpClient.ts @@ -0,0 +1,268 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + AuthenticateResponse, + ListSessionsResponse, + LoadSessionResponse, + NewSessionResponse, + PromptResponse, + RequestPermissionRequest, + RequestPermissionResponse, + SessionNotification, + SetSessionModeResponse, + SetSessionModelResponse, +} from '@agentclientprotocol/sdk'; +import type { + AcpSessionClient, + DesktopApprovalMode, +} from '../../server/services/sessionService.js'; + +const E2E_MODEL_ID = 'e2e/qwen-code'; +const E2E_MODES = [ + { + id: 'default' as const, + name: 'Default', + description: 'Ask before running commands.', + }, + { + id: 'auto-edit' as const, + name: 'Auto Edit', + description: 'Allow edits while keeping command approvals visible.', + }, +]; + +interface E2eSessionRecord { + sessionId: string; + cwd: string; + title: string; + updatedAt: string; +} + +export class E2eAcpClient implements AcpSessionClient { + readonly isConnected = true; + private readonly sessions: E2eSessionRecord[] = []; + private nextSessionId = 1; + private currentMode: DesktopApprovalMode = 'default'; + private currentModelId = E2E_MODEL_ID; + + onSessionUpdate: (notification: SessionNotification) => void = () => {}; + onPermissionRequest: ( + request: RequestPermissionRequest, + ) => Promise = async () => ({ + outcome: { outcome: 'cancelled' }, + }); + + async connect(): Promise {} + + disconnect(): void {} + + async listSessions( + options: { + cwd?: string; + } = {}, + ): Promise { + return { + sessions: this.sessions + .filter((session) => !options.cwd || session.cwd === options.cwd) + .map((session) => ({ + sessionId: session.sessionId, + cwd: session.cwd, + title: session.title, + updatedAt: session.updatedAt, + })), + }; + } + + async newSession(cwd: string): Promise { + const session: E2eSessionRecord = { + sessionId: `session-e2e-${this.nextSessionId}`, + cwd, + title: 'E2E desktop task', + updatedAt: new Date().toISOString(), + }; + this.nextSessionId += 1; + this.sessions.unshift(session); + + return { + sessionId: session.sessionId, + models: this.getModels(), + modes: this.getModes(), + }; + } + + async loadSession( + sessionId: string, + cwd: string, + ): Promise { + if (!this.sessions.some((session) => session.sessionId === sessionId)) { + this.sessions.unshift({ + sessionId, + cwd, + title: 'Loaded E2E desktop task', + updatedAt: new Date().toISOString(), + }); + } + + return { + models: this.getModels(), + modes: this.getModes(), + }; + } + + async prompt(sessionId: string, prompt: string): Promise { + this.emit(sessionId, { + sessionUpdate: 'plan', + entries: [ + { + content: 'Inspect the opened project', + priority: 'high', + status: 'completed', + }, + { + content: 'Request command approval', + priority: 'high', + status: 'in_progress', + }, + ], + }); + + const permission = await this.onPermissionRequest({ + sessionId, + toolCall: { + toolCallId: 'e2e-terminal-check', + kind: 'execute', + title: 'Run desktop E2E command', + status: 'pending', + rawInput: 'printf desktop-e2e', + }, + options: [ + { + optionId: 'approve_once', + name: 'Approve Once', + kind: 'allow_once', + }, + { + optionId: 'approve_for_thread', + name: 'Approve for Thread', + kind: 'allow_always', + }, + { + optionId: 'deny', + name: 'Deny', + kind: 'reject_once', + }, + ], + }); + + this.emit(sessionId, { + sessionUpdate: 'tool_call_update', + toolCallId: 'e2e-terminal-check', + kind: 'execute', + title: 'Run desktop E2E command', + status: + permission.outcome.outcome === 'selected' && + permission.outcome.optionId !== 'deny' + ? 'completed' + : 'failed', + rawOutput: permission.outcome.outcome, + }); + this.emit(sessionId, { + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: `E2E fake ACP response received: ${prompt}`, + }, + }); + + return { stopReason: 'end_turn' }; + } + + async cancel(_sessionId: string): Promise {} + + async authenticate(_methodId = 'default'): Promise { + return {}; + } + + async setMode( + _sessionId: string, + modeId: string, + ): Promise { + if (modeId === 'default' || modeId === 'auto-edit') { + this.currentMode = modeId; + } + } + + async setModel( + _sessionId: string, + modelId: string, + ): Promise { + this.currentModelId = modelId; + } + + async extMethod>( + method: string, + params: Record, + ): Promise { + if (method === 'getAccountInfo') { + return { + authType: 'e2e', + model: this.currentModelId, + baseUrl: 'http://127.0.0.1/e2e', + apiKeyEnvKey: null, + } as unknown as T; + } + + if (method === 'renameSession') { + const session = this.sessions.find( + (entry) => entry.sessionId === params['sessionId'], + ); + if (session && typeof params['title'] === 'string') { + session.title = params['title']; + } + return {} as T; + } + + if (method === 'deleteSession') { + const sessionIndex = this.sessions.findIndex( + (entry) => entry.sessionId === params['sessionId'], + ); + if (sessionIndex >= 0) { + this.sessions.splice(sessionIndex, 1); + } + return {} as T; + } + + return {} as T; + } + + private getModels(): NonNullable { + return { + currentModelId: this.currentModelId, + availableModels: [ + { + modelId: E2E_MODEL_ID, + name: 'Qwen Code E2E', + }, + ], + }; + } + + private getModes(): NonNullable { + return { + currentModeId: this.currentMode, + availableModes: E2E_MODES, + }; + } + + private emit(sessionId: string, update: SessionNotification['update']): void { + this.onSessionUpdate({ sessionId, update }); + } +} + +export function createE2eAcpClient(): E2eAcpClient { + return new E2eAcpClient(); +} diff --git a/packages/desktop/src/main/main.ts b/packages/desktop/src/main/main.ts index f250fd864..033b08faf 100644 --- a/packages/desktop/src/main/main.ts +++ b/packages/desktop/src/main/main.ts @@ -9,15 +9,18 @@ import { shouldQuitWhenWindowsClosed } from './lifecycle/AppLifecycle.js'; import { configureDesktopRemoteDebugging } from './lifecycle/remoteDebugging.js'; import { registerIpc } from './ipc/registerIpc.js'; import { createMainWindow } from './windows/MainWindow.js'; +import { createE2eAcpClient } from './acp/createE2eAcpClient.js'; import { resolveDesktopAcpLaunchConfig } from './acp/resolveCli.js'; import { AcpProcessClient } from '../server/acp/AcpProcessClient.js'; import { startDesktopServer } from '../server/index.js'; import type { DesktopServer } from '../server/types.js'; +import type { AcpSessionClient } from '../server/services/sessionService.js'; let desktopServer: DesktopServer | undefined; -let acpClient: AcpProcessClient | undefined; +let acpClient: DesktopMainAcpClient | undefined; let mainWindow: BrowserWindow | null = null; +configureE2eUserDataPath(); configureDesktopRemoteDebugging(app.commandLine); const gotSingleInstanceLock = app.requestSingleInstanceLock(); @@ -59,7 +62,7 @@ if (!gotSingleInstanceLock) { }); app.on('before-quit', () => { - acpClient?.disconnect(); + acpClient?.disconnect?.(); void desktopServer?.close(); }); } @@ -76,7 +79,11 @@ async function bootstrap(): Promise { execPath: process.execPath, }); process.env['QWEN_DESKTOP_CLI_PATH'] ??= acpLaunchConfig.cliEntryPath; - acpClient = new AcpProcessClient(acpLaunchConfig); + acpClient = + process.env['QWEN_DESKTOP_E2E'] === '1' && + process.env['QWEN_DESKTOP_E2E_FAKE_ACP'] === '1' + ? createE2eAcpClient() + : new AcpProcessClient(acpLaunchConfig); desktopServer = await startDesktopServer({ acpClient }); registerIpc({ getServerInfo: () => { @@ -104,6 +111,17 @@ async function createOrFocusMainWindow(): Promise { }); } +type DesktopMainAcpClient = AcpSessionClient & { + disconnect?: () => void; +}; + +function configureE2eUserDataPath(): void { + const userDataPath = process.env['QWEN_DESKTOP_E2E_USER_DATA_DIR']; + if (process.env['QWEN_DESKTOP_E2E'] === '1' && userDataPath) { + app.setPath('userData', userDataPath); + } +} + function registerContentSecurityPolicy(): void { session.defaultSession.webRequest.onHeadersReceived((details, callback) => { callback({ diff --git a/packages/desktop/src/main/native/dialogs.ts b/packages/desktop/src/main/native/dialogs.ts index e18738036..aa53420ab 100644 --- a/packages/desktop/src/main/native/dialogs.ts +++ b/packages/desktop/src/main/native/dialogs.ts @@ -9,6 +9,11 @@ import { dialog, type BrowserWindow, type OpenDialogOptions } from 'electron'; export async function selectDirectory( owner: BrowserWindow | null, ): Promise { + const e2eDirectory = process.env['QWEN_DESKTOP_TEST_SELECT_DIRECTORY']; + if (process.env['QWEN_DESKTOP_E2E'] === '1' && e2eDirectory) { + return e2eDirectory; + } + const options: OpenDialogOptions = { properties: ['openDirectory', 'createDirectory'], };