Skip to content
77 changes: 50 additions & 27 deletions integration-tests/cli/acp-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
*/

import { spawn } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createInterface } from 'node:readline';
import { setTimeout as delay } from 'node:timers/promises';
import { describe, expect, it } from 'vitest';
import { TestRig } from '../test-helper.js';
import { startFakeOpenAIServer } from '../fake-openai-server.js';
import { fakeToolCall, startFakeOpenAIServer } from '../fake-openai-server.js';
import { ACP_HOME_PREFIX, removeScratchDir } from '../scratch-dir.js';

const REQUEST_TIMEOUT_MS = 60_000;
const INITIAL_PROMPT = 'Create a quick note (smoke test).';
Expand Down Expand Up @@ -124,8 +126,11 @@ function setupAcpTest(
// the `set_config_option` test (acp-integration.test.ts:516). A per-agent
// QWEN_HOME redirects `getGlobalQwenDir()` so the authenticate -> session/new
// round-trip reads back exactly what this agent wrote.
const qwenHome = join(rig.testDir!, '.qwen-home');
mkdirSync(qwenHome, { recursive: true });
// The agent keeps writing under QWEN_HOME for a few hundred ms after it
// exits (measured: memory/projects/usage_record files landing ~300 ms after
// cleanup() returns), so inside rig.testDir those late writes race the
// global teardown's recursive rm with ENOTEMPTY.
Comment thread
yiliang114 marked this conversation as resolved.
const qwenHome = mkdtempSync(join(tmpdir(), ACP_HOME_PREFIX));

const agent = spawn(
'node',
Expand Down Expand Up @@ -301,6 +306,7 @@ function setupAcpTest(
pending.forEach(({ timeout }) => clearTimeout(timeout));
pending.clear();
await waitForExit();
await removeScratchDir(qwenHome);
Comment thread
yiliang114 marked this conversation as resolved.
};

return {
Expand Down Expand Up @@ -962,6 +968,23 @@ function setupAcpTest(
it('blocks write tools in plan mode (issue #1806)', async () => {
const rig = new TestRig();
await rig.setup('acp plan mode enforcement');
let streamingRequestIndex = 0;
const fakeServer = await startFakeOpenAIServer(({ body }) => {
if (body['stream'] !== true) {
return { content: '{"selected_memories":[]}' };
}
if (streamingRequestIndex++ === 0) {
Comment thread
yiliang114 marked this conversation as resolved.
return {
toolCalls: [
fakeToolCall('write_file', {
file_path: join(rig.testDir!, 'test.txt'),
content: 'Hello World',
}),
],
};
}
return { content: 'Done.' };
});

const toolCallEvents: Array<{
toolName: string;
Expand All @@ -970,12 +993,13 @@ function setupAcpTest(
}> = [];

const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig, {
permissionHandler: (request) => {
// Cancel exit_plan_mode to keep plan mode active
if (request.toolCall?.kind === 'switch_mode') {
return { outcome: 'cancelled' };
}
return { optionId: 'proceed_once' };
env: {
OPENAI_API_KEY: 'fake-key',
OPENAI_BASE_URL: fakeServer.baseUrl,
OPENAI_MODEL: 'fake-model',
QWEN_MODEL: 'fake-model',
NO_PROXY: '127.0.0.1,localhost',
no_proxy: '127.0.0.1,localhost',
},
});

Expand Down Expand Up @@ -1010,41 +1034,39 @@ function setupAcpTest(
});
expect(promptResult).toBeDefined();

// Give time for tool calls to be processed
await delay(2000);

// Collect tool call events from session updates
sessionUpdates.forEach((update) => {
if (update.update?.sessionUpdate === 'tool_call_update') {
const toolUpdate = update.update as {
sessionUpdate: string;
toolName?: string;
status?: string;
error?: { message?: string };
content?: Array<{ content?: { text?: string } }>;
_meta?: { toolName?: string };
};
if (toolUpdate.toolName) {
if (toolUpdate._meta?.toolName) {
toolCallEvents.push({
toolName: toolUpdate.toolName,
toolName: toolUpdate._meta.toolName,
status: toolUpdate.status ?? 'unknown',
error: toolUpdate.error?.message,
error: toolUpdate.content
?.map(({ content }) => content?.text ?? '')
.join('\n'),
});
}
}
});

// Verify that if write_file was attempted, it was blocked
const writeFileEvents = toolCallEvents.filter(
(e) => e.toolName === 'write_file',
);

// If the LLM tried to call write_file in plan mode, it should have been blocked
if (writeFileEvents.length > 0) {
const blockedEvent = writeFileEvents.find(
(e) => e.status === 'error' && e.error?.includes('Plan mode'),
);
expect(blockedEvent).toBeDefined();
expect(blockedEvent?.error).toContain('Plan mode is active');
}
const blockedEvent = writeFileEvents.find(
(e) => e.status === 'failed' && e.error?.includes('Plan mode'),
);
expect(
blockedEvent,
`expected a failed write_file tool_call_update blocked by plan mode; events=${JSON.stringify(toolCallEvents)}`,
).toBeDefined();
expect(blockedEvent?.error).toContain('Plan mode is active');

// Verify the file was NOT created
const fs = await import('fs');
Expand All @@ -1057,6 +1079,7 @@ function setupAcpTest(
throw e;
} finally {
await cleanup();
await fakeServer.close();
}
});

Expand Down
51 changes: 44 additions & 7 deletions integration-tests/cli/json-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,65 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { expect, describe, it, beforeEach, afterEach } from 'vitest';
import { TestRig } from '../test-helper.js';
import { expect, describe, it, beforeEach, afterEach, vi } from 'vitest';
import {
CONTAINER_SANDBOX_NO_PROXY,
fakeServerHostOptions,
IS_CONTAINER_SANDBOX,
TestRig,
} from '../test-helper.js';
import {
startFakeOpenAIServer,
type FakeOpenAIServer,
} from '../fake-openai-server.js';

describe('JSON output', () => {
let rig: TestRig;
let fakeServer: FakeOpenAIServer;

beforeEach(async () => {
rig = new TestRig();
// The spawned CLI routes through any inherited HTTP(S)_PROXY; without a
// loopback NO_PROXY entry the fake-server POST is tunnelled and these
// tests hang. Mirrors test-helper.ts:993-995.
const noProxy = IS_CONTAINER_SANDBOX
? CONTAINER_SANDBOX_NO_PROXY
: '127.0.0.1,localhost';
vi.stubEnv('NO_PROXY', noProxy);
vi.stubEnv('no_proxy', noProxy);
fakeServer = await startFakeOpenAIServer(
({ body }) =>
body['stream'] === true
? { contentChunks: ['Par', 'is'] }
: { content: '{"selected_memories":[]}' },
fakeServerHostOptions(),
);
await rig.setup('json-output-test');
});

afterEach(async () => {
await fakeServer?.close();
vi.unstubAllEnvs();
await rig.cleanup();
});

const fakeModelArgs = () => [
Comment thread
yiliang114 marked this conversation as resolved.
'--auth-type',
'openai',
'--model',
'fake-model',
'--openai-base-url',
fakeServer.baseUrl,
'--openai-api-key',
'fake-key',
];

it('should return a valid JSON array with result message containing response and stats', async () => {
const result = await rig.run(
'What is the capital of France?',
'--output-format',
'json',
...fakeModelArgs(),
);
const parsed = JSON.parse(result);

Expand Down Expand Up @@ -58,6 +97,7 @@ describe('JSON output', () => {
'What is the capital of France?',
'--output-format',
'stream-json',
...fakeModelArgs(),
);

// Stream-json output is line-delimited JSON (one JSON object per line)
Expand Down Expand Up @@ -133,6 +173,7 @@ describe('JSON output', () => {
'--output-format',
'stream-json',
'--include-partial-messages',
...fakeModelArgs(),
);

// Stream-json output is line-delimited JSON (one JSON object per line)
Expand Down Expand Up @@ -233,8 +274,6 @@ describe('JSON output', () => {
});

it('should return a JSON error for enforced auth mismatch before running', async () => {
const originalOpenaiApiKey = process.env['OPENAI_API_KEY'];
process.env['OPENAI_API_KEY'] = 'test-key';
await rig.setup('json-output-auth-mismatch', {
settings: {
security: { auth: { enforcedType: 'qwen-oauth' } },
Expand All @@ -243,12 +282,10 @@ describe('JSON output', () => {

let thrown: Error | undefined;
try {
await rig.run('Hello', '--output-format', 'json');
await rig.run('Hello', '--output-format', 'json', ...fakeModelArgs());
expect.fail('Expected process to exit with error');
} catch (e) {
thrown = e as Error;
} finally {
process.env['OPENAI_API_KEY'] = originalOpenaiApiKey;
}

expect(thrown).toBeDefined();
Expand Down
22 changes: 22 additions & 0 deletions integration-tests/globalSetup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ import {
readFile,
readdir,
rm,
utimes,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { ACP_HOME_PREFIX } from './scratch-dir.js';

// The env keys globalSetup's setup() writes, saved so a case can restore the
// suite-wide values after re-importing the module and running its lifecycle.
const SETUP_ENV_KEYS = [
Expand Down Expand Up @@ -141,6 +144,25 @@ describe('globalSetup hermetic qwen home', () => {
expect(existsSync(home!)).toBe(false);
});

it('sweeps a stale per-agent ACP home a torn-down run left behind', async () => {
// cli/acp-integration.test.ts gives each spawned agent its own QWEN_HOME
// directly under the OS temp dir, and a worker torn down mid-test never
// runs the cleanup that removes it. The home is reclaimed only while its
// prefix stays nested under the sweeper's — ACP_HOME_PREFIX is the exact
// string the creation site builds the name from.
const leakedHome = join(tmpRoot, `${ACP_HOME_PREFIX}leaked`);
Comment thread
yiliang114 marked this conversation as resolved.
await mkdir(leakedHome, { recursive: true });
const leakedAt = new Date(Date.now() - 48 * 60 * 60 * 1000);
await utimes(leakedHome, leakedAt, leakedAt);

const { setup, teardown } = await loadGlobalSetup();
await setup();

expect(existsSync(leakedHome)).toBe(false);

await teardown();
});

it('does not exit an all-green run red when the scratch home cannot be removed', async () => {
// A CLI child that outlives its test keeps writing under `debug/`, so the
// removal walk reaches a directory that refills before the rmdir and
Expand Down
18 changes: 3 additions & 15 deletions integration-tests/globalSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { fileURLToPath } from 'node:url';

import { DEFAULT_CONTEXT_FILENAME, Storage } from '@qwen-code/qwen-code-core';

import { removeScratchDir } from './scratch-dir.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, '..');
const integrationTestsDir = join(rootDir, '.integration-tests');
Expand Down Expand Up @@ -233,21 +235,7 @@ export async function teardown() {
// Not gated on KEEP_OUTPUT: this is a scratch dir rather than a test
// artifact, and it holds a copy of the developer's credentials.
if (ownsQwenHome) {
try {
// A CLI child outliving its test keeps writing under `debug/`, so the
// walk can reach a directory that refills before the rmdir — retries
// absorb that. The catch is what matters: a cleanup that cannot finish
// must not exit an all-green run red, the way the memory-file restore
// did in #10325.
await rm(hermeticQwenHome, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 200,
});
} catch (e) {
console.error(`Warning: could not remove ${hermeticQwenHome}:`, e);
}
await removeScratchDir(hermeticQwenHome);
}
}

Expand Down
42 changes: 42 additions & 0 deletions integration-tests/scratch-dir.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { removeScratchDir } from './scratch-dir.js';

describe('removeScratchDir', () => {
let tmpRoot: string;

beforeEach(async () => {
tmpRoot = await mkdtemp(join(tmpdir(), 'qwen-scratch-dir-test-'));
});

afterEach(async () => {
vi.restoreAllMocks();
await rm(tmpRoot, { recursive: true, force: true });
});

it('warns and resolves when the dir cannot be removed', async () => {
Comment thread
yiliang114 marked this conversation as resolved.
// A cleanup that cannot finish must not turn an all-green run red
// (#10325). ENOTDIR is outside rm's retryable codes, so the rejection
// reaches the catch deterministically instead of racing a retry window.
const warn = vi.spyOn(console, 'error').mockImplementation(() => {});
const file = join(tmpRoot, 'not-a-dir');
await writeFile(file, 'x');
const stuck = join(file, 'child');

await expect(removeScratchDir(stuck)).resolves.toBeUndefined();

expect(warn).toHaveBeenCalledWith(
`Warning: could not remove ${stuck}:`,
expect.anything(),
);
});
});
Loading
Loading