mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
Co-authored-by: qwen-code-dev-bot <[email protected]>
315 lines
10 KiB
TypeScript
315 lines
10 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { existsSync } from 'node:fs';
|
|
import {
|
|
mkdir,
|
|
mkdtemp,
|
|
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,
|
|
HOSTED_HOME_PREFIX,
|
|
HOSTED_STORE_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 = [
|
|
'INTEGRATION_TEST_FILE_DIR',
|
|
'QWEN_CODE_INTEGRATION_TEST',
|
|
'TELEMETRY_LOG_FILE',
|
|
'E2E_TEST_FILE_DIR',
|
|
'TEST_CLI_PATH',
|
|
'VERBOSE',
|
|
'KEEP_OUTPUT',
|
|
'QWEN_CODE_MODELS_DEV_REFRESH',
|
|
] as const;
|
|
|
|
describe('globalSetup memory-file save/restore', () => {
|
|
let qwenHome: string;
|
|
let savedEnv: Map<string, string | undefined>;
|
|
|
|
beforeEach(async () => {
|
|
qwenHome = await mkdtemp(join(tmpdir(), 'qwen-globalsetup-test-'));
|
|
savedEnv = new Map(
|
|
[...SETUP_ENV_KEYS, 'QWEN_HOME'].map((key) => [key, process.env[key]]),
|
|
);
|
|
process.env['QWEN_HOME'] = qwenHome;
|
|
// Let teardown remove the run directories this case creates.
|
|
process.env['KEEP_OUTPUT'] = 'false';
|
|
});
|
|
|
|
afterEach(async () => {
|
|
for (const [key, value] of savedEnv) {
|
|
if (value === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
vi.resetModules();
|
|
await rm(qwenHome, { recursive: true, force: true });
|
|
});
|
|
|
|
// memoryFilePath is captured at module import time, so point QWEN_HOME at
|
|
// the scratch dir BEFORE a fresh import of the module.
|
|
async function loadGlobalSetup() {
|
|
vi.resetModules();
|
|
return import('./globalSetup.js');
|
|
}
|
|
|
|
it('restores the saved memory file after the run', async () => {
|
|
await writeFile(join(qwenHome, 'QWEN.md'), 'original content', 'utf-8');
|
|
const { setup, teardown } = await loadGlobalSetup();
|
|
await setup();
|
|
await writeFile(join(qwenHome, 'QWEN.md'), 'mutated by tests', 'utf-8');
|
|
|
|
await expect(teardown()).resolves.toBeUndefined();
|
|
|
|
await expect(readFile(join(qwenHome, 'QWEN.md'), 'utf-8')).resolves.toBe(
|
|
'original content',
|
|
);
|
|
});
|
|
|
|
it('does not exit an all-green run red when the restore cannot write', async () => {
|
|
// The persistent pool runners can carry a readable-but-unwritable
|
|
// QWEN.md left behind by a privileged job; before #10325 the teardown
|
|
// restore threw on it and exited every all-green E2E run on that host
|
|
// red with no failing test. Swap the file for a directory after setup()
|
|
// read it — the write then fails regardless of privilege, since root
|
|
// bypasses permission bits.
|
|
await writeFile(join(qwenHome, 'QWEN.md'), 'original content', 'utf-8');
|
|
const { setup, teardown } = await loadGlobalSetup();
|
|
await setup();
|
|
await rm(join(qwenHome, 'QWEN.md'), { force: true });
|
|
await mkdir(join(qwenHome, 'QWEN.md'));
|
|
|
|
await expect(teardown()).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('globalSetup hermetic qwen home', () => {
|
|
let tmpRoot: string;
|
|
let savedEnv: Map<string, string | undefined>;
|
|
|
|
beforeEach(async () => {
|
|
tmpRoot = await mkdtemp(join(tmpdir(), 'qwen-globalsetup-tmp-'));
|
|
savedEnv = new Map(
|
|
[...SETUP_ENV_KEYS, 'QWEN_HOME', 'TMPDIR'].map((key) => [
|
|
key,
|
|
process.env[key],
|
|
]),
|
|
);
|
|
// The scratch home is only created when nothing has pinned QWEN_HOME, and
|
|
// it lands in the OS temp dir — redirect that so the case owns what the
|
|
// module picks. Both are read at import time, so they must be set first.
|
|
delete process.env['QWEN_HOME'];
|
|
process.env['QWEN_CODE_MODELS_DEV_REFRESH'] = 'on';
|
|
process.env['TMPDIR'] = tmpRoot;
|
|
process.env['KEEP_OUTPUT'] = 'false';
|
|
});
|
|
|
|
afterEach(async () => {
|
|
for (const [key, value] of savedEnv) {
|
|
if (value === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
vi.resetModules();
|
|
await rm(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
async function loadGlobalSetup() {
|
|
vi.resetModules();
|
|
return import('./globalSetup.js');
|
|
}
|
|
|
|
it('points the run at its own qwen home', async () => {
|
|
const { setup, teardown } = await loadGlobalSetup();
|
|
await setup();
|
|
|
|
const home = process.env['QWEN_HOME'];
|
|
expect(home?.startsWith(tmpRoot)).toBe(true);
|
|
expect(process.env['QWEN_CODE_MODELS_DEV_REFRESH']).toBe('off');
|
|
|
|
await expect(teardown()).resolves.toBeUndefined();
|
|
expect(existsSync(home!)).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
['per-agent ACP home', ACP_HOME_PREFIX],
|
|
['Hosted process root', HOSTED_HOME_PREFIX],
|
|
['Hosted Session Store root', HOSTED_STORE_PREFIX],
|
|
])(
|
|
'sweeps a stale %s a torn-down run left behind',
|
|
async (_label, prefix) => {
|
|
// cli/acp-integration.test.ts and the Hosted process helpers create their
|
|
// scratch roots directly under the OS temp dir, and a worker torn down
|
|
// mid-test never runs the cleanup that removes them. A root is reclaimed
|
|
// only while its prefix stays nested under the sweeper's — each prefix is
|
|
// the exact string its creation site builds the name from.
|
|
const leakedHome = join(tmpRoot, `${prefix}leaked`);
|
|
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
|
|
// throws ENOTEMPTY. That is how this cleanup first exited an all-green
|
|
// E2E run red, the same way the memory-file restore did in #10325. Stand
|
|
// in for that child with a writer that keeps the directory refilling —
|
|
// unlike a permission trick, it does not depend on not running as root.
|
|
const { setup, teardown } = await loadGlobalSetup();
|
|
await setup();
|
|
const debugDir = join(process.env['QWEN_HOME']!, 'debug');
|
|
await mkdir(debugDir, { recursive: true });
|
|
|
|
const writer = spawn(
|
|
process.execPath,
|
|
[
|
|
'-e',
|
|
// A tight loop, not a timer: the removal walk only fails when a file
|
|
// appears between its readdir and its rmdir, and a millisecond timer
|
|
// is far too slow to land inside that window. Self-limiting, so a
|
|
// child that outlives the kill below cannot spin indefinitely.
|
|
`const fs = require('node:fs');
|
|
const dir = ${JSON.stringify(debugDir)};
|
|
const stopAt = Date.now() + 30_000;
|
|
while (Date.now() < stopAt) {
|
|
try {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(dir + '/' + process.hrtime.bigint() + '.log', 'x');
|
|
} catch {}
|
|
}`,
|
|
],
|
|
{ stdio: 'ignore' },
|
|
);
|
|
|
|
try {
|
|
// Node takes tens of milliseconds to boot, and the removal walk finishes
|
|
// in about one — without waiting for the writer to actually be producing,
|
|
// teardown would clear an idle directory and the case would pass against
|
|
// the very bug it exists to catch.
|
|
const deadline = Date.now() + 10_000;
|
|
while ((await readdir(debugDir)).length < 50) {
|
|
if (Date.now() > deadline) {
|
|
throw new Error('writer never started refilling the debug directory');
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
}
|
|
|
|
await expect(teardown()).resolves.toBeUndefined();
|
|
} finally {
|
|
writer.kill('SIGKILL');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('globalSetup host config carry-over', () => {
|
|
let tmpRoot: string;
|
|
let homeDir: string;
|
|
let savedEnv: Map<string, string | undefined>;
|
|
|
|
beforeEach(async () => {
|
|
tmpRoot = await mkdtemp(join(tmpdir(), 'qwen-globalsetup-ci-'));
|
|
homeDir = await mkdtemp(join(tmpdir(), 'qwen-globalsetup-home-'));
|
|
savedEnv = new Map(
|
|
[...SETUP_ENV_KEYS, 'QWEN_HOME', 'HOME', 'CI', 'TMPDIR'].map((key) => [
|
|
key,
|
|
process.env[key],
|
|
]),
|
|
);
|
|
delete process.env['QWEN_HOME'];
|
|
process.env['HOME'] = homeDir;
|
|
process.env['TMPDIR'] = tmpRoot;
|
|
process.env['KEEP_OUTPUT'] = 'false';
|
|
process.env['QWEN_CODE_MODELS_DEV_REFRESH'] = 'on';
|
|
await mkdir(join(homeDir, '.qwen'), { recursive: true });
|
|
await writeFile(
|
|
join(homeDir, '.qwen', 'settings.json'),
|
|
JSON.stringify({ security: { auth: { selectedType: 'qwen-oauth' } } }),
|
|
'utf-8',
|
|
);
|
|
await writeFile(
|
|
join(homeDir, '.qwen', 'oauth_creds.json'),
|
|
'{"access_token":"host"}',
|
|
'utf-8',
|
|
);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
for (const [key, value] of savedEnv) {
|
|
if (value === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
vi.resetModules();
|
|
await rm(tmpRoot, { recursive: true, force: true });
|
|
await rm(homeDir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function loadGlobalSetup() {
|
|
vi.resetModules();
|
|
return import('./globalSetup.js');
|
|
}
|
|
|
|
it('does not carry host auth state into the hermetic home on CI', async () => {
|
|
process.env['CI'] = 'true';
|
|
const { setup, teardown } = await loadGlobalSetup();
|
|
await setup();
|
|
|
|
const home = process.env['QWEN_HOME']!;
|
|
await expect(
|
|
readFile(join(home, 'settings.json'), 'utf-8'),
|
|
).rejects.toMatchObject({ code: 'ENOENT' });
|
|
await expect(
|
|
readFile(join(home, 'oauth_creds.json'), 'utf-8'),
|
|
).rejects.toMatchObject({ code: 'ENOENT' });
|
|
|
|
await teardown();
|
|
});
|
|
|
|
it('carries host config into the hermetic home outside CI', async () => {
|
|
delete process.env['CI'];
|
|
const { setup, teardown } = await loadGlobalSetup();
|
|
await setup();
|
|
|
|
const home = process.env['QWEN_HOME']!;
|
|
await expect(
|
|
readFile(join(home, 'settings.json'), 'utf-8'),
|
|
).resolves.toContain('qwen-oauth');
|
|
await expect(
|
|
readFile(join(home, 'oauth_creds.json'), 'utf-8'),
|
|
).resolves.toContain('host');
|
|
|
|
await teardown();
|
|
});
|
|
});
|