From 2c2fad502b1904be47eb77924af64a0afddced6a Mon Sep 17 00:00:00 2001 From: jinye Date: Fri, 7 Aug 2026 18:56:40 +0800 Subject: [PATCH 1/3] fix(integration-tests): make the project typecheckable and fix what that found `tsc -p integration-tests/tsconfig.json` could not run at all. The config carried a `"//"` documentation key inside `compilerOptions.paths`, and every value there must be an array, so tsc aborted with TS5063 before checking a single file. Nothing in CI runs it either, so the directory has been unchecked for its whole life -- which is how PR #8620 shipped an `integration-tests/cli/qwen-serve-streaming.test.ts` that referenced an undeclared `REPO_ROOT`, swallowed the ReferenceError in a bare catch, and reported a green skip for a security regression test. Moving that note out of `paths` exposed 404 errors. Three more config defects accounted for 353 of them: - `composite: true` is inherited from the root config for the packages that are actually referenced. Composite requires every file in the program to appear in `include`, and these tests import package sources by relative path, so it produced 324 TS6307. Nothing references this project and it emits nothing, so it is now `composite: false`. - The root `lib` is ES2023 only. The suite drives browser-side code in `terminal-capture/` and pulls SDK sources that name `WebSocket` and `HeadersInit`, so 21 identifiers resolved to nothing. Now DOM + DOM.Iterable + ES2023, matching packages/cli. - Workspace packages resolved through `packages/core/dist` via a project reference, so with core unbuilt the checker reported a dozen members as missing from `Storage` that are right there in the source. They now resolve from source through `paths`, mirroring packages/cli, and the reference is gone. node-pty declares `types` at the top level but its `exports` map is a bare string with no `types` condition, so nodenext never reached the declarations and every pty handle degraded to `any` -- which is what silently untyped the `data` and `exitCode` callbacks in test-helper.ts. It now resolves through `paths` as well. `@types/jsdom` is added for the one file that uses it; DefinitelyTyped has no release matching jsdom 26 (it jumps 21 -> 27), so this pins the current 28.x. Two real defects fell out of the remaining 51: - write_file.test.ts built a detailed tool-call failure message and passed it to `toBeTruthy()`, which takes no arguments. It was discarded on every failure, leaving only a bare literal. - Two terminal-capture scenarios set `gif: true` inside `streaming`, where the runner never reads it. It is a scenario-level switch. The rest was making an existing `undefined` visible. `readToolLogs()` promised `name: string` for fields copied straight out of telemetry attributes that nothing validates; the stdout fallback can promise them, the telemetry branch cannot, and claiming otherwise just moved the `undefined` past the type checker into the assertions. This is type resolution only. `integration-tests/vitest.config.ts` keeps its own hardcoded aliases onto the built SDK bundle, so the suite still exercises the published-bundle shape at runtime. Not wired into CI here, but not for cost reasons: a cold run of `tsc -p integration-tests/tsconfig.json` takes about 106s on an idle developer box. The program is 2679 files, of which 103 are integration tests and roughly 1100 are package sources their own projects already check, so there is duplicated work available to reclaim by resolving the packages from their built declarations -- but at ~106s it is already cheap enough to gate on as-is. Co-Authored-By: Claude Opus 5 --- integration-tests/channel-plugin.test.ts | 11 ++-- integration-tests/cli/acp-cron.test.ts | 2 +- integration-tests/cli/file-system.test.ts | 2 +- integration-tests/cli/notebook-edit.test.ts | 4 +- .../cli/qwen-serve-streaming.test.ts | 37 +++++++++---- ...-serve-webui-live-journal-recovery.test.ts | 23 ++++---- .../cli/sleep-interception.test.ts | 18 ++++--- integration-tests/cli/stdin-context.test.ts | 7 ++- integration-tests/cli/todo_write.test.ts | 2 +- integration-tests/cli/write_file.test.ts | 8 ++- .../hook-integration/hooks-advanced.test.ts | 6 +-- .../hook-integration/hooks.test.ts | 7 +-- .../terminal-bench/terminal-bench.test.ts | 4 +- .../terminal-capture/scenarios/bugfix-2833.ts | 5 +- .../scenarios/pr-2371-review.ts | 4 +- ...e-pending-height-scroll-lock-regression.ts | 4 +- integration-tests/test-helper.ts | 39 +++++++++----- integration-tests/tsconfig.json | 53 +++++++++++++++++-- .../vitest.terminal-bench.config.ts | 2 +- package-lock.json | 45 +++++++++++++++- package.json | 1 + .../src/daemon/acpRouteTable.ts | 4 +- .../src/daemon/ui/transcript.ts | 6 +-- packages/sdk-typescript/src/query/Query.ts | 2 +- .../src/transport/ProcessTransport.ts | 4 +- 25 files changed, 224 insertions(+), 76 deletions(-) diff --git a/integration-tests/channel-plugin.test.ts b/integration-tests/channel-plugin.test.ts index c5ea92c06e1..4b15a309476 100644 --- a/integration-tests/channel-plugin.test.ts +++ b/integration-tests/channel-plugin.test.ts @@ -35,12 +35,14 @@ import { AcpBridge, SessionRouter, } from '../packages/channels/base/dist/index.js'; -import type { ChannelConfig } from '../packages/channels/base/dist/index.js'; import { MockPluginChannel, createMockServer, } from '../packages/channels/plugin-example/src/index.js'; -import type { MockServerHandle } from '../packages/channels/plugin-example/src/index.js'; +import type { + MockServerHandle, + MockPluginConfig, +} from '../packages/channels/plugin-example/src/index.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const CLI_PATH = join(__dirname, '..', 'dist', 'cli.js'); @@ -74,7 +76,9 @@ describe('Channel Plugin (Mock WebSocket E2E)', () => { await bridge.start(); // 3. Create and connect MockPluginChannel via WebSocket - const config: ChannelConfig & Record = { + // MockPluginConfig, not ChannelConfig: the constructor below requires + // `serverWsUrl`, and typing the literal as the base interface erased it. + const config: MockPluginConfig & Record = { type: 'plugin-example', token: '', senderPolicy: 'open', @@ -82,6 +86,7 @@ describe('Channel Plugin (Mock WebSocket E2E)', () => { sessionScope: 'user', cwd: testDir, groupPolicy: 'disabled', + dmPolicy: 'open', groups: {}, serverWsUrl: server.wsUrl, }; diff --git a/integration-tests/cli/acp-cron.test.ts b/integration-tests/cli/acp-cron.test.ts index d7d0846191a..f0ee85a9529 100644 --- a/integration-tests/cli/acp-cron.test.ts +++ b/integration-tests/cli/acp-cron.test.ts @@ -411,7 +411,7 @@ async function initSession( const cronUserMsg = await waitForSessionUpdate( (u) => u.update?.sessionUpdate === 'user_message_chunk' && - u.update?._meta?.source === 'cron', + u.update?._meta?.['source'] === 'cron', "cron-sourced user_message_chunk (_meta.source === 'cron')", 75_000, ); diff --git a/integration-tests/cli/file-system.test.ts b/integration-tests/cli/file-system.test.ts index bfba679ea59..790a33ded85 100644 --- a/integration-tests/cli/file-system.test.ts +++ b/integration-tests/cli/file-system.test.ts @@ -206,7 +206,7 @@ describe('file-system', () => { const readAttempt = toolLogs.find( (log) => log.toolRequest.name === 'read_file' && - log.toolRequest.args.includes(fileName), + log.toolRequest.args?.includes(fileName), ); const editAttempt = toolLogs.find( (log) => log.toolRequest.name === 'edit_file', diff --git a/integration-tests/cli/notebook-edit.test.ts b/integration-tests/cli/notebook-edit.test.ts index 746380c5ca6..d919e99c4ca 100644 --- a/integration-tests/cli/notebook-edit.test.ts +++ b/integration-tests/cli/notebook-edit.test.ts @@ -83,9 +83,9 @@ const expectNoSuccessfulRawNotebookWrites = ( .readToolLogs() .filter( (log) => - ['edit', 'write_file'].includes(log.toolRequest.name) && + ['edit', 'write_file'].includes(log.toolRequest.name ?? '') && log.toolRequest.success && - log.toolRequest.args.includes(notebookFileName), + log.toolRequest.args?.includes(notebookFileName), ); expect(rawNotebookWrites).toEqual([]); diff --git a/integration-tests/cli/qwen-serve-streaming.test.ts b/integration-tests/cli/qwen-serve-streaming.test.ts index 6e0c68a9972..8fa57c13c9e 100644 --- a/integration-tests/cli/qwen-serve-streaming.test.ts +++ b/integration-tests/cli/qwen-serve-streaming.test.ts @@ -46,6 +46,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { isPathWithinRoot } from '@qwen-code/qwen-code-core'; import { DaemonClient, parseSseStream } from '@qwen-code/sdk'; import type { DaemonEvent, DaemonSessionSummary } from '@qwen-code/sdk'; +import type { NonBlockingPromptAccepted } from '@qwen-code/sdk/daemon'; import { fakeToolCall, startFakeOpenAIServer, @@ -135,6 +136,18 @@ function findExternalReadBase(): string | undefined { const externalReadBase = findExternalReadBase(); +// `promptNonBlocking` returns `NonBlockingPromptAccepted | PromptResult`, and +// `PromptResult` carries an index signature — so `'promptId' in accepted` does +// not narrow the union, and every field read after that check comes back as +// `unknown`. Narrow once, here. +function asAccepted( + result: Awaited>, +): NonBlockingPromptAccepted | undefined { + return 'promptId' in result + ? (result as NonBlockingPromptAccepted) + : undefined; +} + let daemon: ChildProcess; let port = 0; let base = ''; @@ -619,11 +632,13 @@ describePOSIX('qwen serve — same-host external text reads', () => { const requestStart = fakeServer.requests.length; try { await new Promise((resolve) => setTimeout(resolve, 200)); - const accepted = await client.promptNonBlocking(session.sessionId, { - prompt: [{ type: 'text', text: marker }], - }); - expect('promptId' in accepted).toBe(true); - if (!('promptId' in accepted)) return; + const accepted = asAccepted( + await client.promptNonBlocking(session.sessionId, { + prompt: [{ type: 'text', text: marker }], + }), + ); + expect(accepted).toBeDefined(); + if (!accepted) return; promptId = accepted.promptId; await expect.poll(findReadPermission, { timeout: 30_000 }).toBeDefined(); @@ -766,11 +781,13 @@ describePOSIX('qwen serve — daemon Todo Stop Guard replay', () => { }); const requestStart = fakeServer.requests.length; const guardMarker = `todo-guard-e2e-${requestStart}`; - const accepted = await client.promptNonBlocking(session.sessionId, { - prompt: [{ type: 'text', text: guardMarker }], - }); - expect('promptId' in accepted).toBe(true); - if (!('promptId' in accepted)) return; + const accepted = asAccepted( + await client.promptNonBlocking(session.sessionId, { + prompt: [{ type: 'text', text: guardMarker }], + }), + ); + expect(accepted).toBeDefined(); + if (!accepted) return; await expect .poll( diff --git a/integration-tests/cli/qwen-serve-webui-live-journal-recovery.test.ts b/integration-tests/cli/qwen-serve-webui-live-journal-recovery.test.ts index 1d8c3bb1c08..1e59d75d855 100644 --- a/integration-tests/cli/qwen-serve-webui-live-journal-recovery.test.ts +++ b/integration-tests/cli/qwen-serve-webui-live-journal-recovery.test.ts @@ -157,16 +157,19 @@ describe('qwen serve WebUI live journal recovery', () => { root = createRoot(container); await act(async () => { root?.render( - createElement( - DaemonSessionProvider, - { - autoConnect: true, - baseUrl: activeDaemon!.base, - token: activeDaemon!.token, - sessionId: created.sessionId, - }, - createElement(Harness), - ), + // `children` is the one required prop on DaemonSessionProviderProps, + // and a trailing createElement argument does not satisfy it — the + // call only type checks with children in the props object. The lint + // rule guards JSX readability, which does not apply in this .ts file + // where createElement is already being called by hand. + // eslint-disable-next-line react/no-children-prop + createElement(DaemonSessionProvider, { + autoConnect: true, + baseUrl: activeDaemon!.base, + token: activeDaemon!.token, + sessionId: created.sessionId, + children: createElement(Harness), + }), ); }); diff --git a/integration-tests/cli/sleep-interception.test.ts b/integration-tests/cli/sleep-interception.test.ts index cf2feb404b8..514d1372fd3 100644 --- a/integration-tests/cli/sleep-interception.test.ts +++ b/integration-tests/cli/sleep-interception.test.ts @@ -20,9 +20,12 @@ describe('sleep-interception', () => { } }); + // Mirrors the optionality of the parsed telemetry these come from: a + // malformed record yields `undefined` rather than a crash, and the + // predicates below already treat that as "does not match". type ShellCall = { - args: string; - success: boolean; + args?: string; + success?: boolean; error?: string; }; @@ -67,7 +70,7 @@ describe('sleep-interception', () => { ); const foundBlockedCall = await waitForShellCall( - (call) => call.args.includes('sleep 5') && !call.success, + (call) => !!call.args?.includes('sleep 5') && !call.success, ); if (!foundBlockedCall) { @@ -85,7 +88,7 @@ describe('sleep-interception', () => { // error attribute is only available from file-based telemetry; the // podman stdout fallback leaves it undefined. const blockedCall = shellCalls().find( - (call) => call.args.includes('sleep 5') && !call.success, + (call) => !!call.args?.includes('sleep 5') && !call.success, ); if (blockedCall?.error !== undefined) { expect(blockedCall.error).toContain('Monitor'); @@ -107,7 +110,7 @@ describe('sleep-interception', () => { ); const foundSuccessfulCall = await waitForShellCall( - (call) => call.args.includes('sleep 1') && call.success, + (call) => !!call.args?.includes('sleep 1') && call.success === true, ); if (!foundSuccessfulCall) { @@ -140,7 +143,8 @@ describe('sleep-interception', () => { // The escape hatch worked iff a call carrying the intentional-sleep // comment completed successfully. const foundIntentionalCall = await waitForShellCall( - (call) => call.args.includes('intentional-sleep') && call.success, + (call) => + !!call.args?.includes('intentional-sleep') && call.success === true, ); if (!foundIntentionalCall) { @@ -175,7 +179,7 @@ describe('sleep-interception', () => { ); const foundBlockedCall = await waitForShellCall( - (call) => call.args.includes('sleep 5') && !call.success, + (call) => !!call.args?.includes('sleep 5') && !call.success, ); if (!foundBlockedCall) { diff --git a/integration-tests/cli/stdin-context.test.ts b/integration-tests/cli/stdin-context.test.ts index 2dd4aca7459..81f8de2ae64 100644 --- a/integration-tests/cli/stdin-context.test.ts +++ b/integration-tests/cli/stdin-context.test.ts @@ -26,7 +26,12 @@ describe.skip('stdin context', () => { const lastRequest = rig.readLastApiRequest(); expect(lastRequest).not.toBeNull(); - const historyString = lastRequest.attributes.request_text; + // `expect(...).not.toBeNull()` is a runtime check; it does not narrow the + // type. Assert the shape explicitly so the `indexOf` calls below are not + // reaching into `unknown`. + const historyString = String( + lastRequest?.attributes?.['request_text'] ?? '', + ); // TODO: This test currently fails in sandbox mode (Docker/Podman) because // stdin content is not properly forwarded to the container when used diff --git a/integration-tests/cli/todo_write.test.ts b/integration-tests/cli/todo_write.test.ts index 5bc28125f53..bae4d4649fc 100644 --- a/integration-tests/cli/todo_write.test.ts +++ b/integration-tests/cli/todo_write.test.ts @@ -49,7 +49,7 @@ Use the todo_write tool to create this list.`; expect(todoWriteCalls.length).toBeGreaterThan(0); // Parse the arguments to verify they contain our tasks - const todoArgs = JSON.parse(todoWriteCalls[0].toolRequest.args); + const todoArgs = JSON.parse(todoWriteCalls[0].toolRequest.args ?? '{}'); expect(todoArgs.todos).toBeDefined(); expect(Array.isArray(todoArgs.todos)).toBe(true); diff --git a/integration-tests/cli/write_file.test.ts b/integration-tests/cli/write_file.test.ts index 2440c593139..6d6e7aea5ce 100644 --- a/integration-tests/cli/write_file.test.ts +++ b/integration-tests/cli/write_file.test.ts @@ -28,13 +28,17 @@ describe('write_file', () => { } const allTools = rig.readToolLogs(); - expect(foundToolCall, 'Expected to find a write_file tool call').toBeTruthy( + // The detailed message belongs on `expect`, not on `toBeTruthy` — the + // latter takes no arguments, so this diagnostic was being built and + // discarded on every failure, leaving only the bare literal. + expect( + foundToolCall, createToolCallErrorMessage( 'write_file', allTools.map((t) => t.toolRequest.name), result, ), - ); + ).toBeTruthy(); // Validate model output - will throw if no output, warn if missing expected content validateModelOutput(result, 'dad.txt', 'Write file test'); diff --git a/integration-tests/hook-integration/hooks-advanced.test.ts b/integration-tests/hook-integration/hooks-advanced.test.ts index da73786e807..d922831351c 100644 --- a/integration-tests/hook-integration/hooks-advanced.test.ts +++ b/integration-tests/hook-integration/hooks-advanced.test.ts @@ -316,7 +316,7 @@ describe('HTTP Hooks Integration', () => { const requestLogs = mockServer.getRequestLogs(); if (requestLogs.length > 0) { - expect(requestLogs[0].body.hook_event_name).toBe('UserPromptSubmit'); + expect(requestLogs[0].body['hook_event_name']).toBe('UserPromptSubmit'); } }); @@ -398,7 +398,7 @@ describe('HTTP Hooks Integration', () => { const requestLogs = mockServer.getRequestLogs(); if (requestLogs.length > 0) { - expect(requestLogs[0].body.hook_event_name).toBe('PostToolUse'); + expect(requestLogs[0].body['hook_event_name']).toBe('PostToolUse'); } }); }); @@ -441,7 +441,7 @@ describe('HTTP Hooks Integration', () => { const requestLogs = mockServer.getRequestLogs(); if (requestLogs.length > 0) { - expect(requestLogs[0].body.hook_event_name).toBe('SessionStart'); + expect(requestLogs[0].body['hook_event_name']).toBe('SessionStart'); } }); }); diff --git a/integration-tests/hook-integration/hooks.test.ts b/integration-tests/hook-integration/hooks.test.ts index 1be3c3139c2..4247fd84cb9 100644 --- a/integration-tests/hook-integration/hooks.test.ts +++ b/integration-tests/hook-integration/hooks.test.ts @@ -1427,11 +1427,8 @@ describe('Hooks System Integration', () => { }); // When Stop hooks block, agent continues execution normally (with max turns to prevent infinite loop) - const _result = await rig.run( - 'Say all block', - '--max-session-turns', - '3', - ); + // The run is the subject of the assertions below; its output is not. + await rig.run('Say all block', '--max-session-turns', '3'); // Verify Stop hook was invoked multiple times (indicating multiple rounds) const hookInvokeCount = rig diff --git a/integration-tests/terminal-bench/terminal-bench.test.ts b/integration-tests/terminal-bench/terminal-bench.test.ts index ed5348a9c7a..ed26d225f43 100644 --- a/integration-tests/terminal-bench/terminal-bench.test.ts +++ b/integration-tests/terminal-bench/terminal-bench.test.ts @@ -94,7 +94,9 @@ describe('terminal-bench integration', () => { .map((s) => s.trim()) .filter(Boolean); - const available = new Set(baseTestTasks.map((t) => t)); + // Set, not Set: the whole point is to test + // arbitrary env-supplied ids for membership. + const available = new Set(baseTestTasks); const unknown = selected.filter((s) => !available.has(s)); if (unknown.length > 0) { throw new Error( diff --git a/integration-tests/terminal-capture/scenarios/bugfix-2833.ts b/integration-tests/terminal-capture/scenarios/bugfix-2833.ts index dffa2567fd0..17e715aa141 100644 --- a/integration-tests/terminal-capture/scenarios/bugfix-2833.ts +++ b/integration-tests/terminal-capture/scenarios/bugfix-2833.ts @@ -9,6 +9,10 @@ export default { name: 'streaming-bugfix-2833', spawn: ['node', 'dist/cli.js', '--yolo'], terminal: { title: 'qwen-code', cwd: '../../..' }, + // Generate an animated GIF. This is a scenario-level switch (see + // ScenarioConfig); it used to sit inside `streaming` below, where the runner + // never read it. + gif: true, flow: [ { type: '/qc:bugfix https://github.com/QwenLM/qwen-code/issues/2833', @@ -17,7 +21,6 @@ export default { delayMs: 10000, // Wait 10s for initial prompt processing intervalMs: 30000, // Capture every 30 seconds count: 50, // Up to 25 minutes of capture (50 * 30s) - gif: true, // Generate animated GIF }, }, ], diff --git a/integration-tests/terminal-capture/scenarios/pr-2371-review.ts b/integration-tests/terminal-capture/scenarios/pr-2371-review.ts index 0752f0a207b..7054e690c28 100644 --- a/integration-tests/terminal-capture/scenarios/pr-2371-review.ts +++ b/integration-tests/terminal-capture/scenarios/pr-2371-review.ts @@ -4,6 +4,9 @@ export default { name: 'pr-2371-review', spawn: ['node', 'dist/cli.js', '--yolo'], terminal: { title: 'qwen-code', cwd: '../../..' }, + // `gif` is a scenario-level switch (see ScenarioConfig). It used to sit + // inside `streaming` below, where the runner never read it. + gif: true, flow: [ { type: '/review https://github.com/QwenLM/qwen-code/pull/2371', @@ -11,7 +14,6 @@ export default { delayMs: 5000, intervalMs: 10000, // Every 10s count: 60, // 10 minutes total (60 * 10s) - gif: true, }, }, ], diff --git a/integration-tests/terminal-capture/table-pending-height-scroll-lock-regression.ts b/integration-tests/terminal-capture/table-pending-height-scroll-lock-regression.ts index a26562c4ac8..3d62f1bfea8 100644 --- a/integration-tests/terminal-capture/table-pending-height-scroll-lock-regression.ts +++ b/integration-tests/terminal-capture/table-pending-height-scroll-lock-regression.ts @@ -47,7 +47,9 @@ * QWEN_TUI_E2E_OUT output dir (default under os.tmpdir()) * QWEN_TUI_E2E_REPO repo root whose dist/cli.js is launched */ -import { createServer, type AddressInfo } from 'node:http'; +import { createServer } from 'node:http'; +// AddressInfo is declared by node:net, not node:http. +import type { AddressInfo } from 'node:net'; import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { basename, dirname, join, resolve } from 'node:path'; diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index 87323d9d60d..1c626c8baf3 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -27,7 +27,11 @@ function sanitizeTestName(name: string) { // Helper to create detailed error messages export function createToolCallErrorMessage( expectedTools: string | string[], - foundTools: string[], + // Callers build this by mapping `toolRequest.name` over the parsed + // telemetry, where the name is optional. This is a failure message, so a + // missing entry should print as `undefined` rather than force every call + // site to filter first. + foundTools: Array, result: string, ) { const expectedStr = Array.isArray(expectedTools) @@ -170,6 +174,10 @@ interface ParsedLog { duration_ms?: number; status?: string; 'error.message'?: string; + // Telemetry carries far more attributes than the tool-call subset named + // above; callers reach them by key (`attributes['request_text']`). Every + // value is `unknown` because nothing validates the payload shape. + [key: string]: unknown; }; scopeMetrics?: { metrics: { @@ -227,7 +235,7 @@ export class TestRig { otlpEndpoint: '', outfile: telemetryPath, }, - sandbox: env.QWEN_SANDBOX !== 'false' ? env.QWEN_SANDBOX : false, + sandbox: env['QWEN_SANDBOX'] !== 'false' ? env['QWEN_SANDBOX'] : false, ...options.settings, // Allow tests to override/add settings }; writeFileSync( @@ -261,7 +269,7 @@ export class TestRig { initialArgs: string[]; } { const isNpmReleaseTest = - process.env.INTEGRATION_TEST_USE_INSTALLED_GEMINI === 'true'; + process.env['INTEGRATION_TEST_USE_INSTALLED_GEMINI'] === 'true'; const command = isNpmReleaseTest ? 'qwen' : 'node'; const initialArgs = isNpmReleaseTest ? ['--no-chat-recording', ...extraInitialArgs] @@ -442,14 +450,14 @@ export class TestRig { child.stdout!.on('data', (data: Buffer) => { stdout += data; - if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { + if (env['KEEP_OUTPUT'] === 'true' || env['VERBOSE'] === 'true') { process.stdout.write(data); } }); child.stderr!.on('data', (data: Buffer) => { stderr += data; - if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { + if (env['KEEP_OUTPUT'] === 'true' || env['VERBOSE'] === 'true') { process.stderr.write(data); } }); @@ -777,7 +785,7 @@ export class TestRig { logs.push(logData); } catch (e) { // Skip objects that aren't valid JSON - if (env.VERBOSE === 'true') { + if (env['VERBOSE'] === 'true') { console.error('Failed to parse telemetry object:', e); } } @@ -816,12 +824,17 @@ export class TestRig { } const parsedLogs = this._readAndParseTelemetryLog(); + // Every field is optional because it is copied straight out of the + // telemetry attributes, which nothing validates. The stdout fallback above + // reconstructs the same fields from a regex and can promise them; this + // branch cannot, and claiming otherwise just moved the `undefined` past + // the type checker into the assertions. const logs: { toolRequest: { - name: string; - args: string; - success: boolean; - duration_ms: number; + name?: string; + args?: string; + success?: boolean; + duration_ms?: number; status?: string; error?: string; }; @@ -850,7 +863,9 @@ export class TestRig { return logs; } - readLastApiRequest(): Record | null { + // Returns the parsed log, not a bare record: callers want `.attributes`, + // and `Record` hid that the value already has a shape. + readLastApiRequest(): ParsedLog | null { const logs = this._readAndParseTelemetryLog(); const apiRequests = logs.filter( (logData) => @@ -909,7 +924,7 @@ export class TestRig { ptyProcess.onData((data) => { this._interactiveOutput += data; - if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { + if (env['KEEP_OUTPUT'] === 'true' || env['VERBOSE'] === 'true') { process.stdout.write(data); } }); diff --git a/integration-tests/tsconfig.json b/integration-tests/tsconfig.json index 8e94434c0c3..b2946e90476 100644 --- a/integration-tests/tsconfig.json +++ b/integration-tests/tsconfig.json @@ -3,12 +3,57 @@ "compilerOptions": { "noEmit": true, "allowJs": true, + // Nothing references this project and it emits nothing, but the root + // config turns `composite` on for the packages that do. Composite demands + // that every file in the program appear in `include`, and these tests + // import package sources across the repo by relative path, so inheriting + // it produced 300+ TS6307 "not listed within the file list" errors. + "composite": false, + // Matches packages/cli. The suite drives browser-side code in + // `terminal-capture/` and pulls SDK sources that reference `WebSocket` / + // `HeadersInit`, none of which exist in the root's ES2023-only lib. + "lib": ["DOM", "DOM.Iterable", "ES2023"], "baseUrl": ".", + // Resolve `@qwen-code/sdk` types from source rather than dist so `tsc -p` + // here does not require a fresh `npm run build` of the SDK package before + // checking integration tests. The runtime vitest alias + // (`integration-tests/vitest.config.ts`) still points at the built + // `dist/index.mjs` to exercise the published-bundle shape; this entry only + // affects type resolution. + // + // Keep notes like this OUT of `paths` itself: every value there must be an + // array, so a `"//"` string key makes tsc abort with TS5063 before it type + // checks a single file — which is how this project silently went unchecked. "paths": { - "//": "Resolve types from SDK source rather than dist so `tsc -p` here does not require a fresh `npm run build` of the SDK package before checking integration tests. The runtime vitest alias (`integration-tests/vitest.config.ts`) still points at the built `dist/index.mjs` to exercise the published-bundle shape; this paths entry only affects type resolution.", - "@qwen-code/sdk": ["../packages/sdk-typescript/src/index.ts"] + // Mirrors packages/cli. These tests import package sources by relative + // path (`../../packages/cli/src/...`), so those files get checked here + // too and must resolve their own imports the same way packages/cli does. + // Going through `packages/core/dist` instead made the result depend on + // whether someone had built core recently — and with core unbuilt it + // reported a dozen "does not exist on type Storage" errors for members + // that are right there in the source. + "@qwen-code/qwen-code-core": ["../packages/core/src/index.ts"], + "@qwen-code/qwen-code-core/transcriptRecords": [ + "../packages/core/src/utils/transcript-records.ts" + ], + "@qwen-code/qwen-code-core/*": ["../packages/core/src/*"], + "@qwen-code/sdk": ["../packages/sdk-typescript/src/index.ts"], + "@qwen-code/sdk/daemon/ui/transcript": [ + "../packages/sdk-typescript/src/daemon/ui/transcript.ts" + ], + "@qwen-code/sdk/daemon/types": [ + "../packages/sdk-typescript/src/daemon/types.ts" + ], + "@qwen-code/sdk/*": ["../packages/sdk-typescript/src/*"], + "@qwen-code/acp-bridge": ["../packages/acp-bridge/src/index.ts"], + "@qwen-code/acp-bridge/*": ["../packages/acp-bridge/src/*"], + // node-pty declares `types` at the top level but its `exports` map is a + // bare string with no `types` condition, so nodenext resolution never + // reaches the declarations and every pty handle degrades to `any` — + // which is what silently untyped the `data` / `exitCode` callbacks in + // test-helper.ts. Point at the shipped .d.ts directly. + "@lydell/node-pty": ["../node_modules/@lydell/node-pty/node-pty.d.ts"] } }, - "include": ["**/*.ts"], - "references": [{ "path": "../packages/core" }] + "include": ["**/*.ts"] } diff --git a/integration-tests/vitest.terminal-bench.config.ts b/integration-tests/vitest.terminal-bench.config.ts index f8d94753b8a..d90d1487dab 100644 --- a/integration-tests/vitest.terminal-bench.config.ts +++ b/integration-tests/vitest.terminal-bench.config.ts @@ -6,7 +6,7 @@ import { defineConfig } from 'vitest/config'; -const timeoutMinutes = Number(process.env.TB_TIMEOUT_MINUTES || '30'); +const timeoutMinutes = Number(process.env['TB_TIMEOUT_MINUTES'] || '30'); const testTimeoutMs = timeoutMinutes * 60 * 1000; export default defineConfig({ diff --git a/package-lock.json b/package-lock.json index 27889eea2c9..4c5b10ed8f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ }, "devDependencies": { "@types/chrome": "^0.1.32", + "@types/jsdom": "^28.0.3", "@types/marked": "^5.0.2", "@types/mime-types": "^3.0.1", "@types/minimatch": "^5.1.2", @@ -91,7 +92,7 @@ }, "integrations/external-context": { "name": "@qwen-code/external-context", - "version": "0.21.7", + "version": "0.20.1", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "undici": "^7.28.0", @@ -8445,6 +8446,48 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsdom": { + "version": "28.0.3", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-28.0.3.tgz", + "integrity": "sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^8.0.0", + "undici-types": "^7.21.0" + } + }, + "node_modules/@types/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@types/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/@types/jsdom/node_modules/undici-types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.29.0.tgz", + "integrity": "sha512-vamA8dGlzMwhpyYpQp9d8vka3o4D/yn5I7ez7Or+msDA4bZ8Uh+Zy91WvWf3I73gDAkFha9JcYRqm2li0Npfgg==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", diff --git a/package.json b/package.json index da9dbffc545..1985af6f917 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ ], "devDependencies": { "@types/chrome": "^0.1.32", + "@types/jsdom": "^28.0.3", "@types/marked": "^5.0.2", "@types/mime-types": "^3.0.1", "@types/minimatch": "^5.1.2", diff --git a/packages/sdk-typescript/src/daemon/acpRouteTable.ts b/packages/sdk-typescript/src/daemon/acpRouteTable.ts index 3d301210f0e..7ca6bf52d29 100644 --- a/packages/sdk-typescript/src/daemon/acpRouteTable.ts +++ b/packages/sdk-typescript/src/daemon/acpRouteTable.ts @@ -309,8 +309,8 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ return { sessionId: segs[0], artifactId: segs[1], - ...(typeof record.clientId === 'string' - ? { clientId: record.clientId } + ...(typeof record['clientId'] === 'string' + ? { clientId: record['clientId'] } : {}), }; }, diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 887c0ca2e14..f5d9ffa6ac1 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -130,13 +130,13 @@ export function appendLocalUserTranscriptMessage( // This is a dev/CI safety net; in production it is pure O(blocks) overhead on // every dispatch and the reducer's own mutation discipline (takeBlocksOwnership) // does not depend on it, so skip it there. App bundlers statically replace -// `process.env.NODE_ENV`, folding the check to `false`. The `typeof process` +// `process.env['NODE_ENV']`, folding the check to `false`. The `typeof process` // guard keeps an unbundled browser consumer from throwing a ReferenceError — // this module sits on the browser-hostile `daemon/ui` surface and Vite lib -// builds preserve `process.env.NODE_ENV` in their output — matching the +// builds preserve `process.env['NODE_ENV']` in their output — matching the // existing SDK idiom (see ProcessTransport, cliPath). const FREEZE_TRANSCRIPT_BLOCKS = - typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'; + typeof process !== 'undefined' && process.env['NODE_ENV'] !== 'production'; export function reduceDaemonTranscriptEvents( state: DaemonTranscriptState, diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index a173c8ff76b..a19457f8c54 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -1000,7 +1000,7 @@ export class Query implements AsyncIterable { ControlRequestType.SET_EFFORT, { effort }, ); - return Boolean((response as Record | null)?.applied); + return Boolean((response as Record | null)?.['applied']); } /** diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts index 5dc121f23bf..d85d740d98c 100644 --- a/packages/sdk-typescript/src/transport/ProcessTransport.ts +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -61,14 +61,14 @@ export class ProcessTransport implements Transport { this.options.debug || this.options.stderr ? 'pipe' : 'ignore'; // Check if we should use fork for Electron integration - const useFork = env.FORK_MODE === '1'; + const useFork = env['FORK_MODE'] === '1'; if (useFork) { // Detect Electron environment const isElectron = typeof process !== 'undefined' && process.versions && - !!process.versions.electron; + !!process.versions['electron']; // In Electron, process.execPath points to Electron, not Node.js // When spawnInfo uses process.execPath to run a JS file, we need to handle it specially From 1f66c987f091e849956229baf872a0354f9e2354 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Fri, 7 Aug 2026 19:04:38 +0000 Subject: [PATCH 2/3] fix(integration-tests): isolate jsdom types and complete source-resolution paths Address review round 1: - external-context: override `types` to ["node"]. The root @types/jsdom entered its program through vitest's optional jsdom types and injected lib dom, flipping @types/node's fetch globals to DOM variants whose ReadableStream is not async-iterable (TS2504 in http-client.ts), which failed every CI job during the npm ci prepare build. - integration-tests tsconfig: explicit nodenext paths entries for every workspace subpath the program imports (sdk/daemon, 19 acp-bridge subpaths, core goalWire/memoryScopes/userPromptSubmitContext, webui daemon-react-sdk, channel-base); drop the dead `*` wildcards; include **/*.tsx. Typechecks green with the source packages' dists removed. - Relax noPropertyAccessFromIndexSignature in integration-tests and revert the six bracket-access rewrites it forced in SDK sources. - channel-plugin: import channels/base from src and map @qwen-code/channel-base to source so both declarations agree. - qwen-serve-streaming: asAccepted delegates to the SDK's exported isNonBlockingAccepted type predicate instead of a drifted copy. - sleep-interception: tighten blocked predicates to success === false and fix the comment describing them. - Declare jsdom at the root next to @types/jsdom. --- integration-tests/channel-plugin.test.ts | 2 +- integration-tests/cli/acp-cron.test.ts | 2 +- .../cli/qwen-serve-streaming.test.ts | 13 +- .../cli/sleep-interception.test.ts | 10 +- .../hook-integration/hooks-advanced.test.ts | 6 +- integration-tests/test-helper.ts | 12 +- integration-tests/tsconfig.json | 122 +++++++++++++++--- .../vitest.terminal-bench.config.ts | 2 +- integrations/external-context/tsconfig.json | 9 ++ package-lock.json | 1 + package.json | 1 + .../src/daemon/acpRouteTable.ts | 4 +- .../src/daemon/ui/transcript.ts | 6 +- packages/sdk-typescript/src/query/Query.ts | 2 +- .../src/transport/ProcessTransport.ts | 4 +- 15 files changed, 144 insertions(+), 52 deletions(-) diff --git a/integration-tests/channel-plugin.test.ts b/integration-tests/channel-plugin.test.ts index 4b15a309476..643ae97431f 100644 --- a/integration-tests/channel-plugin.test.ts +++ b/integration-tests/channel-plugin.test.ts @@ -34,7 +34,7 @@ import { mkdirSync } from 'node:fs'; import { AcpBridge, SessionRouter, -} from '../packages/channels/base/dist/index.js'; +} from '../packages/channels/base/src/index.js'; import { MockPluginChannel, createMockServer, diff --git a/integration-tests/cli/acp-cron.test.ts b/integration-tests/cli/acp-cron.test.ts index f0ee85a9529..d7d0846191a 100644 --- a/integration-tests/cli/acp-cron.test.ts +++ b/integration-tests/cli/acp-cron.test.ts @@ -411,7 +411,7 @@ async function initSession( const cronUserMsg = await waitForSessionUpdate( (u) => u.update?.sessionUpdate === 'user_message_chunk' && - u.update?._meta?.['source'] === 'cron', + u.update?._meta?.source === 'cron', "cron-sourced user_message_chunk (_meta.source === 'cron')", 75_000, ); diff --git a/integration-tests/cli/qwen-serve-streaming.test.ts b/integration-tests/cli/qwen-serve-streaming.test.ts index 8fa57c13c9e..131fc03ed15 100644 --- a/integration-tests/cli/qwen-serve-streaming.test.ts +++ b/integration-tests/cli/qwen-serve-streaming.test.ts @@ -46,7 +46,10 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { isPathWithinRoot } from '@qwen-code/qwen-code-core'; import { DaemonClient, parseSseStream } from '@qwen-code/sdk'; import type { DaemonEvent, DaemonSessionSummary } from '@qwen-code/sdk'; -import type { NonBlockingPromptAccepted } from '@qwen-code/sdk/daemon'; +import { + isNonBlockingAccepted, + type NonBlockingPromptAccepted, +} from '@qwen-code/sdk/daemon'; import { fakeToolCall, startFakeOpenAIServer, @@ -136,16 +139,10 @@ function findExternalReadBase(): string | undefined { const externalReadBase = findExternalReadBase(); -// `promptNonBlocking` returns `NonBlockingPromptAccepted | PromptResult`, and -// `PromptResult` carries an index signature — so `'promptId' in accepted` does -// not narrow the union, and every field read after that check comes back as -// `unknown`. Narrow once, here. function asAccepted( result: Awaited>, ): NonBlockingPromptAccepted | undefined { - return 'promptId' in result - ? (result as NonBlockingPromptAccepted) - : undefined; + return isNonBlockingAccepted(result) ? result : undefined; } let daemon: ChildProcess; diff --git a/integration-tests/cli/sleep-interception.test.ts b/integration-tests/cli/sleep-interception.test.ts index 514d1372fd3..ffcce1f3b31 100644 --- a/integration-tests/cli/sleep-interception.test.ts +++ b/integration-tests/cli/sleep-interception.test.ts @@ -21,8 +21,8 @@ describe('sleep-interception', () => { }); // Mirrors the optionality of the parsed telemetry these come from: a - // malformed record yields `undefined` rather than a crash, and the - // predicates below already treat that as "does not match". + // malformed record yields `undefined` rather than a crash. The predicates + // below only match an explicit `success` boolean. type ShellCall = { args?: string; success?: boolean; @@ -70,7 +70,7 @@ describe('sleep-interception', () => { ); const foundBlockedCall = await waitForShellCall( - (call) => !!call.args?.includes('sleep 5') && !call.success, + (call) => !!call.args?.includes('sleep 5') && call.success === false, ); if (!foundBlockedCall) { @@ -88,7 +88,7 @@ describe('sleep-interception', () => { // error attribute is only available from file-based telemetry; the // podman stdout fallback leaves it undefined. const blockedCall = shellCalls().find( - (call) => !!call.args?.includes('sleep 5') && !call.success, + (call) => !!call.args?.includes('sleep 5') && call.success === false, ); if (blockedCall?.error !== undefined) { expect(blockedCall.error).toContain('Monitor'); @@ -179,7 +179,7 @@ describe('sleep-interception', () => { ); const foundBlockedCall = await waitForShellCall( - (call) => !!call.args?.includes('sleep 5') && !call.success, + (call) => !!call.args?.includes('sleep 5') && call.success === false, ); if (!foundBlockedCall) { diff --git a/integration-tests/hook-integration/hooks-advanced.test.ts b/integration-tests/hook-integration/hooks-advanced.test.ts index d922831351c..da73786e807 100644 --- a/integration-tests/hook-integration/hooks-advanced.test.ts +++ b/integration-tests/hook-integration/hooks-advanced.test.ts @@ -316,7 +316,7 @@ describe('HTTP Hooks Integration', () => { const requestLogs = mockServer.getRequestLogs(); if (requestLogs.length > 0) { - expect(requestLogs[0].body['hook_event_name']).toBe('UserPromptSubmit'); + expect(requestLogs[0].body.hook_event_name).toBe('UserPromptSubmit'); } }); @@ -398,7 +398,7 @@ describe('HTTP Hooks Integration', () => { const requestLogs = mockServer.getRequestLogs(); if (requestLogs.length > 0) { - expect(requestLogs[0].body['hook_event_name']).toBe('PostToolUse'); + expect(requestLogs[0].body.hook_event_name).toBe('PostToolUse'); } }); }); @@ -441,7 +441,7 @@ describe('HTTP Hooks Integration', () => { const requestLogs = mockServer.getRequestLogs(); if (requestLogs.length > 0) { - expect(requestLogs[0].body['hook_event_name']).toBe('SessionStart'); + expect(requestLogs[0].body.hook_event_name).toBe('SessionStart'); } }); }); diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index ef4a0e863d7..b35ab0303b8 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -235,7 +235,7 @@ export class TestRig { otlpEndpoint: '', outfile: telemetryPath, }, - sandbox: env['QWEN_SANDBOX'] !== 'false' ? env['QWEN_SANDBOX'] : false, + sandbox: env.QWEN_SANDBOX !== 'false' ? env.QWEN_SANDBOX : false, ...options.settings, // Allow tests to override/add settings }; writeFileSync( @@ -264,7 +264,7 @@ export class TestRig { initialArgs: string[]; } { const isNpmReleaseTest = - process.env['INTEGRATION_TEST_USE_INSTALLED_GEMINI'] === 'true'; + process.env.INTEGRATION_TEST_USE_INSTALLED_GEMINI === 'true'; const command = isNpmReleaseTest ? 'qwen' : 'node'; const initialArgs = isNpmReleaseTest ? ['--no-chat-recording', ...extraInitialArgs] @@ -445,14 +445,14 @@ export class TestRig { child.stdout!.on('data', (data: Buffer) => { stdout += data; - if (env['KEEP_OUTPUT'] === 'true' || env['VERBOSE'] === 'true') { + if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { process.stdout.write(data); } }); child.stderr!.on('data', (data: Buffer) => { stderr += data; - if (env['KEEP_OUTPUT'] === 'true' || env['VERBOSE'] === 'true') { + if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { process.stderr.write(data); } }); @@ -780,7 +780,7 @@ export class TestRig { logs.push(logData); } catch (e) { // Skip objects that aren't valid JSON - if (env['VERBOSE'] === 'true') { + if (env.VERBOSE === 'true') { console.error('Failed to parse telemetry object:', e); } } @@ -919,7 +919,7 @@ export class TestRig { ptyProcess.onData((data) => { this._interactiveOutput += data; - if (env['KEEP_OUTPUT'] === 'true' || env['VERBOSE'] === 'true') { + if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') { process.stdout.write(data); } }); diff --git a/integration-tests/tsconfig.json b/integration-tests/tsconfig.json index b2946e90476..9eca9562224 100644 --- a/integration-tests/tsconfig.json +++ b/integration-tests/tsconfig.json @@ -9,44 +9,128 @@ // import package sources across the repo by relative path, so inheriting // it produced 300+ TS6307 "not listed within the file list" errors. "composite": false, + // The root turns `noPropertyAccessFromIndexSignature` on, which forced + // bracket-access rewrites in production SDK sources just to satisfy this + // test program (packages/desktop already sets it false). Relax it here so + // packages keep their own compiler regime and the tests keep dot access. + "noPropertyAccessFromIndexSignature": false, // Matches packages/cli. The suite drives browser-side code in // `terminal-capture/` and pulls SDK sources that reference `WebSocket` / // `HeadersInit`, none of which exist in the root's ES2023-only lib. "lib": ["DOM", "DOM.Iterable", "ES2023"], "baseUrl": ".", - // Resolve `@qwen-code/sdk` types from source rather than dist so `tsc -p` - // here does not require a fresh `npm run build` of the SDK package before - // checking integration tests. The runtime vitest alias - // (`integration-tests/vitest.config.ts`) still points at the built - // `dist/index.mjs` to exercise the published-bundle shape; this entry only - // affects type resolution. + // Resolve workspace packages from source so `tsc -p` here does not depend + // on whether someone had built them recently — a missing dist used to fail + // resolution outright and a stale one silently typechecked against old + // declarations. nodenext does no extension or index probing on + // substituted paths, so every subpath the program imports needs an + // explicit entry naming its source file; a bare wildcard falls through to + // the package exports map, i.e. back to dist. Keep these in sync with the + // packages' exports maps. The runtime vitest aliases + // (`integration-tests/vitest.config.ts`) still point at the built SDK + // bundle to exercise the published-bundle shape; these entries only affect + // type resolution. // // Keep notes like this OUT of `paths` itself: every value there must be an // array, so a `"//"` string key makes tsc abort with TS5063 before it type // checks a single file — which is how this project silently went unchecked. "paths": { - // Mirrors packages/cli. These tests import package sources by relative - // path (`../../packages/cli/src/...`), so those files get checked here - // too and must resolve their own imports the same way packages/cli does. - // Going through `packages/core/dist` instead made the result depend on - // whether someone had built core recently — and with core unbuilt it - // reported a dozen "does not exist on type Storage" errors for members - // that are right there in the source. + // These tests import package sources by relative path + // (`../../packages/cli/src/...`), so those files get checked here too + // and must resolve their own imports. "@qwen-code/qwen-code-core": ["../packages/core/src/index.ts"], "@qwen-code/qwen-code-core/transcriptRecords": [ "../packages/core/src/utils/transcript-records.ts" ], - "@qwen-code/qwen-code-core/*": ["../packages/core/src/*"], + "@qwen-code/qwen-code-core/goalWire": [ + "../packages/core/src/goals/goal-wire.ts" + ], + "@qwen-code/qwen-code-core/memoryScopes": [ + "../packages/core/src/memory/scopes.ts" + ], + "@qwen-code/qwen-code-core/userPromptSubmitContext": [ + "../packages/core/src/hooks/user-prompt-submit-context.ts" + ], "@qwen-code/sdk": ["../packages/sdk-typescript/src/index.ts"], - "@qwen-code/sdk/daemon/ui/transcript": [ - "../packages/sdk-typescript/src/daemon/ui/transcript.ts" + "@qwen-code/sdk/daemon": [ + "../packages/sdk-typescript/src/daemon/index.ts" + ], + "@qwen-code/sdk/daemon/transcript": [ + "../packages/sdk-typescript/src/daemon/transcript.ts" + ], + "@qwen-code/sdk/daemon/transports": [ + "../packages/sdk-typescript/src/daemon/transports.ts" ], "@qwen-code/sdk/daemon/types": [ "../packages/sdk-typescript/src/daemon/types.ts" ], - "@qwen-code/sdk/*": ["../packages/sdk-typescript/src/*"], + "@qwen-code/sdk/daemon/ui/transcript": [ + "../packages/sdk-typescript/src/daemon/ui/transcript.ts" + ], "@qwen-code/acp-bridge": ["../packages/acp-bridge/src/index.ts"], - "@qwen-code/acp-bridge/*": ["../packages/acp-bridge/src/*"], + "@qwen-code/acp-bridge/bridge": ["../packages/acp-bridge/src/bridge.ts"], + "@qwen-code/acp-bridge/bridgeClient": [ + "../packages/acp-bridge/src/bridgeClient.ts" + ], + "@qwen-code/acp-bridge/bridgeErrors": [ + "../packages/acp-bridge/src/bridgeErrors.ts" + ], + "@qwen-code/acp-bridge/bridgeFileSystem": [ + "../packages/acp-bridge/src/bridgeFileSystem.ts" + ], + "@qwen-code/acp-bridge/bridgeOptions": [ + "../packages/acp-bridge/src/bridgeOptions.ts" + ], + "@qwen-code/acp-bridge/bridgeTypes": [ + "../packages/acp-bridge/src/bridgeTypes.ts" + ], + "@qwen-code/acp-bridge/channelControlTimeouts": [ + "../packages/acp-bridge/src/channel-control-timeouts.ts" + ], + "@qwen-code/acp-bridge/childHeapPolicy": [ + "../packages/acp-bridge/src/child-heap-policy.ts" + ], + "@qwen-code/acp-bridge/daemonEventTypes": [ + "../packages/acp-bridge/src/daemonEventTypes.ts" + ], + "@qwen-code/acp-bridge/daemonMemoryBudget": [ + "../packages/acp-bridge/src/daemon-memory-budget.ts" + ], + "@qwen-code/acp-bridge/eventBus": [ + "../packages/acp-bridge/src/eventBus.ts" + ], + "@qwen-code/acp-bridge/externalToolGuard": [ + "../packages/acp-bridge/src/externalToolGuard.ts" + ], + "@qwen-code/acp-bridge/logRedaction": [ + "../packages/acp-bridge/src/logRedaction.ts" + ], + "@qwen-code/acp-bridge/mcpTimeouts": [ + "../packages/acp-bridge/src/mcpTimeouts.ts" + ], + "@qwen-code/acp-bridge/sessionArtifacts": [ + "../packages/acp-bridge/src/sessionArtifacts.ts" + ], + "@qwen-code/acp-bridge/spawnChannel": [ + "../packages/acp-bridge/src/spawnChannel.ts" + ], + "@qwen-code/acp-bridge/status": ["../packages/acp-bridge/src/status.ts"], + "@qwen-code/acp-bridge/transcriptReplay": [ + "../packages/acp-bridge/src/transcript-replay.ts" + ], + "@qwen-code/acp-bridge/workspacePaths": [ + "../packages/acp-bridge/src/workspacePaths.ts" + ], + // qwen-serve-webui-live-journal-recovery.test.ts imports this subpath; + // without an entry it resolves through the exports map to dist. + "@qwen-code/webui/daemon-react-sdk": [ + "../packages/webui/src/daemon-react-sdk.ts" + ], + // channel-plugin.test.ts imports plugin-example sources, whose + // `@qwen-code/channel-base` imports must resolve to the same source + // declarations as the test's own relative import — mixing src and dist + // declarations produces duplicate-private-class errors. + "@qwen-code/channel-base": ["../packages/channels/base/src/index.ts"], // node-pty declares `types` at the top level but its `exports` map is a // bare string with no `types` condition, so nodenext resolution never // reaches the declarations and every pty handle degrades to `any` — @@ -55,5 +139,5 @@ "@lydell/node-pty": ["../node_modules/@lydell/node-pty/node-pty.d.ts"] } }, - "include": ["**/*.ts"] + "include": ["**/*.ts", "**/*.tsx"] } diff --git a/integration-tests/vitest.terminal-bench.config.ts b/integration-tests/vitest.terminal-bench.config.ts index d90d1487dab..f8d94753b8a 100644 --- a/integration-tests/vitest.terminal-bench.config.ts +++ b/integration-tests/vitest.terminal-bench.config.ts @@ -6,7 +6,7 @@ import { defineConfig } from 'vitest/config'; -const timeoutMinutes = Number(process.env['TB_TIMEOUT_MINUTES'] || '30'); +const timeoutMinutes = Number(process.env.TB_TIMEOUT_MINUTES || '30'); const testTimeoutMs = timeoutMinutes * 60 * 1000; export default defineConfig({ diff --git a/integrations/external-context/tsconfig.json b/integrations/external-context/tsconfig.json index 07d93d16c90..053e5ad66a5 100644 --- a/integrations/external-context/tsconfig.json +++ b/integrations/external-context/tsconfig.json @@ -2,6 +2,15 @@ "extends": "../../tsconfig.json", "compilerOptions": { "composite": true, + // Override the root's `vitest/globals` entry: vitest's types import the + // optional `jsdom` peer types, and once `@types/jsdom` is installed that + // drags `/// ` into this program. The DOM lib + // flips @types/node's conditional fetch globals to their DOM variants, + // whose ReadableStream is not async-iterable (needs lib.dom.asynciterable), + // breaking the `for await` over `response.body` in http-client.ts. The + // sources compiled here use no vitest globals (tests are excluded), so + // `node` alone is enough. + "types": ["node"], "outDir": "dist", "rootDir": "src" }, diff --git a/package-lock.json b/package-lock.json index 4c5b10ed8f5..cb2da4bfd3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "glob": "^10.5.0", "globals": "^16.0.0", "husky": "^9.1.7", + "jsdom": "^26.1.0", "json": "^11.0.0", "lint-staged": "^16.1.6", "memfs": "^4.42.0", diff --git a/package.json b/package.json index 1985af6f917..e277a495b42 100644 --- a/package.json +++ b/package.json @@ -143,6 +143,7 @@ "glob": "^10.5.0", "globals": "^16.0.0", "husky": "^9.1.7", + "jsdom": "^26.1.0", "json": "^11.0.0", "lint-staged": "^16.1.6", "memfs": "^4.42.0", diff --git a/packages/sdk-typescript/src/daemon/acpRouteTable.ts b/packages/sdk-typescript/src/daemon/acpRouteTable.ts index 7ca6bf52d29..3d301210f0e 100644 --- a/packages/sdk-typescript/src/daemon/acpRouteTable.ts +++ b/packages/sdk-typescript/src/daemon/acpRouteTable.ts @@ -309,8 +309,8 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ return { sessionId: segs[0], artifactId: segs[1], - ...(typeof record['clientId'] === 'string' - ? { clientId: record['clientId'] } + ...(typeof record.clientId === 'string' + ? { clientId: record.clientId } : {}), }; }, diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index f5d9ffa6ac1..887c0ca2e14 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -130,13 +130,13 @@ export function appendLocalUserTranscriptMessage( // This is a dev/CI safety net; in production it is pure O(blocks) overhead on // every dispatch and the reducer's own mutation discipline (takeBlocksOwnership) // does not depend on it, so skip it there. App bundlers statically replace -// `process.env['NODE_ENV']`, folding the check to `false`. The `typeof process` +// `process.env.NODE_ENV`, folding the check to `false`. The `typeof process` // guard keeps an unbundled browser consumer from throwing a ReferenceError — // this module sits on the browser-hostile `daemon/ui` surface and Vite lib -// builds preserve `process.env['NODE_ENV']` in their output — matching the +// builds preserve `process.env.NODE_ENV` in their output — matching the // existing SDK idiom (see ProcessTransport, cliPath). const FREEZE_TRANSCRIPT_BLOCKS = - typeof process !== 'undefined' && process.env['NODE_ENV'] !== 'production'; + typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'; export function reduceDaemonTranscriptEvents( state: DaemonTranscriptState, diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index a19457f8c54..a173c8ff76b 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -1000,7 +1000,7 @@ export class Query implements AsyncIterable { ControlRequestType.SET_EFFORT, { effort }, ); - return Boolean((response as Record | null)?.['applied']); + return Boolean((response as Record | null)?.applied); } /** diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts index d85d740d98c..5dc121f23bf 100644 --- a/packages/sdk-typescript/src/transport/ProcessTransport.ts +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -61,14 +61,14 @@ export class ProcessTransport implements Transport { this.options.debug || this.options.stderr ? 'pipe' : 'ignore'; // Check if we should use fork for Electron integration - const useFork = env['FORK_MODE'] === '1'; + const useFork = env.FORK_MODE === '1'; if (useFork) { // Detect Electron environment const isElectron = typeof process !== 'undefined' && process.versions && - !!process.versions['electron']; + !!process.versions.electron; // In Electron, process.execPath points to Electron, not Node.js // When spawnInfo uses process.execPath to run a JS file, we need to handle it specially From a8a6000aaddcd7847ea2af443cb1f6f3075ad101 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Sat, 8 Aug 2026 03:12:35 +0000 Subject: [PATCH 3/3] fix(integration-tests): complete source-resolution paths and restore single channel-base instance Address review round 2: - Map the eight builtin channel adapters and web-templates to source. channel-registry.ts and html.ts still resolved them through their exports maps to dist, so the typecheck's build-independence was incomplete: on a tree without built dists it failed with the exact 9 x TS2307 the maintainer verification measured. - channel-plugin.test.ts: import @qwen-code/channel-base by bare specifier instead of a relative src path. At runtime the test and plugin-example now resolve the same dist/index.js through the exports map, restoring the single ChannelBase / SessionRouter instance the relative src import silently split; type resolution still maps to source through paths, and vitest.config.ts keeps pointing e2e runs at the built bundles. --- integration-tests/channel-plugin.test.ts | 5 +---- integration-tests/tsconfig.json | 26 ++++++++++++++++++++---- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/integration-tests/channel-plugin.test.ts b/integration-tests/channel-plugin.test.ts index 643ae97431f..9566af326ca 100644 --- a/integration-tests/channel-plugin.test.ts +++ b/integration-tests/channel-plugin.test.ts @@ -31,10 +31,7 @@ import { fileURLToPath } from 'node:url'; import { mkdirSync } from 'node:fs'; // Import from the monorepo channel packages -import { - AcpBridge, - SessionRouter, -} from '../packages/channels/base/src/index.js'; +import { AcpBridge, SessionRouter } from '@qwen-code/channel-base'; import { MockPluginChannel, createMockServer, diff --git a/integration-tests/tsconfig.json b/integration-tests/tsconfig.json index 9eca9562224..101ca5a9f62 100644 --- a/integration-tests/tsconfig.json +++ b/integration-tests/tsconfig.json @@ -126,11 +126,29 @@ "@qwen-code/webui/daemon-react-sdk": [ "../packages/webui/src/daemon-react-sdk.ts" ], - // channel-plugin.test.ts imports plugin-example sources, whose - // `@qwen-code/channel-base` imports must resolve to the same source - // declarations as the test's own relative import — mixing src and dist - // declarations produces duplicate-private-class errors. + // channel-plugin.test.ts and the plugin-example sources it imports + // both import `@qwen-code/channel-base`. Map it to source so the + // typecheck does not depend on channel-base's dist and both import + // sites share one declaration — mixing src and dist declarations + // produces duplicate-private-class errors. "@qwen-code/channel-base": ["../packages/channels/base/src/index.ts"], + // cli's channel-registry.ts imports the eight builtin channel + // adapters and html.ts imports web-templates; without entries they + // resolve through their exports maps to dist, leaving the typecheck + // dependent on those packages being built. + "@qwen-code/channel-telegram": [ + "../packages/channels/telegram/src/index.ts" + ], + "@qwen-code/channel-weixin": ["../packages/channels/weixin/src/index.ts"], + "@qwen-code/channel-dingtalk": [ + "../packages/channels/dingtalk/src/index.ts" + ], + "@qwen-code/channel-wecom": ["../packages/channels/wecom/src/index.ts"], + "@qwen-code/channel-feishu": ["../packages/channels/feishu/src/index.ts"], + "@qwen-code/channel-qqbot": ["../packages/channels/qqbot/src/index.ts"], + "@qwen-code/channel-github": ["../packages/channels/github/src/index.ts"], + "@qwen-code/channel-gitlab": ["../packages/channels/gitlab/src/index.ts"], + "@qwen-code/web-templates": ["../packages/web-templates/src/index.ts"], // node-pty declares `types` at the top level but its `exports` map is a // bare string with no `types` condition, so nodenext resolution never // reaches the declarations and every pty handle degrades to `any` —