diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index d9942acbe07..72e5e08bbf0 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -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'); diff --git a/packages/cli/src/config/shared-env-keys.test.ts b/packages/cli/src/config/shared-env-keys.test.ts index 73d1a04ab11..5db54ff6139 100644 --- a/packages/cli/src/config/shared-env-keys.test.ts +++ b/packages/cli/src/config/shared-env-keys.test.ts @@ -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, diff --git a/packages/cli/src/config/shared-env-keys.ts b/packages/cli/src/config/shared-env-keys.ts index a05d8a0a602..c2d93d8a5fd 100644 --- a/packages/cli/src/config/shared-env-keys.ts +++ b/packages/cli/src/config/shared-env-keys.ts @@ -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, diff --git a/packages/cli/src/serve/process-env-guard.test.ts b/packages/cli/src/serve/process-env-guard.test.ts index cf08a1024a8..da2105a05c7 100644 --- a/packages/cli/src/serve/process-env-guard.test.ts +++ b/packages/cli/src/serve/process-env-guard.test.ts @@ -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, diff --git a/packages/cli/src/serve/sandbox.test.ts b/packages/cli/src/serve/sandbox.test.ts index ee606f58756..4b285e7f911 100644 --- a/packages/cli/src/serve/sandbox.test.ts +++ b/packages/cli/src/serve/sandbox.test.ts @@ -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); diff --git a/packages/cli/src/serve/sandbox.ts b/packages/cli/src/serve/sandbox.ts index 1a343b002b8..efd63a38e1f 100644 --- a/packages/cli/src/serve/sandbox.ts +++ b/packages/cli/src/serve/sandbox.ts @@ -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']) { diff --git a/packages/cli/src/utils/startupWarnings.test.ts b/packages/cli/src/utils/startupWarnings.test.ts index e39552eb426..06c39206833 100644 --- a/packages/cli/src/utils/startupWarnings.test.ts +++ b/packages/cli/src/utils/startupWarnings.test.ts @@ -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'; diff --git a/packages/cli/src/utils/startupWarnings.ts b/packages/cli/src/utils/startupWarnings.ts index 4b4337476b6..4f7b8f99829 100644 --- a/packages/cli/src/utils/startupWarnings.ts +++ b/packages/cli/src/utils/startupWarnings.ts @@ -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 { + 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'); diff --git a/scripts/check-build-status.js b/scripts/check-build-status.js index 696fa39f3c6..742019e6102 100644 --- a/scripts/check-build-status.js +++ b/scripts/check-build-status.js @@ -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 diff --git a/scripts/start.js b/scripts/start.js index 09ad2ce9b9e..94b6c4af91a 100755 --- a/scripts/start.js +++ b/scripts/start.js @@ -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. diff --git a/scripts/tests/check-build-status.test.js b/scripts/tests/check-build-status.test.js index 8fa11385981..29a54d58eb0 100644 --- a/scripts/tests/check-build-status.test.js +++ b/scripts/tests/check-build-status.test.js @@ -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 }); + } + }); }); diff --git a/scripts/tests/start.test.js b/scripts/tests/start.test.js index ddea1da6eb0..8b54d0f504e 100644 --- a/scripts/tests/start.test.js +++ b/scripts/tests/start.test.js @@ -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(); + } + }); });