Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions integration-tests/channel-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,15 @@ import { fileURLToPath } from 'node:url';
import { mkdirSync } from 'node:fs';

// Import from the monorepo channel packages
import {
AcpBridge,
SessionRouter,
} from '../packages/channels/base/dist/index.js';
import type { ChannelConfig } from '../packages/channels/base/dist/index.js';
import { AcpBridge, SessionRouter } from '@qwen-code/channel-base';
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';
Comment thread
doudouOUC marked this conversation as resolved.

const __dirname = dirname(fileURLToPath(import.meta.url));
const CLI_PATH = join(__dirname, '..', 'dist', 'cli.js');
Expand Down Expand Up @@ -74,14 +73,17 @@ describe('Channel Plugin (Mock WebSocket E2E)', () => {
await bridge.start();

// 3. Create and connect MockPluginChannel via WebSocket
const config: ChannelConfig & Record<string, unknown> = {
// MockPluginConfig, not ChannelConfig: the constructor below requires
// `serverWsUrl`, and typing the literal as the base interface erased it.
const config: MockPluginConfig & Record<string, unknown> = {
type: 'plugin-example',
token: '',
senderPolicy: 'open',
allowedUsers: [],
sessionScope: 'user',
cwd: testDir,
groupPolicy: 'disabled',
dmPolicy: 'open',
groups: {},
serverWsUrl: server.wsUrl,
};
Expand Down
2 changes: 1 addition & 1 deletion integration-tests/cli/file-system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions integration-tests/cli/notebook-edit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down
34 changes: 24 additions & 10 deletions integration-tests/cli/qwen-serve-streaming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +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 {
isNonBlockingAccepted,
type NonBlockingPromptAccepted,
} from '@qwen-code/sdk/daemon';
import {
fakeToolCall,
startFakeOpenAIServer,
Expand Down Expand Up @@ -135,6 +139,12 @@ function findExternalReadBase(): string | undefined {

const externalReadBase = findExternalReadBase();

function asAccepted(
result: Awaited<ReturnType<DaemonClient['promptNonBlocking']>>,
): NonBlockingPromptAccepted | undefined {
Comment thread
doudouOUC marked this conversation as resolved.
return isNonBlockingAccepted(result) ? result : undefined;
}

let daemon: ChildProcess;
let port = 0;
let base = '';
Expand Down Expand Up @@ -619,11 +629,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();
Expand Down Expand Up @@ -766,11 +778,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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}),
);
});

Expand Down
18 changes: 11 additions & 7 deletions integration-tests/cli/sleep-interception.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. The predicates
// below only match an explicit `success` boolean.
type ShellCall = {
args: string;
success: boolean;
args?: string;
success?: boolean;
error?: string;
};

Expand Down Expand Up @@ -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 === false,
);

if (!foundBlockedCall) {
Expand All @@ -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 === false,
);
if (blockedCall?.error !== undefined) {
expect(blockedCall.error).toContain('Monitor');
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 === false,
);

if (!foundBlockedCall) {
Expand Down
7 changes: 6 additions & 1 deletion integration-tests/cli/stdin-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion integration-tests/cli/todo_write.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 6 additions & 2 deletions integration-tests/cli/write_file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
7 changes: 2 additions & 5 deletions integration-tests/hook-integration/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion integration-tests/terminal-bench/terminal-bench.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ describe('terminal-bench integration', () => {
.map((s) => s.trim())
.filter(Boolean);

const available = new Set(baseTestTasks.map((t) => t));
// Set<string>, not Set<of the literal union>: the whole point is to test
// arbitrary env-supplied ids for membership.
const available = new Set<string>(baseTestTasks);
const unknown = selected.filter((s) => !available.has(s));
if (unknown.length > 0) {
throw new Error(
Expand Down
5 changes: 4 additions & 1 deletion integration-tests/terminal-capture/scenarios/bugfix-2833.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
},
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ 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',
streaming: {
delayMs: 5000,
intervalMs: 10000, // Every 10s
count: 60, // 10 minutes total (60 * 10s)
gif: true,
},
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
27 changes: 21 additions & 6 deletions integration-tests/test-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>,
result: string,
) {
const expectedStr = Array.isArray(expectedTools)
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -811,12 +819,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;
};
Expand Down Expand Up @@ -845,7 +858,9 @@ export class TestRig {
return logs;
}

readLastApiRequest(): Record<string, unknown> | null {
// Returns the parsed log, not a bare record: callers want `.attributes`,
// and `Record<string, unknown>` hid that the value already has a shape.
readLastApiRequest(): ParsedLog | null {
const logs = this._readAndParseTelemetryLog();
const apiRequests = logs.filter(
(logData) =>
Expand Down
Loading
Loading