mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(cli): scope startup warnings to dev sessions (#8456)
* fix(cli): scope startup warnings to dev sessions * fix(cli): secure scoped startup warnings Signed-off-by: MarkXian <[email protected]> * test(cli): cover scoped startup warning cleanup * test(cli): assert sandbox warning env flag * test: cover build warning cleanup branches * test(cli): cover startup warning cleanup timing * test(cli): cover unset warnings file sandbox env * test(cli): isolate warning file env in startup tests * test(cli): harden startup warning file cleanup * test(cli): cover warnings file path translation * test: allow warnings file env in sandbox guard --------- Signed-off-by: MarkXian <[email protected]> Co-authored-by: 冼健聪 <[email protected]>
This commit is contained in:
1 parent
eaa2f5695f
commit
80b34ecbbb
12 files changed
+381
-72
No files matched your search
@@ -4902,6 +4902,7 @@ describe('Settings Loading and Merging', () => {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
delete process.env['QWEN_CODE_MCP_APPROVALS_PATH'];
|
||||
delete process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'];
|
||||
delete process.env['QWEN_CODE_WARNINGS_FILE'];
|
||||
|
||||
const cwdSpy = vi
|
||||
.spyOn(process, 'cwd')
|
||||
@@ -4924,6 +4925,7 @@ describe('Settings Loading and Merging', () => {
|
||||
'QWEN_RUNTIME_DIR=/tmp/hijack-runtime',
|
||||
'QWEN_CODE_MCP_APPROVALS_PATH=/tmp/preapproved.json',
|
||||
'QWEN_CODE_TRUSTED_FOLDERS_PATH=/tmp/trusted.json',
|
||||
'QWEN_CODE_WARNINGS_FILE=/tmp/sensitive.txt',
|
||||
'OTHER_VAR=ok',
|
||||
].join('\n');
|
||||
return '{}';
|
||||
@@ -4937,6 +4939,7 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(process.env['QWEN_RUNTIME_DIR']).toBeUndefined();
|
||||
expect(process.env['QWEN_CODE_MCP_APPROVALS_PATH']).toBeUndefined();
|
||||
expect(process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']).toBeUndefined();
|
||||
expect(process.env['QWEN_CODE_WARNINGS_FILE']).toBeUndefined();
|
||||
// Other vars from the same project .env still load.
|
||||
expect(process.env['OTHER_VAR']).toEqual('ok');
|
||||
|
||||
|
||||
@@ -40,6 +40,11 @@ describe('PROJECT_ENV_HARDCODED_EXCLUSIONS', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes QWEN_CODE_WARNINGS_FILE so a project .env cannot redirect temp warnings', () => {
|
||||
expect(PROJECT_ENV_HARDCODED_EXCLUSIONS).toContain(
|
||||
'QWEN_CODE_WARNINGS_FILE',
|
||||
);
|
||||
});
|
||||
it('keeps ACP repeated-tool-failure rollout policy operator-owned', () => {
|
||||
expect(PROJECT_ENV_HARDCODED_EXCLUSIONS).toContain(
|
||||
ENV_ACP_REPEATED_TOOL_FAILURE_GUARD,
|
||||
|
||||
@@ -27,6 +27,9 @@ export const PROJECT_ENV_HARDCODED_EXCLUSIONS = [
|
||||
'QWEN_RUNTIME_DIR',
|
||||
'QWEN_CODE_MCP_APPROVALS_PATH',
|
||||
'QWEN_CODE_TRUSTED_FOLDERS_PATH',
|
||||
// This points to a host temp file that carries build warnings. A project
|
||||
// `.env` must not redirect it to an arbitrary file to read or delete.
|
||||
'QWEN_CODE_WARNINGS_FILE',
|
||||
// Runtime attribution markers are stamped by trusted launchers. A project
|
||||
// `.env` must not spoof client channel telemetry.
|
||||
QWEN_CODE_SERVE_ENV,
|
||||
|
||||
@@ -242,6 +242,7 @@ const allowedProcessEnvAccesses = normalizeAllowances([
|
||||
'key:PYTHONPATH': 2,
|
||||
'key:QWEN_CODE_INTEGRATION_TEST': 1,
|
||||
'key:QWEN_CODE_MCP_APPROVALS_PATH': 2,
|
||||
'key:QWEN_CODE_WARNINGS_FILE': 2,
|
||||
'key:QWEN_CODE_SCRUB_ELECTRON_RUN_AS_NODE': 1,
|
||||
'key:QWEN_CODE_TEST_VAR': 2,
|
||||
'key:QWEN_SANDBOX_PROXY_COMMAND': 2,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
@@ -50,6 +51,7 @@ afterEach(() => {
|
||||
describe('start_sandbox', () => {
|
||||
it('passes child environment variables into a container sandbox', async () => {
|
||||
vi.stubEnv('SANDBOX_SET_UID_GID', 'false');
|
||||
vi.stubEnv('QWEN_CODE_WARNINGS_FILE', '/tmp/qwen-code-warnings-test.txt');
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((filePath) =>
|
||||
String(filePath),
|
||||
@@ -87,6 +89,13 @@ describe('start_sandbox', () => {
|
||||
'--env',
|
||||
PRIVATE_ACP_CAPABILITY_ENV,
|
||||
]);
|
||||
const warningsFlagIndex = args.indexOf(
|
||||
'QWEN_CODE_WARNINGS_FILE=/tmp/qwen-code-warnings-test.txt',
|
||||
);
|
||||
expect(args.slice(warningsFlagIndex - 1, warningsFlagIndex + 1)).toEqual([
|
||||
'--env',
|
||||
'QWEN_CODE_WARNINGS_FILE=/tmp/qwen-code-warnings-test.txt',
|
||||
]);
|
||||
expect(options).toEqual(
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
@@ -99,6 +108,94 @@ describe('start_sandbox', () => {
|
||||
await expect(result).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('omits the warnings file environment variable when it is unset', async () => {
|
||||
vi.stubEnv('SANDBOX_SET_UID_GID', 'false');
|
||||
vi.stubEnv('QWEN_CODE_WARNINGS_FILE', '');
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((filePath) =>
|
||||
String(filePath),
|
||||
);
|
||||
execSyncMock.mockReturnValue(Buffer.from(''));
|
||||
|
||||
const imageCheck = Object.assign(new EventEmitter(), {
|
||||
stdout: new EventEmitter(),
|
||||
});
|
||||
const child = new EventEmitter();
|
||||
spawnMock
|
||||
.mockImplementationOnce(() => {
|
||||
queueMicrotask(() => {
|
||||
imageCheck.stdout.emit('data', Buffer.from('image-id'));
|
||||
imageCheck.emit('close', 0);
|
||||
});
|
||||
return imageCheck;
|
||||
})
|
||||
.mockReturnValueOnce(child);
|
||||
|
||||
const result = start_sandbox(
|
||||
{ command: 'docker', image: 'example.com/qwen-code:latest' },
|
||||
[],
|
||||
undefined,
|
||||
[process.execPath, '/path/to/cli.js'],
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2));
|
||||
const args = spawnMock.mock.calls[1]?.[1] as string[];
|
||||
expect(
|
||||
args.filter((arg) => arg.startsWith('QWEN_CODE_WARNINGS_FILE=')),
|
||||
).toEqual([]);
|
||||
|
||||
child.emit('close', 0);
|
||||
await expect(result).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('translates the warnings file path before forwarding it on Windows', async () => {
|
||||
vi.stubEnv('SANDBOX_SET_UID_GID', 'false');
|
||||
vi.stubEnv(
|
||||
'QWEN_CODE_WARNINGS_FILE',
|
||||
'C:\\Users\\test\\Temp\\qwen-code-warnings-test.txt',
|
||||
);
|
||||
vi.spyOn(os, 'platform').mockReturnValue('win32');
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((filePath) =>
|
||||
String(filePath),
|
||||
);
|
||||
execSyncMock.mockReturnValue(Buffer.from(''));
|
||||
|
||||
const imageCheck = Object.assign(new EventEmitter(), {
|
||||
stdout: new EventEmitter(),
|
||||
});
|
||||
const child = new EventEmitter();
|
||||
spawnMock
|
||||
.mockImplementationOnce(() => {
|
||||
queueMicrotask(() => {
|
||||
imageCheck.stdout.emit('data', Buffer.from('image-id'));
|
||||
imageCheck.emit('close', 0);
|
||||
});
|
||||
return imageCheck;
|
||||
})
|
||||
.mockReturnValueOnce(child);
|
||||
|
||||
const result = start_sandbox(
|
||||
{ command: 'docker', image: 'example.com/qwen-code:latest' },
|
||||
[],
|
||||
undefined,
|
||||
[process.execPath, '/path/to/cli.js'],
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2));
|
||||
const args = spawnMock.mock.calls[1]?.[1] as string[];
|
||||
const warningsFlagIndex = args.indexOf(
|
||||
'QWEN_CODE_WARNINGS_FILE=/c/Users/test/Temp/qwen-code-warnings-test.txt',
|
||||
);
|
||||
expect(args.slice(warningsFlagIndex - 1, warningsFlagIndex + 1)).toEqual([
|
||||
'--env',
|
||||
'QWEN_CODE_WARNINGS_FILE=/c/Users/test/Temp/qwen-code-warnings-test.txt',
|
||||
]);
|
||||
|
||||
child.emit('close', 0);
|
||||
await expect(result).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('checks image presence with an offline inspect that sees digest references', async () => {
|
||||
vi.stubEnv('SANDBOX_SET_UID_GID', 'false');
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
|
||||
@@ -672,6 +672,14 @@ export async function start_sandbox(
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
if (process.env['QWEN_CODE_WARNINGS_FILE']) {
|
||||
args.push(
|
||||
'--env',
|
||||
`QWEN_CODE_WARNINGS_FILE=${getContainerPath(
|
||||
process.env['QWEN_CODE_WARNINGS_FILE'],
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// copy GEMINI_API_KEY(s)
|
||||
if (process.env['GEMINI_API_KEY']) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getStartupWarnings } from './startupWarnings.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { getErrorMessage } from '@qwen-code/qwen-code-core';
|
||||
@@ -20,8 +20,15 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
});
|
||||
|
||||
describe('startupWarnings', () => {
|
||||
const warningsFilePath = '/tmp/qwen-code-startup-warnings-test.txt';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.stubEnv('QWEN_CODE_WARNINGS_FILE', warningsFilePath);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should return warnings from the file and delete it', async () => {
|
||||
@@ -32,12 +39,21 @@ describe('startupWarnings', () => {
|
||||
|
||||
const warnings = await getStartupWarnings();
|
||||
|
||||
expect(fs.access).toHaveBeenCalled();
|
||||
expect(fs.readFile).toHaveBeenCalled();
|
||||
expect(fs.unlink).toHaveBeenCalled();
|
||||
expect(fs.access).toHaveBeenCalledWith(warningsFilePath);
|
||||
expect(fs.readFile).toHaveBeenCalledWith(warningsFilePath, 'utf-8');
|
||||
expect(fs.unlink).toHaveBeenCalledWith(warningsFilePath);
|
||||
expect(warnings).toEqual(['Warning 1', 'Warning 2']);
|
||||
});
|
||||
|
||||
it('does not read a shared warnings file when no scoped path is provided', async () => {
|
||||
delete process.env['QWEN_CODE_WARNINGS_FILE'];
|
||||
|
||||
const warnings = await getStartupWarnings();
|
||||
|
||||
expect(warnings).toEqual([]);
|
||||
expect(fs.access).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return an empty array if the file does not exist', async () => {
|
||||
const error = new Error('File not found');
|
||||
(error as Error & { code: string }).code = 'ENOENT';
|
||||
|
||||
@@ -5,13 +5,12 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import { join as pathJoin } from 'node:path';
|
||||
import { getErrorMessage } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const warningsFilePath = pathJoin(os.tmpdir(), 'qwen-code-warnings.txt');
|
||||
|
||||
export async function getStartupWarnings(): Promise<string[]> {
|
||||
const warningsFilePath = process.env['QWEN_CODE_WARNINGS_FILE'];
|
||||
if (!warningsFilePath) return [];
|
||||
|
||||
try {
|
||||
await fs.access(warningsFilePath); // Check if file exists
|
||||
const warningsContent = await fs.readFile(warningsFilePath, 'utf-8');
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os'; // Import os module
|
||||
|
||||
// --- Configuration ---
|
||||
const cliPackageDir = path.resolve('packages', 'cli'); // Base directory for the CLI package
|
||||
@@ -17,9 +16,31 @@ const filesToWatch = [
|
||||
path.join(cliPackageDir, 'tsconfig.json'),
|
||||
]; // Specific files within the CLI package
|
||||
const buildDir = path.join(cliPackageDir, 'dist'); // Build output directory within the CLI package
|
||||
const warningsFilePath = path.join(os.tmpdir(), 'qwen-code-warnings.txt'); // Temp file for warnings
|
||||
const warningsFilePath = process.env['QWEN_CODE_WARNINGS_FILE'];
|
||||
// ---------------------
|
||||
|
||||
function clearWarningsFile() {
|
||||
if (!warningsFilePath) return;
|
||||
try {
|
||||
if (fs.existsSync(warningsFilePath)) {
|
||||
fs.unlinkSync(warningsFilePath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[Check Script] Warning: Could not delete previous warnings file: ${err.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function writeWarningsFile(content) {
|
||||
if (!warningsFilePath) return;
|
||||
try {
|
||||
fs.writeFileSync(warningsFilePath, content, { mode: 0o600 });
|
||||
} catch (err) {
|
||||
console.error(`[Check Script] Error writing warnings file: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getMtime(filePath) {
|
||||
try {
|
||||
return fs.statSync(filePath).mtimeMs; // Use mtimeMs for higher precision
|
||||
@@ -58,28 +79,14 @@ function findSourceFiles(dir, allFiles = []) {
|
||||
console.error('Checking build status...');
|
||||
|
||||
// Clean up old warnings file before check
|
||||
try {
|
||||
if (fs.existsSync(warningsFilePath)) {
|
||||
fs.unlinkSync(warningsFilePath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[Check Script] Warning: Could not delete previous warnings file: ${err.message}`,
|
||||
);
|
||||
}
|
||||
clearWarningsFile();
|
||||
|
||||
const buildMtime = getMtime(buildTimestampPath);
|
||||
if (!buildMtime) {
|
||||
// If build is missing, write that as a warning and exit(0) so app can display it
|
||||
const errorMessage = `ERROR: Build timestamp file (${path.relative(process.cwd(), buildTimestampPath)}) not found. Run \`npm run build\` first.`;
|
||||
console.error(errorMessage); // Still log error here
|
||||
try {
|
||||
fs.writeFileSync(warningsFilePath, errorMessage);
|
||||
} catch (writeErr) {
|
||||
console.error(
|
||||
`[Check Script] Error writing missing build warning file: ${writeErr.message}`,
|
||||
);
|
||||
}
|
||||
writeWarningsFile(errorMessage);
|
||||
process.exit(0); // Allow app to start and show the error
|
||||
}
|
||||
|
||||
@@ -129,25 +136,11 @@ if (newerSourceFileFound) {
|
||||
console.warn(finalWarning);
|
||||
|
||||
// Write warnings to the temp file
|
||||
try {
|
||||
fs.writeFileSync(warningsFilePath, warningMessages.join('\n'));
|
||||
// Removed debug log
|
||||
} catch (err) {
|
||||
console.error(`[Check Script] Error writing warnings file: ${err.message}`);
|
||||
// Proceed without writing, app won't show warnings
|
||||
}
|
||||
writeWarningsFile(warningMessages.join('\n'));
|
||||
} else {
|
||||
console.error('Build is up-to-date.');
|
||||
// Ensure no stale warning file exists if build is ok
|
||||
try {
|
||||
if (fs.existsSync(warningsFilePath)) {
|
||||
fs.unlinkSync(warningsFilePath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[Check Script] Warning: Could not delete previous warnings file: ${err.message}`,
|
||||
);
|
||||
}
|
||||
clearWarningsFile();
|
||||
}
|
||||
|
||||
process.exit(0); // Always exit successfully so the app starts
|
||||
+27
-13
@@ -20,18 +20,38 @@
|
||||
// limitations under the License.
|
||||
|
||||
import { spawn, execSync } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, rmSync } from 'node:fs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
|
||||
const startupWarningsFile = join(
|
||||
tmpdir(),
|
||||
`qwen-code-warnings-${process.pid}-${randomUUID()}.txt`,
|
||||
);
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
CLI_VERSION: pkg.version,
|
||||
DEV: 'true',
|
||||
QWEN_CODE_WARNINGS_FILE: startupWarningsFile,
|
||||
// The entry a `qwen …` subprocess should call to reach THIS build. This
|
||||
// launcher runs `node packages/cli` directly — no bin wrapper in the chain, so
|
||||
// nothing else publishes it, and a /review run from `npm start` would fall
|
||||
// back to whatever `qwen` PATH resolves to. Assignment, not `||=`, for the
|
||||
// same reason as scripts/dev.js: an inherited value is another session's CLI.
|
||||
QWEN_CODE_CLI: fileURLToPath(import.meta.url),
|
||||
};
|
||||
|
||||
// check build status, write warnings to file for app to display if needed
|
||||
execSync('node ./scripts/check-build-status.js', {
|
||||
stdio: 'inherit',
|
||||
cwd: root,
|
||||
env,
|
||||
});
|
||||
|
||||
const nodeArgs = ['--expose-gc'];
|
||||
@@ -60,18 +80,6 @@ if (process.env.DEBUG && !sandboxCommand) {
|
||||
nodeArgs.push(join(root, 'packages', 'cli'));
|
||||
nodeArgs.push(...process.argv.slice(2));
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
CLI_VERSION: pkg.version,
|
||||
DEV: 'true',
|
||||
// The entry a `qwen …` subprocess should call to reach THIS build. This
|
||||
// launcher runs `node packages/cli` directly — no bin wrapper in the chain, so
|
||||
// nothing else publishes it, and a /review run from `npm start` would fall
|
||||
// back to whatever `qwen` PATH resolves to. Assignment, not `||=`, for the
|
||||
// same reason as scripts/dev.js: an inherited value is another session's CLI.
|
||||
QWEN_CODE_CLI: fileURLToPath(import.meta.url),
|
||||
};
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
// If this is not set, the debugger will pause on the outer process rather
|
||||
// than the relaunched process making it harder to debug.
|
||||
@@ -87,6 +95,12 @@ const child = spawn('node', nodeArgs, {
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
try {
|
||||
rmSync(startupWarningsFile, { force: true });
|
||||
} catch {
|
||||
// The checker or startup warnings consumer may already have removed it.
|
||||
}
|
||||
|
||||
// Same contract as scripts/dev.js: this launcher is a QWEN_CODE_CLI entry, and
|
||||
// a signal-killed child (`code === null`) must not exit 0 — `process.exit(null)`
|
||||
// coerces to success. Re-raise the signal; fall back to a non-zero exit.
|
||||
|
||||
@@ -6,12 +6,75 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { execFile } from 'node:child_process';
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
|
||||
describe('scripts/check-build-status.js', () => {
|
||||
function createBuildFixture(cwd, { sourceIsNewer }) {
|
||||
const cliDir = join(cwd, 'packages', 'cli');
|
||||
const distDir = join(cliDir, 'dist');
|
||||
const sourceDir = join(cliDir, 'src');
|
||||
const buildTimestamp = join(distDir, '.last_build');
|
||||
const sourceFile = join(sourceDir, 'fixture.ts');
|
||||
const watchedFiles = [
|
||||
join(cliDir, 'package.json'),
|
||||
join(cliDir, 'tsconfig.json'),
|
||||
sourceFile,
|
||||
];
|
||||
|
||||
mkdirSync(distDir, { recursive: true });
|
||||
mkdirSync(sourceDir, { recursive: true });
|
||||
writeFileSync(watchedFiles[0], '{}');
|
||||
writeFileSync(watchedFiles[1], '{}');
|
||||
writeFileSync(sourceFile, 'export const fixture = true;');
|
||||
writeFileSync(buildTimestamp, '');
|
||||
|
||||
const buildMtime = new Date(Date.now() - 1_000);
|
||||
const oldMtime = new Date(buildMtime.getTime() - 1_000);
|
||||
const sourceMtime = sourceIsNewer
|
||||
? new Date(buildMtime.getTime() + 1_000)
|
||||
: oldMtime;
|
||||
utimesSync(buildTimestamp, buildMtime, buildMtime);
|
||||
utimesSync(watchedFiles[0], oldMtime, oldMtime);
|
||||
utimesSync(watchedFiles[1], oldMtime, oldMtime);
|
||||
utimesSync(sourceFile, sourceMtime, sourceMtime);
|
||||
|
||||
return { warningsFile: join(cwd, 'warnings.txt') };
|
||||
}
|
||||
|
||||
function runChecker(cwd, env = process.env) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[join(root, 'scripts', 'check-build-status.js')],
|
||||
{ cwd, env },
|
||||
(err, stdout, stderr) => {
|
||||
// The checker may exit non-zero without a spawn failure; the contract
|
||||
// under test is the stream, not the verdict. A SPAWN failure must
|
||||
// reject: execFile still hands back an empty-string stdout there, so
|
||||
// resolving would let the empty-stdout assertion pass green on a run
|
||||
// that never happened. Spawn-level errors (a missing node binary)
|
||||
// carry a string code; process exit codes are numbers.
|
||||
if (err && typeof err.code === 'string') reject(err);
|
||||
else resolve({ stdout, stderr });
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it('writes nothing to stdout — start.js runs it in front of piped review JSON', async () => {
|
||||
// `scripts/start.js` executes this checker with `stdio: 'inherit'` before
|
||||
// every spawn, and start.js is a QWEN_CODE_CLI entry whose stdout callers
|
||||
@@ -19,23 +82,78 @@ describe('scripts/check-build-status.js', () => {
|
||||
// whose first line is JSON. One `console.log` here — the shape this pins
|
||||
// against — puts "Checking build status..." at the top of that file. Status
|
||||
// and warnings belong on stderr, whatever build state the checker finds.
|
||||
const { stdout } = await new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[join(root, 'scripts', 'check-build-status.js')],
|
||||
{ cwd: root },
|
||||
(err, stdout, stderr) => {
|
||||
// The checker may exit non-zero on an unbuilt tree; the contract under
|
||||
// test is the stream, not the verdict. But a SPAWN failure (ENOENT —
|
||||
// the script renamed or moved) must reject: execFile still hands back
|
||||
// an empty-string stdout there, so the old stdout-type guard resolved
|
||||
// and the empty-stdout assertion passed green on a script that never
|
||||
// ran. Spawn-level errors carry a string code; exit codes are numbers.
|
||||
if (err && typeof err.code === 'string') reject(err);
|
||||
else resolve({ stdout, stderr });
|
||||
},
|
||||
);
|
||||
const { stdout } = await runChecker(root, {
|
||||
...process.env,
|
||||
QWEN_CODE_WARNINGS_FILE: '',
|
||||
});
|
||||
expect(stdout).toBe('');
|
||||
});
|
||||
|
||||
it('writes missing-build warnings to the configured file', async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'qwen-check-build-'));
|
||||
const warningsFile = join(cwd, 'warnings.txt');
|
||||
try {
|
||||
await runChecker(cwd, {
|
||||
...process.env,
|
||||
QWEN_CODE_WARNINGS_FILE: warningsFile,
|
||||
});
|
||||
expect(readFileSync(warningsFile, 'utf8')).toContain(
|
||||
'Build timestamp file',
|
||||
);
|
||||
if (process.platform !== 'win32') {
|
||||
expect(statSync(warningsFile).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not create a warnings file when the variable is unset', async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'qwen-check-build-'));
|
||||
const warningsFile = join(cwd, 'warnings.txt');
|
||||
const env = { ...process.env, TMPDIR: cwd, TMP: cwd, TEMP: cwd };
|
||||
delete env.QWEN_CODE_WARNINGS_FILE;
|
||||
try {
|
||||
await runChecker(cwd, env);
|
||||
expect(() => readFileSync(warningsFile)).toThrow();
|
||||
expect(existsSync(join(cwd, 'qwen-code-warnings.txt'))).toBe(false);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('deletes a stale warnings file when the build is up to date', async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'qwen-check-build-'));
|
||||
const { warningsFile } = createBuildFixture(cwd, {
|
||||
sourceIsNewer: false,
|
||||
});
|
||||
writeFileSync(warningsFile, 'stale warning');
|
||||
try {
|
||||
await runChecker(cwd, {
|
||||
...process.env,
|
||||
QWEN_CODE_WARNINGS_FILE: warningsFile,
|
||||
});
|
||||
expect(existsSync(warningsFile)).toBe(false);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('writes source-newer warnings to the configured file', async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'qwen-check-build-'));
|
||||
const { warningsFile } = createBuildFixture(cwd, {
|
||||
sourceIsNewer: true,
|
||||
});
|
||||
try {
|
||||
await runChecker(cwd, {
|
||||
...process.env,
|
||||
QWEN_CODE_WARNINGS_FILE: warningsFile,
|
||||
});
|
||||
expect(readFileSync(warningsFile, 'utf8')).toContain(
|
||||
'modified since the last build',
|
||||
);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,10 +5,13 @@
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { dirname, isAbsolute } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
const { spawnMock, execSyncMock } = vi.hoisted(() => ({
|
||||
const { spawnMock, execSyncMock, rmSyncMock } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(() => ({ on: vi.fn() })),
|
||||
execSyncMock: vi.fn(() => ''),
|
||||
rmSyncMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
@@ -18,6 +21,7 @@ vi.mock('node:child_process', () => ({
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
readFileSync: vi.fn(() => JSON.stringify({ version: '0.0.0-test' })),
|
||||
rmSync: rmSyncMock,
|
||||
}));
|
||||
|
||||
const normalizePath = (path) => String(path).replaceAll('\\', '/');
|
||||
@@ -76,4 +80,52 @@ describe('scripts/start.js launcher', () => {
|
||||
else process.env.QWEN_CODE_CLI = inherited;
|
||||
}
|
||||
});
|
||||
|
||||
it('passes one scoped warnings file to the checker and child process', async () => {
|
||||
await import('../start.js?scopes-startup-warnings');
|
||||
|
||||
const [, checkerOptions] = execSyncMock.mock.calls[0];
|
||||
const [, , childOptions] = spawnMock.mock.calls[0];
|
||||
const checkerPath = checkerOptions.env.QWEN_CODE_WARNINGS_FILE;
|
||||
|
||||
expect(checkerPath).toBe(childOptions.env.QWEN_CODE_WARNINGS_FILE);
|
||||
expect(isAbsolute(checkerPath)).toBe(true);
|
||||
expect(dirname(checkerPath)).toBe(tmpdir());
|
||||
expect(normalizePath(checkerPath)).toMatch(
|
||||
/qwen-code-warnings-\d+-[0-9a-f-]+\.txt$/,
|
||||
);
|
||||
|
||||
await import('../start.js?scopes-startup-warnings-second');
|
||||
const checkerCalls = execSyncMock.mock.calls.filter(
|
||||
([command]) => command === 'node ./scripts/check-build-status.js',
|
||||
);
|
||||
expect(checkerCalls).toHaveLength(2);
|
||||
expect(checkerCalls[1][1].env.QWEN_CODE_WARNINGS_FILE).not.toBe(
|
||||
checkerPath,
|
||||
);
|
||||
});
|
||||
|
||||
it('cleans up the scoped warnings file when the child closes', async () => {
|
||||
await import('../start.js?cleans-startup-warnings');
|
||||
|
||||
const child = spawnMock.mock.results[0].value;
|
||||
const close = child.on.mock.calls.find(([ev]) => ev === 'close')[1];
|
||||
const [, checkerOptions] = execSyncMock.mock.calls[0];
|
||||
const warningsPath = checkerOptions.env.QWEN_CODE_WARNINGS_FILE;
|
||||
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
rmSyncMock.mockImplementationOnce(() => {
|
||||
throw new Error('EBUSY');
|
||||
});
|
||||
expect(rmSyncMock).not.toHaveBeenCalled();
|
||||
close(0, null);
|
||||
expect(rmSyncMock).toHaveBeenCalledWith(warningsPath, { force: true });
|
||||
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user