diff --git a/AGENTS.md b/AGENTS.md index 3ac9852d28..2ed39c1839 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,15 +128,15 @@ cd integration-tests && \ **Gotcha:** In interactive tests, always call `session.idle()` between sends — ANSI output streams asynchronously. -### Linting, Formatting & Verification +### Linting & Formatting ```bash npm run lint # ESLint check npm run lint:fix # Auto-fix lint issues npm run format # Prettier formatting npm run typecheck # TypeScript type checking -npm run preflight # Legacy broad check; writes formatting and omits some PR CI checks -npm run verify:pr # Optional clean PR verification after committing +npm run preflight # Full check: clean → install → format → lint → build + # → typecheck → test ``` ## Code Conventions @@ -211,10 +211,6 @@ npm run verify:pr # Optional clean PR verification after committing Here, `/review` means the Codex code-review workflow, not Qwen Review or the `qwen-review` plugin. Do not invoke Qwen Review unless the user explicitly requests it by name. -6. **Optional PR verification** — after the final changes are committed and the - working tree is clean, ask whether to run `npm run verify:pr`. Run it only - when requested; it is not a required local step before pushing, and remote CI - remains authoritative. ### Feature development diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fc9fd540d4..78d6be9117 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,7 @@ If you'd like to get early feedback on your work, please use GitHub's **Draft Pu #### 4. Ensure All Checks Pass -Before submitting your PR, run `npm run verify:pr` from a clean Node 22 checkout. This is the final local PR gate; `npm run preflight` remains useful during development but is not equivalent to CI. +Before submitting your PR, ensure that all automated checks are passing by running `npm run preflight`. This command runs all tests, linting, and other style checks. #### 5. Update Documentation @@ -152,7 +152,7 @@ To execute the unit test suite for the project: npm run test ``` -This will run tests located in the `packages/core` and `packages/cli` directories. Ensure focused tests pass while developing, then run `npm run verify:pr` before submitting the PR. +This will run tests located in the `packages/core` and `packages/cli` directories. Ensure tests pass before submitting any changes. For a more comprehensive check, it is recommended to run `npm run preflight`. #### Integration Tests @@ -166,21 +166,15 @@ npm run test:e2e For more detailed information on the integration testing framework, please see the [Integration Tests documentation](./docs/developers/development/integration-tests.md). -### Linting and PR Verification +### Linting and Preflight Checks -During development, the legacy preflight command provides a broad check and may rewrite formatting: +To ensure code quality and formatting consistency, run the preflight check: ```bash npm run preflight ``` -Before pushing a PR, run the read-only final local gate from a clean worktree: - -```bash -npm run verify:pr -``` - -The final gate covers the deterministic PR CI checks that `preflight` omits. See [`docs/design/local-pr-verification.md`](./docs/design/local-pr-verification.md) for profiles, supported hosts, and remote-only boundaries. +This command will run ESLint, Prettier, all tests, and other checks as defined in the project's `package.json`. _ProTip_ diff --git a/docs/design/local-pr-verification.md b/docs/design/local-pr-verification.md deleted file mode 100644 index ce87973c1f..0000000000 --- a/docs/design/local-pr-verification.md +++ /dev/null @@ -1,121 +0,0 @@ -# Local PR verification - -## Motivation - -The pre-commit hook is intentionally fast: it formats and lints only staged -files. The legacy `npm run preflight` command is broader, but it runs Prettier -in write mode and omits several PR CI checks. Neither is the final local PR -gate. - -Use three levels of feedback: - -1. During development, run focused tests from the affected package. -2. Keep the existing pre-commit hook for fast staged-file feedback. -3. After committing and before the first PR push or an update push, run - `npm run verify:pr`. - -There is no repository pre-push hook. The full gate includes network access -and the full workspace test suite, so it remains an explicit developer action. - -## Interface and guards - -```bash -npm run verify:pr -npm run verify:pr -- --base origin/release/ -npm run verify:pr -- --profile auto -``` - -The default base is `origin/main`, and the default profile is `full`. Full is -intentionally conservative. `--profile auto` is an opt-in optimization that -reuses the CI changed-file classifier. - -Before validation starts, the command requires Node 22.x on macOS x64/ARM64 or -Linux x64. It also requires a caller working tree with no staged, unstaged, or -untracked files, a resolvable base commit and merge base, and at least one -committed change between that merge base and `HEAD`. - -For profiles that run commands, the gate creates an owned detached temporary -worktree at the exact SHA captured during caller inspection, disables checkout -hooks, runs validation there, and removes it afterward. -Commands that install, build, or generate files therefore do not change the -caller's tracked checkout. Developers do not need to create a separate worktree -themselves. A docs-only auto profile finishes after the same caller guards -without creating the temporary worktree. - -On POSIX systems, the owned container is created below the canonical `/tmp` -path. This avoids both macOS `/var` aliases and excessively long per-user -temporary paths. Build outputs, dependency caches, browser binaries, homes, and -verifier-owned temporary files stay inside the container. Git temporarily -registers the detached worktree in repository metadata until cleanup. - -Installation forces lifecycle scripts to run so the build and bundle cannot -be skipped by caller npm settings, while disabling Husky setup to avoid -changing the linked worktrees' shared hook configuration. - -## Profiles - -- `full` runs the complete deterministic local gate and is always selected - unless `auto` is requested. -- `docs_only` is selected by `auto` only when every changed path is classified - as documentation. It performs the caller guards and skips validation - commands. -- `github_ci_only` is selected by `auto` only when every changed path belongs - to the CI safety-helper set. It sets up linters, runs actionlint and yamllint, - and runs the associated Node helper tests. - -Mixed or unrecognized changes select `full`. - -## Full validation - -The full profile runs these categories in fail-fast order: - -- clean installation in the fresh detached checkout: `npm ci` invokes the - repository `prepare` script, which runs the build and bundle; -- critical runtime dependency audit, lockfile validation, and desktop - workspace isolation; -- ESLint, actionlint, shellcheck, yamllint, and a read-only Prettier check of - regular files changed by the PR that still exist at `HEAD`; -- the GitHub CI profile-classifier and safety-helper tests; -- i18n validation, read-only settings-schema freshness plus a check that the - build left the committed schema unchanged, type checking, and the serve - fast-path bundle-closure check; -- all workspace unit tests plus script tests, with workspaces and test files - scheduled serially to keep the repository-wide run stable under local load; - the test contents and coverage settings stay unchanged; -- no-AK integration tests, an isolated Chromium installation, and the web-shell - Playwright smoke test on a dynamically allocated localhost port. - -Every validation subprocess receives an isolated temporary home and a small -allowlist of transport-related caller variables such as `PATH`, proxy settings, -and certificate paths. Caller credentials, npm/pip modes, Qwen state paths, -test overrides, and Playwright redirects are not inherited. Python and npm use -owned configuration and cache paths. - -When the PR changes `packages/sdk-python/` or its CI workflow, the full profile -also requires `uv` and creates isolated Python 3.10, 3.11, and 3.12 -environments. Each version installs the SDK development dependencies and runs -Ruff lint and format checks, Mypy, and Pytest. - -The existing sensitive-keyword no-op is intentionally not part of this gate. - -## Failures and remote boundaries - -The gate stops at the first failed step. Output includes the step and a safely -escaped command before execution; validation failures also report the exact -`HEAD`, selected base and profile, and direct developers to rerun the gate. -It preserves a child's numeric exit code. `SIGINT` and `SIGTERM` received by -the verifier are relayed after cleanup; other child-only signals use the -conventional `128 + signal` exit status. The gate -always attempts to remove the temporary worktree on success or failure. -`SIGINT` and `SIGTERM` received while a validation command is running are -forwarded to its process group; a second signal force-terminates that group, -and the gate cleans up before relaying the original signal. -Cleanup failures are reported, including separately when a validation failure -already exists. - -This gate does not replace remote CI. Unsupported hosts, including Windows and -Linux ARM64, fail before repository inspection. The gate cannot reproduce ECS -runner load or cache behavior, Windows and macOS merge-queue jobs, integrations -that use real secrets, GitHub permissions and artifact handling, review bots, -or every residual test flake. Remote checks remain authoritative for those -surfaces. diff --git a/docs/developers/contributing.md b/docs/developers/contributing.md index 9a8b44709a..f391eab125 100644 --- a/docs/developers/contributing.md +++ b/docs/developers/contributing.md @@ -41,7 +41,7 @@ If you'd like to get early feedback on your work, please use GitHub's **Draft Pu #### 4. Ensure All Checks Pass -Before submitting your PR, run `npm run verify:pr` from a clean Node 22 checkout. This is the final local PR gate; `npm run preflight` remains useful during development but is not equivalent to CI. +Before submitting your PR, ensure that all automated checks are passing by running `npm run preflight`. This command runs all tests, linting, and other style checks. #### 5. Update Documentation @@ -126,7 +126,7 @@ To execute the unit test suite for the project: npm run test ``` -This will run tests located in the `packages/core` and `packages/cli` directories. Ensure focused tests pass while developing, then run `npm run verify:pr` before submitting the PR. +This will run tests located in the `packages/core` and `packages/cli` directories. Ensure tests pass before submitting any changes. For a more comprehensive check, it is recommended to run `npm run preflight`. #### Integration Tests @@ -140,21 +140,15 @@ npm run test:e2e For more detailed information on the integration testing framework, please see the [Integration Tests documentation](./development/integration-tests.md). -### Linting and PR Verification +### Linting and Preflight Checks -During development, the legacy preflight command provides a broad check and may rewrite formatting: +To ensure code quality and formatting consistency, run the preflight check: ```bash npm run preflight ``` -Before pushing a PR, run the read-only final local gate from a clean worktree: - -```bash -npm run verify:pr -``` - -The final gate covers the deterministic PR CI checks that `preflight` omits. See [`local-pr-verification.md`](../design/local-pr-verification.md) for profiles, supported hosts, and remote-only boundaries. +This command will run ESLint, Prettier, all tests, and other checks as defined in the project's `package.json`. _ProTip_ diff --git a/package.json b/package.json index bcab9ea791..8cba5f7ba8 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ "debug": "cross-env DEBUG=1 node --inspect-brk scripts/start.js", "generate": "node scripts/generate-git-commit-info.js", "generate:settings-schema": "node --import tsx/esm scripts/generate-settings-schema.ts", - "verify:pr": "node scripts/verify-pr.js", "build": "cross-env NODE_OPTIONS=\"--max-old-space-size=3072\" node scripts/build.js", "build-and-start": "npm run build && npm run start", "build:vscode": "node scripts/build_vscode_companion.js", diff --git a/scripts/generate-settings-schema.ts b/scripts/generate-settings-schema.ts index 9eb22b11cf..f762396a43 100644 --- a/scripts/generate-settings-schema.ts +++ b/scripts/generate-settings-schema.ts @@ -255,49 +255,16 @@ function generateJsonSchema( return jsonSchema; } +const schema = getSettingsSchema(); +const jsonSchema = generateJsonSchema(schema as unknown as SettingsSchema); + const outputDir = path.resolve( __dirname, '../packages/vscode-ide-companion/schemas', ); const outputPath = path.join(outputDir, 'settings.schema.json'); -export function runGenerateSettingsSchema( - args: string[], - schemaPath = outputPath, -): number { - const checkMode = args.length === 1 && args[0] === '--check'; - if (args.length > 0 && !checkMode) { - console.error( - `Unknown argument${args.length === 1 ? '' : 's'}: ${args.join(' ')}. Usage: npm run generate:settings-schema -- [--check]`, - ); - return 1; - } +fs.mkdirSync(outputDir, { recursive: true }); +fs.writeFileSync(outputPath, JSON.stringify(jsonSchema, null, 2) + '\n'); - const schema = getSettingsSchema(); - const jsonSchema = generateJsonSchema(schema as unknown as SettingsSchema); - const serializedSchema = JSON.stringify(jsonSchema, null, 2) + '\n'; - - if (checkMode) { - if ( - !fs.existsSync(schemaPath) || - fs.readFileSync(schemaPath, 'utf8') !== serializedSchema - ) { - console.error( - 'Settings JSON Schema is stale. Run "npm run generate:settings-schema" and commit the updated schema.', - ); - return 1; - } - console.log(`Settings JSON Schema is current: ${schemaPath}`); - return 0; - } - - fs.mkdirSync(path.dirname(schemaPath), { recursive: true }); - fs.writeFileSync(schemaPath, serializedSchema); - - console.log(`Generated settings JSON Schema at: ${schemaPath}`); - return 0; -} - -if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { - process.exitCode = runGenerateSettingsSchema(process.argv.slice(2)); -} +console.log(`Generated settings JSON Schema at: ${outputPath}`); diff --git a/scripts/lint.js b/scripts/lint.js index 536064aca2..d66ba6e378 100644 --- a/scripts/lint.js +++ b/scripts/lint.js @@ -10,7 +10,7 @@ import { execSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { mkdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { delimiter, join } from 'node:path'; +import { join } from 'node:path'; const ACTIONLINT_VERSION = '1.7.12'; const SHELLCHECK_VERSION = '0.11.0'; @@ -87,7 +87,7 @@ const platformArch = getPlatformArch(); */ const LINTERS = { actionlint: { - check: `test "$(actionlint -version 2>/dev/null)" = "${ACTIONLINT_VERSION}"`, + check: 'command -v actionlint', installer: ` mkdir -p "${TEMP_DIR}/actionlint" curl -sSLo "${TEMP_DIR}/.actionlint.tgz" "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.tar.gz" @@ -106,7 +106,7 @@ const LINTERS = { `, }, shellcheck: { - check: `test "$(shellcheck --version 2>/dev/null | awk '/^version:/ { print $2 }')" = "${SHELLCHECK_VERSION}"`, + check: 'command -v shellcheck', installer: ` mkdir -p "${TEMP_DIR}/shellcheck" curl -sSLo "${TEMP_DIR}/.shellcheck.txz" "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.${platformArch.shellcheck}.tar.xz" @@ -125,42 +125,23 @@ const LINTERS = { `, }, yamllint: { - check: `test "$(yamllint --version 2>/dev/null)" = "yamllint ${YAMLLINT_VERSION}"`, - installer: `python3 -m pip install --target "${TEMP_DIR}/yamllint" "yamllint==${YAMLLINT_VERSION}"`, + check: 'command -v yamllint', + installer: `pip3 install --user "yamllint==${YAMLLINT_VERSION}"`, run: "git ls-files | grep -E '\\.(yaml|yml)' | xargs yamllint --format github", }, }; -export function createLinterEnvironment({ - cwd = process.cwd(), - env = process.env, - tempDir = TEMP_DIR, -} = {}) { - const yamllintTarget = join(tempDir, 'yamllint'); - return { - ...env, - PIP_CONFIG_FILE: '/dev/null', - PIP_REQUIRE_VIRTUALENV: 'false', - PIP_USER: 'false', - PATH: [ - join(cwd, 'node_modules', '.bin'), - join(tempDir, 'actionlint'), - join(tempDir, 'shellcheck'), - join(yamllintTarget, 'bin'), - env.PATH, - ] - .filter(Boolean) - .join(delimiter), - PYTHONPATH: [yamllintTarget, env.PYTHONPATH] - .filter(Boolean) - .join(delimiter), - PYTHONNOUSERSITE: '1', - }; -} - function runCommand(command, stdio = 'inherit') { try { - execSync(command, { stdio, env: createLinterEnvironment() }); + const env = { ...process.env }; + const nodeBin = join(process.cwd(), 'node_modules', '.bin'); + env.PATH = `${nodeBin}:${TEMP_DIR}/actionlint:${TEMP_DIR}/shellcheck:${env.PATH}`; + if (process.platform === 'darwin') { + env.PATH = `${env.PATH}:${process.env.HOME}/Library/Python/3.12/bin`; + } else if (process.platform === 'linux') { + env.PATH = `${env.PATH}:${process.env.HOME}/.local/bin`; + } + execSync(command, { stdio, env }); return true; } catch (_e) { return false; diff --git a/scripts/tests/generate-settings-schema.test.ts b/scripts/tests/generate-settings-schema.test.ts deleted file mode 100644 index aeccf85ba0..0000000000 --- a/scripts/tests/generate-settings-schema.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - mkdtempSync, - existsSync, - mkdirSync, - readFileSync, - rmSync, - statSync, - utimesSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { runGenerateSettingsSchema } from '../generate-settings-schema.js'; - -vi.unmock('node:fs'); - -const tempDirs: string[] = []; - -function makeSchemaPath(): string { - const root = mkdtempSync(path.join(tmpdir(), 'qwen-settings-schema-')); - tempDirs.push(root); - return path.join(root, 'schemas', 'settings.schema.json'); -} - -beforeEach(() => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - vi.spyOn(console, 'log').mockImplementation(() => {}); -}); - -afterEach(() => { - vi.restoreAllMocks(); - - for (const dir of tempDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } -}); - -describe('runGenerateSettingsSchema', () => { - it('runs the real entry point with the default path and exit status', () => { - const script = path.resolve('scripts/generate-settings-schema.ts'); - const current = spawnSync('npx', ['tsx', script, '--check'], { - encoding: 'utf8', - }); - const invalid = spawnSync('npx', ['tsx', script, '--unknown'], { - encoding: 'utf8', - }); - - expect(current.status, current.stderr).toBe(0); - expect(current.stdout).toContain('Settings JSON Schema is current'); - expect(invalid.status).toBe(1); - expect(invalid.stderr).toContain('Unknown argument: --unknown'); - }); - - it('returns success without rewriting a current schema', () => { - const schemaPath = makeSchemaPath(); - expect(runGenerateSettingsSchema([], schemaPath)).toBe(0); - - const content = readFileSync(schemaPath, 'utf8'); - utimesSync(schemaPath, new Date(1_000_000_000), new Date(1_000_000_000)); - const mtimeMs = statSync(schemaPath).mtimeMs; - - expect(runGenerateSettingsSchema(['--check'], schemaPath)).toBe(0); - expect(readFileSync(schemaPath, 'utf8')).toBe(content); - expect(statSync(schemaPath).mtimeMs).toBe(mtimeMs); - }); - - it('reports a stale schema without rewriting it', () => { - const schemaPath = makeSchemaPath(); - mkdirSync(path.dirname(schemaPath), { recursive: true }); - writeFileSync(schemaPath, 'stale schema\n'); - utimesSync(schemaPath, new Date(1_000_000_000), new Date(1_000_000_000)); - const mtimeMs = statSync(schemaPath).mtimeMs; - - expect(runGenerateSettingsSchema(['--check'], schemaPath)).toBe(1); - expect(readFileSync(schemaPath, 'utf8')).toBe('stale schema\n'); - expect(statSync(schemaPath).mtimeMs).toBe(mtimeMs); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining('npm run generate:settings-schema'), - ); - }); - - it('reports a missing schema without creating it', () => { - const schemaPath = makeSchemaPath(); - - expect(runGenerateSettingsSchema(['--check'], schemaPath)).toBe(1); - expect(existsSync(schemaPath)).toBe(false); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining('npm run generate:settings-schema'), - ); - }); - - it('rejects unknown arguments without writing a schema', () => { - const schemaPath = makeSchemaPath(); - - expect(runGenerateSettingsSchema(['--unknown'], schemaPath)).toBe(1); - expect(existsSync(schemaPath)).toBe(false); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining('Unknown argument: --unknown'), - ); - }); -}); diff --git a/scripts/tests/lint.test.js b/scripts/tests/lint.test.js index a98f02a82e..ea7845c8ca 100644 --- a/scripts/tests/lint.test.js +++ b/scripts/tests/lint.test.js @@ -5,21 +5,9 @@ */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { spawnSync } from 'node:child_process'; -import { - chmodSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; describe('getLinterTempDir', () => { const originalArgv = process.argv; - const tempDirs = []; beforeEach(() => { process.argv = ['node', 'scripts/lint.js', '--test-import']; @@ -27,9 +15,6 @@ describe('getLinterTempDir', () => { afterEach(() => { process.argv = originalArgv; - for (const directory of tempDirs.splice(0)) { - rmSync(directory, { recursive: true, force: true }); - } }); it('isolates GitHub Actions linter installs by run and job', async () => { @@ -79,91 +64,4 @@ describe('getLinterTempDir', () => { expect(second).toMatch(/\/qwen-code-linters\/local-[a-f0-9]{16}$/); expect(first).not.toBe(second); }); - - it('uses the owned yamllint target without a version-specific user path', async () => { - const { createLinterEnvironment } = await import('../lint.js'); - - const environment = createLinterEnvironment({ - cwd: '/workspace', - env: { - HOME: '/caller/home', - PATH: '/usr/bin', - PIP_CONFIG_FILE: '/caller/pip.conf', - PIP_REQUIRE_VIRTUALENV: 'true', - PIP_USER: 'true', - PYTHONPATH: '/caller/python', - }, - tempDir: '/owned/linters', - }); - - expect(environment.PATH.split(path.delimiter)).toEqual([ - '/workspace/node_modules/.bin', - '/owned/linters/actionlint', - '/owned/linters/shellcheck', - '/owned/linters/yamllint/bin', - '/usr/bin', - ]); - expect(environment.PYTHONPATH).toBe( - ['/owned/linters/yamllint', '/caller/python'].join(path.delimiter), - ); - expect(environment).toMatchObject({ - PIP_CONFIG_FILE: '/dev/null', - PIP_REQUIRE_VIRTUALENV: 'false', - PIP_USER: 'false', - PYTHONNOUSERSITE: '1', - }); - expect(Object.values(environment).join(path.delimiter)).not.toContain( - 'Python/3.12', - ); - }); - - it.skipIf(process.platform === 'win32')( - 'recognizes the pinned linter version output', - () => { - const root = mkdtempSync(path.join(tmpdir(), 'lint-version-test-')); - tempDirs.push(root); - const bin = path.join(root, 'bin'); - mkdirSync(bin); - const log = path.join(root, 'versions.log'); - const executables = { - actionlint: - '#!/bin/sh\necho actionlint >> "$LINTER_VERSION_LOG"\necho 1.7.12\n', - curl: '#!/bin/sh\nexit 99\n', - python3: '#!/bin/sh\nexit 99\n', - shellcheck: - '#!/bin/sh\necho shellcheck >> "$LINTER_VERSION_LOG"\necho "ShellCheck - shell script analysis tool"\necho "version: 0.11.0"\n', - tar: '#!/bin/sh\nexit 99\n', - yamllint: - '#!/bin/sh\necho yamllint >> "$LINTER_VERSION_LOG"\necho "yamllint 1.35.1"\n', - }; - for (const [name, contents] of Object.entries(executables)) { - const executable = path.join(bin, name); - writeFileSync(executable, contents); - chmodSync(executable, 0o755); - } - - const result = spawnSync( - process.execPath, - ['scripts/lint.js', '--setup'], - { - cwd: path.resolve('.'), - encoding: 'utf8', - env: { - ...process.env, - LINTER_VERSION_LOG: log, - PATH: [bin, process.env.PATH].filter(Boolean).join(path.delimiter), - RUNNER_TEMP: path.join(root, 'runner'), - }, - }, - ); - - expect(result.status, result.stderr).toBe(0); - expect(result.stdout).not.toContain('Installing '); - expect(readFileSync(log, 'utf8').trim().split('\n')).toEqual([ - 'actionlint', - 'shellcheck', - 'yamllint', - ]); - }, - ); }); diff --git a/scripts/tests/package-scripts.test.js b/scripts/tests/package-scripts.test.js index 29fb6f4127..5236a4160d 100644 --- a/scripts/tests/package-scripts.test.js +++ b/scripts/tests/package-scripts.test.js @@ -57,12 +57,6 @@ function getWorkflowStep(job, stepName) { } describe('package scripts', () => { - it('exposes the local PR verification runner', () => { - expect(readPackageJson().scripts['verify:pr']).toBe( - 'node scripts/verify-pr.js', - ); - }); - it('keeps the serve fast-path bundle check outside unit test scripts', () => { const packageJson = readPackageJson(); diff --git a/scripts/tests/verify-pr.test.js b/scripts/tests/verify-pr.test.js deleted file mode 100644 index 2b25c52219..0000000000 --- a/scripts/tests/verify-pr.test.js +++ /dev/null @@ -1,1616 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - existsSync, - chmodSync, - mkdirSync, - mkdtempSync, - readFileSync, - realpathSync, - rmSync, - symlinkSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { spawn, spawnSync } from 'node:child_process'; -import { afterEach, describe, expect, it } from 'vitest'; - -import { - assertNode22, - assertSupportedHost, - createGitEnvironment, - createPythonSteps, - createStepEnvironment, - createValidationSteps, - getVenvPythonPath, - needsPythonChecks, - inspectRepository, - parseArgs, - runCli, - runSteps, - selectProfile, - verifyPullRequest, - withTemporaryWorktree, -} from '../verify-pr.js'; - -const tempDirs = []; - -afterEach(() => { - for (const directory of tempDirs.splice(0)) { - rmSync(directory, { recursive: true, force: true }); - } -}); - -function git(cwd, args, baseEnv = process.env) { - const result = spawnSync('git', ['-c', 'commit.gpgSign=false', ...args], { - cwd, - encoding: 'utf8', - env: createGitEnvironment(baseEnv, { - home: cwd, - hooksPath: '/dev/null', - }), - }); - if (result.status !== 0) { - throw new Error(result.stderr || `git ${args.join(' ')} failed`); - } - return result.stdout.trim(); -} - -function createRepository(baseEnv = process.env) { - const cwd = mkdtempSync(path.join(tmpdir(), 'verify-pr-test-')); - tempDirs.push(cwd); - const template = path.join(cwd, '.git-template'); - mkdirSync(template); - git(cwd, ['init', '--quiet', `--template=${template}`], baseEnv); - rmSync(template, { recursive: true }); - git(cwd, ['config', 'user.email', 'verify-pr@example.com'], baseEnv); - git(cwd, ['config', 'user.name', 'Verify PR Test'], baseEnv); - writeFileSync(path.join(cwd, 'README.md'), 'initial\n'); - git(cwd, ['add', 'README.md'], baseEnv); - git(cwd, ['commit', '--quiet', '-m', 'initial'], baseEnv); - writeFileSync(path.join(cwd, 'source.js'), 'export {};\n'); - git(cwd, ['add', 'source.js'], baseEnv); - git(cwd, ['commit', '--quiet', '-m', 'change'], baseEnv); - return cwd; -} - -async function captureValidation({ baseEnv, changedFiles, worktree }) { - let execution; - await verifyPullRequest( - { base: 'origin/main', cwd: '/caller', requestedProfile: 'full' }, - { - baseEnv, - inspect: () => ({ - baseSha: '1'.repeat(40), - changedFiles, - head: '2'.repeat(40), - mergeBase: '1'.repeat(40), - }), - log: () => {}, - nodeVersion: '22.17.0', - runValidationSteps: async (options) => { - execution = options; - }, - temporaryWorktree: async ({ head, validate }) => { - expect(head).toBe('2'.repeat(40)); - return validate({ - container: '/owned/container', - home: '/owned/home', - hooks: '/owned/hooks', - pythonRoot: '/owned/python', - temp: '/owned/tmp', - worktree, - }); - }, - }, - ); - return execution; -} - -describe('verify-pr CLI', () => { - it('uses the full profile against origin/main by default', () => { - expect(parseArgs([])).toEqual({ - base: 'origin/main', - help: false, - profile: 'full', - }); - }); - - it('accepts base and profile overrides', () => { - expect( - parseArgs(['--base', 'origin/release/1.x', '--profile', 'auto']), - ).toEqual({ - base: 'origin/release/1.x', - help: false, - profile: 'auto', - }); - }); - - it('recognizes help', () => { - expect(parseArgs(['--help'])).toEqual({ - base: 'origin/main', - help: true, - profile: 'full', - }); - }); - - it.each([ - ['missing base value', ['--base']], - ['missing profile value', ['--profile']], - ['option used as a base value', ['--base', '--help']], - ['invalid profile', ['--profile', 'quick']], - ['positional argument', ['main']], - ['unknown option', ['--dirty-worktree']], - ])('rejects %s', (_label, argv) => { - expect(() => parseArgs(argv)).toThrow(); - }); - - it('prints help without starting verification', async () => { - const output = []; - let verified = false; - const exitCode = await runCli(['--help'], { - log: (message) => output.push(message), - verify: async () => { - verified = true; - }, - }); - - expect(exitCode).toBe(0); - expect(verified).toBe(false); - expect(output.join('\n')).toMatch(/--base .*--profile /s); - }); - - it('runs the real entry point for help and invalid arguments', () => { - const script = path.resolve('scripts/verify-pr.js'); - const help = spawnSync(process.execPath, [script, '--help'], { - encoding: 'utf8', - }); - const invalid = spawnSync(process.execPath, [script, '--unknown'], { - encoding: 'utf8', - }); - - expect(help.status, help.stderr).toBe(0); - expect(help.stdout).toContain('npm run verify:pr'); - expect(invalid.status).toBe(1); - expect(invalid.stderr).toMatch(/Unknown option.*Usage:/s); - }); - - it('reports failure context and a single-step rerun hint', async () => { - const errors = []; - const exitCode = await runCli([], { - error: (message) => errors.push(message), - log: () => {}, - verify: async () => { - throw Object.assign(new Error('unit tests failed'), { - base: 'origin/main', - command: ['npm', 'run', 'test:ci'], - detail: 'exited with status 7', - exitCode: 7, - head: 'a'.repeat(40), - profile: 'full', - stage: 'Run unit tests', - }); - }, - }); - - expect(exitCode).toBe(7); - expect(errors.join('\n')).toMatch( - /Stage: Run unit tests.*Command: npm run test:ci.*HEAD: a{40}.*Base: origin\/main.*Profile: full.*Rerun: npm run verify:pr -- --base origin\/main --profile full/s, - ); - }); - - it('relays a child signal after printing failure diagnostics', async () => { - const events = []; - const exitCode = await runCli([], { - error: (message) => events.push(`error:${message}`), - log: () => {}, - relaySignal: (signal) => events.push(`signal:${signal}`), - verify: async () => { - throw Object.assign(new Error('terminated'), { - command: ['npm', 'run', 'test:ci'], - detail: 'terminated by signal SIGTERM', - relaySignal: 'SIGTERM', - signal: 'SIGTERM', - stage: 'Run unit tests', - }); - }, - }); - - expect(exitCode).toBe(143); - expect(events.at(-1)).toBe('signal:SIGTERM'); - expect(events.slice(0, -1).join('\n')).toMatch( - /error:PR verification failed.*error:Error: terminated by signal SIGTERM/s, - ); - }); - - it('uses a conventional exit code for a child-only signal', async () => { - let relayed = false; - const exitCode = await runCli([], { - error: () => {}, - log: () => {}, - relaySignal: () => { - relayed = true; - }, - verify: async () => { - throw Object.assign(new Error('terminated'), { - command: ['node', 'child.js'], - detail: 'terminated by signal SIGPIPE', - exitCode: 141, - signal: 'SIGPIPE', - stage: 'Run child', - }); - }, - }); - - expect(exitCode).toBe(141); - expect(relayed).toBe(false); - }); -}); - -describe('caller guards', () => { - it('requires Node 22', () => { - expect(() => assertNode22('22.17.0')).not.toThrow(); - expect(() => assertNode22('20.19.0')).toThrow(/Node 22/); - expect(() => assertNode22('23.0.0')).toThrow(/Node 22/); - }); - - it('rejects hosts that the pinned linter toolchain does not support', () => { - expect(() => assertSupportedHost('darwin', 'arm64')).not.toThrow(); - expect(() => assertSupportedHost('linux', 'x64')).not.toThrow(); - expect(() => assertSupportedHost('win32', 'x64')).toThrow( - /supports macOS.*Linux x64.*GitHub CI/i, - ); - expect(() => assertSupportedHost('linux', 'arm64')).toThrow( - /supports macOS.*Linux x64.*GitHub CI/i, - ); - }); - - it('isolates fixture commits from global signing and hooks', () => { - const root = mkdtempSync(path.join(tmpdir(), 'verify-pr-git-config-')); - tempDirs.push(root); - const hooks = path.join(root, 'hooks'); - const marker = path.join(root, 'hook-ran'); - const config = path.join(root, 'gitconfig'); - mkdirSync(hooks); - const hook = path.join(hooks, 'post-commit'); - writeFileSync(hook, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\n`); - chmodSync(hook, 0o755); - writeFileSync( - config, - `[commit]\n\tgpgSign = true\n[core]\n\thooksPath = ${hooks}\n`, - ); - - const cwd = createRepository({ - ...process.env, - GIT_CONFIG_GLOBAL: config, - }); - - expect(git(cwd, ['log', '--oneline'])).toContain('change'); - expect(existsSync(marker)).toBe(false); - }); - - it.skipIf(process.platform === 'win32')( - 'preserves the real Git path required by a PATH wrapper', - () => { - const root = mkdtempSync(path.join(tmpdir(), 'verify-pr-git-wrapper-')); - tempDirs.push(root); - const bin = path.join(root, 'bin'); - mkdirSync(bin); - const wrapper = path.join(bin, 'git'); - writeFileSync(wrapper, '#!/bin/sh\nexec "$QWEN_CI_REAL_GIT" "$@"\n'); - chmodSync(wrapper, 0o755); - const realGit = - process.env.QWEN_CI_REAL_GIT || - spawnSync('sh', ['-c', 'command -v git'], { - encoding: 'utf8', - env: process.env, - }).stdout.trim(); - - const cwd = createRepository({ - ...process.env, - PATH: [bin, process.env.PATH].filter(Boolean).join(path.delimiter), - QWEN_CI_REAL_GIT: realGit, - }); - - expect(git(cwd, ['log', '--oneline'])).toContain('change'); - }, - ); - - it('resolves the base and reports committed changed paths', () => { - const cwd = createRepository(); - - const repository = inspectRepository({ base: 'HEAD^', cwd }); - - expect(repository.head).toBe(git(cwd, ['rev-parse', 'HEAD'])); - expect(repository.baseSha).toBe(git(cwd, ['rev-parse', 'HEAD^'])); - expect(repository.mergeBase).toBe( - git(cwd, ['merge-base', 'HEAD^', 'HEAD']), - ); - expect(repository.changedFiles).toEqual(['source.js']); - }); - - it('ignores Git routing variables when inspecting the caller', () => { - const caller = createRepository(); - const other = createRepository(); - writeFileSync(path.join(caller, 'source.js'), 'dirty\n'); - - expect(() => - inspectRepository({ - base: 'HEAD^', - cwd: caller, - env: { - ...process.env, - GIT_DIR: path.join(other, '.git'), - GIT_INDEX_FILE: path.join(other, '.git', 'index'), - GIT_WORK_TREE: other, - }, - }), - ).toThrow(/working tree/); - }); - - it.each(['staged', 'unstaged', 'untracked'])( - 'rejects a %s caller change before resolving the base', - (kind) => { - const cwd = createRepository(); - const filename = kind === 'untracked' ? 'new.txt' : 'source.js'; - writeFileSync(path.join(cwd, filename), `${kind}\n`); - if (kind === 'staged') git(cwd, ['add', filename]); - - expect(() => inspectRepository({ base: 'missing-ref', cwd })).toThrow( - /working tree.*staged.*unstaged.*untracked/i, - ); - }, - ); - - it('rejects an unresolved base ref', () => { - const cwd = createRepository(); - - expect(() => inspectRepository({ base: 'missing-ref', cwd })).toThrow( - /missing-ref/, - ); - }); - - it('requires a committed change against the merge base', () => { - const cwd = createRepository(); - - expect(() => inspectRepository({ base: 'HEAD', cwd })).toThrow( - /No committed changes/, - ); - }); -}); - -describe('profile selection', () => { - it('keeps the default full profile without classifying paths', () => { - let classified = false; - - expect( - selectProfile({ - changedFiles: ['docs/guide.md'], - classify: () => { - classified = true; - return 'docs_only'; - }, - requestedProfile: 'full', - }), - ).toBe('full'); - expect(classified).toBe(false); - }); - - it.each([ - [['docs/guide.md'], 'docs_only'], - [['.github/scripts/pr-safety-precheck.mjs'], 'github_ci_only'], - [['packages/core/src/index.ts'], 'full'], - [['docs/guide.md', 'packages/core/src/index.ts'], 'full'], - ])('classifies auto profile changes %j as %s', (changedFiles, expected) => { - expect(selectProfile({ changedFiles, requestedProfile: 'auto' })).toBe( - expected, - ); - }); - - it('treats both sides of a cross-profile rename as changed', () => { - const cwd = createRepository(); - mkdirSync(path.join(cwd, 'docs')); - writeFileSync(path.join(cwd, 'docs', 'guide.md'), 'guide\n'); - git(cwd, ['add', 'docs/guide.md']); - git(cwd, ['commit', '--quiet', '-m', 'add docs']); - mkdirSync(path.join(cwd, 'packages', 'core'), { recursive: true }); - git(cwd, ['mv', 'docs/guide.md', 'packages/core/guide.ts']); - git(cwd, ['commit', '--quiet', '-m', 'rename docs to source']); - - const { changedFiles } = inspectRepository({ base: 'HEAD^', cwd }); - - expect(changedFiles).toEqual(['docs/guide.md', 'packages/core/guide.ts']); - expect(selectProfile({ changedFiles, requestedProfile: 'auto' })).toBe( - 'full', - ); - }); -}); - -describe('validation profiles', () => { - it('uses the exact dependency-free GitHub CI helper checks', () => { - expect( - createValidationSteps({ profile: 'github_ci_only' }).map( - ({ command }) => command, - ), - ).toEqual([ - ['node', 'scripts/lint.js', '--setup'], - ['node', 'scripts/lint.js', '--actionlint'], - ['node', 'scripts/lint.js', '--yamllint'], - [ - 'node', - '--test', - '.github/scripts/pr-safety-precheck.test.mjs', - '.github/scripts/ci/classify-profile.test.mjs', - '.github/scripts/resolve-sandbox-image.test.mjs', - ], - ]); - }); - - it('uses the exact full validation sequence without a redundant build', () => { - expect( - createValidationSteps({ - prettierFiles: ['scripts/verify-pr.js'], - profile: 'full', - }).map(({ command }) => command), - ).toEqual([ - [ - 'npm', - 'ci', - '--prefer-offline', - '--no-audit', - '--progress=false', - '--ignore-scripts=false', - ], - ['npm', 'run', 'audit:runtime:critical'], - ['npm', 'run', 'check:lockfile'], - ['npm', 'run', 'check:desktop-isolation'], - ['node', 'scripts/lint.js', '--setup'], - ['node', 'scripts/lint.js', '--eslint'], - ['node', 'scripts/lint.js', '--actionlint'], - ['node', 'scripts/lint.js', '--shellcheck'], - ['node', 'scripts/lint.js', '--yamllint'], - [ - 'node', - '--test', - '.github/scripts/pr-safety-precheck.test.mjs', - '.github/scripts/ci/classify-profile.test.mjs', - '.github/scripts/resolve-sandbox-image.test.mjs', - ], - [ - 'npx', - 'prettier', - '--experimental-cli', - '--check', - '--ignore-unknown', - '--', - 'scripts/verify-pr.js', - ], - ['npm', 'run', 'check-i18n'], - ['npm', 'run', 'generate:settings-schema', '--', '--check'], - [ - 'git', - 'cat-file', - '-e', - 'HEAD:packages/vscode-ide-companion/schemas/settings.schema.json', - ], - [ - 'git', - 'diff', - '--exit-code', - 'HEAD', - '--', - 'packages/vscode-ide-companion/schemas/settings.schema.json', - ], - ['npm', 'run', 'typecheck'], - ['npm', 'run', 'check:serve-fast-path-bundle'], - [ - 'npx', - 'cross-env', - 'NODE_OPTIONS=--max-old-space-size=3072', - 'npm', - 'run', - 'test:ci', - '--workspaces', - '--if-present', - '--', - '--no-file-parallelism', - ], - ['npm', 'run', 'test:scripts', '--', '--no-file-parallelism'], - ['npm', 'run', 'test:integration:no-ak:sandbox:none'], - ['npx', 'playwright', 'install', 'chromium'], - ['npm', 'run', 'test:e2e:smoke', '--workspace=packages/web-shell'], - ]); - }); - - it('omits Prettier when there are no existing changed files', () => { - const steps = createValidationSteps({ profile: 'full' }); - - expect(steps.map(({ name }) => name)).not.toContain('Run Prettier'); - expect( - steps.find(({ name }) => name === 'Install dependencies'), - ).toMatchObject({ installEnvironment: true }); - expect( - steps.find(({ name }) => name === 'Run web shell smoke tests'), - ).toMatchObject({ playwright: true, playwrightEnvironment: true }); - }); - - it('adds Python checks only for SDK or workflow changes', () => { - expect(needsPythonChecks(['packages/sdk-python/src/client.py'])).toBe(true); - expect(needsPythonChecks(['.github/workflows/sdk-python.yml'])).toBe(true); - expect(needsPythonChecks(['packages/core/src/index.ts'])).toBe(false); - }); - - it('uses the platform-specific virtualenv Python path', () => { - expect(getVenvPythonPath('/tmp/venv', 'linux')).toBe( - path.join('/tmp/venv', 'bin', 'python'), - ); - expect(getVenvPythonPath('C:\\venv', 'win32')).toBe( - path.join('C:\\venv', 'Scripts', 'python.exe'), - ); - }); - - it('expands the Python checks for 3.10, 3.11, and 3.12', () => { - const pythonRoot = path.join('/tmp', 'verify-pr-python'); - const commands = createPythonSteps({ - platform: 'linux', - pythonRoot, - }).map(({ command }) => command); - const expected = [['uv', '--version']]; - for (const version of ['3.10', '3.11', '3.12']) { - const venv = path.join(pythonRoot, version); - const python = path.join(venv, 'bin', 'python'); - expected.push( - ['uv', 'venv', '--python', version, '--seed', venv], - [python, '-m', 'pip', 'install', '--upgrade', 'pip'], - [python, '-m', 'pip', 'install', '-e', 'packages/sdk-python[dev]'], - [ - python, - '-m', - 'ruff', - 'check', - '--config', - 'packages/sdk-python/pyproject.toml', - 'packages/sdk-python', - ], - [ - python, - '-m', - 'ruff', - 'format', - '--check', - '--config', - 'packages/sdk-python/pyproject.toml', - 'packages/sdk-python', - ], - [ - python, - '-m', - 'mypy', - '--config-file', - 'packages/sdk-python/pyproject.toml', - 'packages/sdk-python/src', - ], - [ - python, - '-m', - 'pytest', - '-c', - 'packages/sdk-python/pyproject.toml', - 'packages/sdk-python/tests', - '-q', - ], - ); - } - expect(commands).toEqual(expected); - expect( - createPythonSteps({ platform: 'linux', pythonRoot }).every( - (pythonStep) => pythonStep.pythonEnvironment, - ), - ).toBe(true); - }); -}); - -describe('step execution', () => { - it.skipIf(process.platform === 'win32')( - 'keeps a forwarded signal separate when the child exits cleanly', - async () => { - const moduleUrl = new URL('../verify-pr.js', import.meta.url).href; - const harnessScript = ` - import { executeChild } from ${JSON.stringify(moduleUrl)}; - - const result = await executeChild({ - command: [ - process.execPath, - '--input-type=module', - '--eval', - 'process.on("SIGINT", () => process.exit(0)); console.log("CHILD_READY"); setInterval(() => {}, 1000);', - ], - cwd: process.cwd(), - env: process.env, - }); - console.log('RESULT:' + JSON.stringify(result)); - `; - const harness = spawn( - process.execPath, - ['--input-type=module', '--eval', harnessScript], - { - cwd: path.resolve(import.meta.dirname, '../..'), - stdio: ['ignore', 'pipe', 'pipe'], - }, - ); - let output = ''; - harness.stdout.on('data', (chunk) => { - output += chunk; - }); - harness.stderr.on('data', (chunk) => { - output += chunk; - }); - const completion = new Promise((resolve) => { - harness.once('exit', (code, signal) => resolve({ code, signal })); - }); - - try { - await new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error(`Signal harness did not start:\n${output}`)), - 5000, - ); - const checkReady = () => { - if (!output.includes('CHILD_READY')) return; - clearTimeout(timeout); - resolve(); - }; - harness.stdout.on('data', checkReady); - checkReady(); - }); - - harness.kill('SIGINT'); - await expect(completion).resolves.toEqual({ code: 0, signal: null }); - const result = JSON.parse(output.split('RESULT:').at(-1).trim()); - expect(result).toEqual({ - relaySignal: 'SIGINT', - signal: null, - status: 0, - }); - } finally { - if (harness.exitCode === null && harness.signalCode === null) { - harness.kill('SIGKILL'); - await completion; - } - } - }, - ); - - it('reports a forwarded signal without claiming child termination', async () => { - await expect( - runSteps({ - allocatePort: async () => 43123, - baseEnv: {}, - cwd: '/temporary-worktree', - execute: () => ({ - relaySignal: 'SIGINT', - signal: null, - status: 0, - }), - home: '/temporary-home', - log: () => {}, - steps: createValidationSteps({ profile: 'github_ci_only' }).slice(0, 1), - }), - ).rejects.toMatchObject({ - detail: 'interrupted after forwarding signal SIGINT', - exitCode: 130, - relaySignal: 'SIGINT', - signal: undefined, - }); - }); - - it('uses a controlled environment and isolated HOME for every step', () => { - const steps = [ - ...createValidationSteps({ profile: 'full' }), - ...createPythonSteps({ pythonRoot: '/owned/python' }), - ]; - const baseEnv = { - HOME: '/caller/home', - HTTPS_PROXY: 'http://proxy.example', - NPM_CONFIG_GLOBAL: 'true', - NODE_ENV: 'production', - OPENAI_API_KEY: 'secret', - PATH: '/usr/bin', - PIP_REQUIRE_VIRTUALENV: 'true', - PLAYWRIGHT_BROWSERS_PATH: '/caller/browsers', - PYTEST_ADDOPTS: '--collect-only', - QwEn_OaUtH: 'secret', - QWEN_HOME: '/caller/qwen', - SAFE: 'must-not-pass', - SHELL: '/caller/shell', - TZ: 'Pacific/Honolulu', - USER: 'caller', - USERPROFILE: '/caller/profile', - XAI_API_KEY: 'secret', - }; - - for (const currentStep of steps) { - const env = createStepEnvironment({ - baseEnv, - home: '/owned/home', - playwrightPort: currentStep.playwright ? 43123 : undefined, - step: currentStep, - }); - expect(env).toMatchObject({ - CI: 'true', - HOME: '/owned/home', - HTTPS_PROXY: 'http://proxy.example', - LANG: 'C', - LC_ALL: 'C', - NPM_CONFIG_GLOBAL: 'false', - NPM_CONFIG_GLOBALCONFIG: '/dev/null', - NPM_CONFIG_CACHE: path.join('/owned/home', '.npm'), - NPM_CONFIG_PREFIX: path.join('/owned/home', '.npm-prefix'), - NPM_CONFIG_USERCONFIG: path.join('/owned/home', '.npmrc'), - NO_COLOR: 'true', - PATH: '/usr/bin', - TZ: 'UTC', - USERPROFILE: '/owned/home', - }); - for (const key of [ - 'NODE_ENV', - 'OPENAI_API_KEY', - 'PYTEST_ADDOPTS', - 'QwEn_OaUtH', - 'QWEN_HOME', - 'SAFE', - 'SHELL', - 'USER', - 'XAI_API_KEY', - ]) { - expect(env).not.toHaveProperty(key); - } - if (currentStep.playwrightEnvironment) { - expect(env.PLAYWRIGHT_BROWSERS_PATH).toBe( - path.join('/owned/home', 'playwright'), - ); - } else { - expect(env).not.toHaveProperty('PLAYWRIGHT_BROWSERS_PATH'); - } - if (currentStep.pythonEnvironment) { - expect(env).toMatchObject({ - PIP_CONFIG_FILE: '/dev/null', - PIP_REQUIRE_VIRTUALENV: 'false', - PIP_USER: 'false', - PYTHONNOUSERSITE: '1', - UV_NO_CONFIG: '1', - }); - } else { - expect(env).not.toHaveProperty('PIP_REQUIRE_VIRTUALENV'); - } - expect(env.PLAYWRIGHT_PORT).toBe( - currentStep.playwright ? '43123' : undefined, - ); - } - }); - - it('preserves a numeric exit code and stops at the first failure', async () => { - const steps = createValidationSteps({ profile: 'github_ci_only' }); - const commands = []; - - await expect( - runSteps({ - allocatePort: async () => 43123, - baseEnv: {}, - cwd: '/temporary-worktree', - execute: ({ command }) => { - commands.push(command.join(' ')); - return { status: commands.length === 2 ? 7 : 0 }; - }, - home: '/temporary-home', - log: () => {}, - steps, - }), - ).rejects.toMatchObject({ - command: steps[1].command, - exitCode: 7, - stage: steps[1].name, - }); - expect(commands).toEqual( - steps.slice(0, 2).map(({ command }) => command.join(' ')), - ); - }); - - it.each([ - [ - 'spawn error', - { error: new Error('command not found'), status: null }, - /spawn error: command not found/, - 1, - ], - ['signal', { signal: 'SIGTERM', status: null }, /signal SIGTERM/, 143], - ])( - 'reports a %s as a non-zero failure', - async (_label, result, message, exitCode) => { - await expect( - runSteps({ - allocatePort: async () => 43123, - baseEnv: {}, - cwd: '/temporary-worktree', - execute: () => result, - home: '/temporary-home', - log: () => {}, - steps: createValidationSteps({ profile: 'github_ci_only' }).slice( - 0, - 1, - ), - }), - ).rejects.toMatchObject({ - detail: expect.stringMatching(message), - exitCode, - }); - }, - ); - - it('reports an actionable error when uv is unavailable', async () => { - await expect( - runSteps({ - allocatePort: async () => 43123, - baseEnv: {}, - cwd: '/temporary-worktree', - execute: () => ({ - error: Object.assign(new Error('spawn uv ENOENT'), { - code: 'ENOENT', - }), - status: null, - }), - home: '/temporary-home', - log: () => {}, - steps: createPythonSteps({ pythonRoot: '/temporary-python' }).slice( - 0, - 1, - ), - }), - ).rejects.toMatchObject({ - exitCode: 1, - message: expect.stringMatching(/uv is required.*Install uv.*PATH/i), - }); - }); - - it('allocates the Playwright port immediately before its step', async () => { - const events = []; - const steps = createValidationSteps({ profile: 'full' }).filter( - ({ name }) => - name === 'Install Playwright Chromium' || - name === 'Run web shell smoke tests', - ); - - await runSteps({ - allocatePort: async () => { - events.push('allocate'); - return 43123; - }, - baseEnv: {}, - cwd: '/temporary-worktree', - execute: ({ command, env }) => { - events.push({ command: command.join(' '), port: env.PLAYWRIGHT_PORT }); - return { status: 0 }; - }, - home: '/temporary-home', - log: () => {}, - steps, - }); - - expect(events.slice(-2)).toEqual([ - 'allocate', - { - command: 'npm run test:e2e:smoke --workspace=packages/web-shell', - port: '43123', - }, - ]); - }); - - it('attributes Playwright port allocation failures to the current step', async () => { - const step = createValidationSteps({ profile: 'full' }).find( - ({ playwright }) => playwright, - ); - - await expect( - runSteps({ - allocatePort: async () => { - throw new Error('bind failed'); - }, - baseEnv: {}, - cwd: '/temporary-worktree', - execute: () => { - throw new Error('must not execute'); - }, - home: '/temporary-home', - log: () => {}, - steps: [step], - }), - ).rejects.toMatchObject({ - command: step.command, - detail: expect.stringMatching(/Playwright port.*bind failed/), - stage: step.name, - }); - }); - - it('escapes control characters only in command display', async () => { - const command = [ - 'npx', - 'prettier', - 'bad\u001b]52;c;payload\u0007\nname.js', - ]; - const output = []; - let executed; - - await runSteps({ - allocatePort: async () => 43123, - baseEnv: {}, - cwd: '/temporary-worktree', - execute: ({ command: childCommand }) => { - executed = childCommand; - return { status: 0 }; - }, - home: '/temporary-home', - log: (message) => output.push(message), - steps: [{ command, name: 'Display unsafe path' }], - }); - - expect(executed).toEqual(command); - const display = output.join(' '); - expect( - [...display].every((character) => { - const codePoint = character.codePointAt(0); - return !(codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)); - }), - ).toBe(true); - expect(display).toContain('\\u{1b}'); - expect(display).toContain('\\u{a}'); - }); - - it('rejects a schema rewritten before the committed freshness check', async () => { - const cwd = createRepository(); - const schemaPath = path.join( - cwd, - 'packages', - 'vscode-ide-companion', - 'schemas', - 'settings.schema.json', - ); - mkdirSync(path.dirname(schemaPath), { recursive: true }); - writeFileSync(schemaPath, 'committed schema\n'); - git(cwd, ['add', schemaPath]); - git(cwd, ['commit', '--quiet', '-m', 'add schema']); - writeFileSync(schemaPath, 'schema rewritten by build\n'); - git(cwd, ['add', schemaPath]); - const steps = createValidationSteps({ profile: 'full' }).filter( - ({ name }) => - name === 'Ensure settings schema is committed' || - name === 'Check committed settings schema', - ); - - await expect( - runSteps({ - allocatePort: async () => 43123, - baseEnv: process.env, - cwd, - home: '/temporary-home', - log: () => {}, - steps, - }), - ).rejects.toMatchObject({ - exitCode: 1, - stage: 'Check committed settings schema', - }); - }); - - it('rejects a generated schema that is not tracked at HEAD', async () => { - const cwd = createRepository(); - const schemaPath = path.join( - cwd, - 'packages', - 'vscode-ide-companion', - 'schemas', - 'settings.schema.json', - ); - mkdirSync(path.dirname(schemaPath), { recursive: true }); - writeFileSync(schemaPath, 'untracked generated schema\n'); - const steps = createValidationSteps({ profile: 'full' }).filter( - ({ name }) => name === 'Ensure settings schema is committed', - ); - - await expect( - runSteps({ - allocatePort: async () => 43123, - baseEnv: process.env, - cwd, - home: '/temporary-home', - log: () => {}, - steps, - }), - ).rejects.toMatchObject({ - exitCode: 128, - stage: 'Ensure settings schema is committed', - }); - }); - - it.skipIf(process.platform === 'win32')( - 'cleans an owned worktree before relaying a real termination signal', - async () => { - const cwd = createRepository(); - const head = git(cwd, ['rev-parse', 'HEAD']); - const harnessRoot = mkdtempSync(path.join(tmpdir(), 'verify-pr-signal-')); - tempDirs.push(harnessRoot); - const marker = path.join(harnessRoot, 'cleanup.json'); - const moduleUrl = new URL('../verify-pr.js', import.meta.url).href; - const harnessScript = ` - import { existsSync, writeFileSync } from 'node:fs'; - import { spawnSync } from 'node:child_process'; - import { runSteps, withTemporaryWorktree } from ${JSON.stringify(moduleUrl)}; - - let ownedPaths; - let relayedSignal; - try { - await withTemporaryWorktree({ - cwd: process.cwd(), - head: ${JSON.stringify(head)}, - validate: async (paths) => { - ownedPaths = paths; - await runSteps({ - allocatePort: async () => 43123, - baseEnv: process.env, - cwd: paths.worktree, - home: paths.home, - log: () => {}, - steps: [{ - command: [ - process.execPath, - '--input-type=module', - '--eval', - 'process.on("SIGTERM", () => {}); console.log("READY"); setInterval(() => {}, 1000);', - ], - name: 'Wait for signal', - }], - }); - }, - }); - } catch (error) { - relayedSignal = error.relaySignal; - } - - const registrations = spawnSync( - 'git', - ['worktree', 'list', '--porcelain'], - { cwd: process.cwd(), encoding: 'utf8' }, - ).stdout; - writeFileSync( - ${JSON.stringify(marker)}, - JSON.stringify({ - containerExists: existsSync(ownedPaths.container), - registered: registrations.includes(ownedPaths.worktree), - signal: relayedSignal, - }), - ); - process.kill(process.pid, relayedSignal); - `; - const harness = spawn( - process.execPath, - ['--input-type=module', '--eval', harnessScript], - { - cwd, - stdio: ['ignore', 'pipe', 'pipe'], - }, - ); - let output = ''; - harness.stdout.setEncoding('utf8'); - harness.stderr.setEncoding('utf8'); - harness.stdout.on('data', (chunk) => { - output += chunk; - }); - harness.stderr.on('data', (chunk) => { - output += chunk; - }); - const completion = new Promise((resolve) => { - harness.once('exit', (code, signal) => resolve({ code, signal })); - }); - try { - await new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error(`Signal harness did not start:\n${output}`)), - 10_000, - ); - const checkReady = () => { - if (!output.includes('READY')) return; - clearTimeout(timeout); - harness.stdout.off('data', checkReady); - resolve(); - }; - harness.stdout.on('data', checkReady); - checkReady(); - }); - - harness.kill('SIGTERM'); - await new Promise((resolve) => setTimeout(resolve, 50)); - harness.kill('SIGTERM'); - const result = await completion; - - expect(result).toEqual({ code: null, signal: 'SIGTERM' }); - expect(JSON.parse(readFileSync(marker, 'utf8'))).toEqual({ - containerExists: false, - registered: false, - signal: 'SIGTERM', - }); - } finally { - if (harness.exitCode === null && harness.signalCode === null) { - harness.kill('SIGKILL'); - await completion; - } - } - }, - 15_000, - ); - - it('uses CI-equivalent npm fetch settings only for npm ci', () => { - const steps = createValidationSteps({ profile: 'full' }); - const installEnv = createStepEnvironment({ - baseEnv: { - HUSKY: 'caller-value', - NPM_CONFIG_GLOBAL: 'true', - QWEN_SKIP_PREPARE: '1', - npm_config_ignore_scripts: 'true', - }, - home: '/temporary-home', - step: steps[0], - }); - expect(installEnv).toMatchObject({ - HUSKY: '0', - NPM_CONFIG_FETCH_RETRIES: '5', - NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: '120000', - NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: '20000', - NPM_CONFIG_FETCH_TIMEOUT: '300000', - NPM_CONFIG_GLOBAL: 'false', - NPM_CONFIG_IGNORE_SCRIPTS: 'false', - }); - for (const key of ['QWEN_SKIP_PREPARE', 'npm_config_ignore_scripts']) { - expect(installEnv).not.toHaveProperty(key); - } - const laterEnv = createStepEnvironment({ - baseEnv: { HUSKY: 'caller-value', QWEN_SKIP_PREPARE: '1' }, - home: '/temporary-home', - step: steps[1], - }); - expect(laterEnv).not.toHaveProperty('NPM_CONFIG_FETCH_RETRIES'); - expect(laterEnv).not.toHaveProperty('HUSKY'); - expect(laterEnv).not.toHaveProperty('QWEN_SKIP_PREPARE'); - }); -}); - -describe('temporary worktree isolation', () => { - it('checks out the inspected SHA without running caller checkout hooks', async () => { - const cwd = createRepository(); - const inspectedHead = git(cwd, ['rev-parse', 'HEAD']); - const root = mkdtempSync(path.join(tmpdir(), 'verify-pr-hooks-')); - tempDirs.push(root); - const hooks = path.join(root, 'hooks'); - const marker = path.join(root, 'post-checkout-ran'); - mkdirSync(hooks); - const hook = path.join(hooks, 'post-checkout'); - writeFileSync(hook, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\n`); - chmodSync(hook, 0o755); - writeFileSync(path.join(cwd, 'source.js'), 'new head\n'); - git(cwd, ['add', 'source.js']); - git(cwd, ['commit', '--quiet', '-m', 'move head']); - - await withTemporaryWorktree({ - baseEnv: { - ...process.env, - GIT_CONFIG_COUNT: '1', - GIT_CONFIG_KEY_0: 'core.hooksPath', - GIT_CONFIG_VALUE_0: hooks, - }, - cwd, - head: inspectedHead, - validate: async (paths) => { - expect( - readFileSync(path.join(paths.worktree, 'source.js'), 'utf8'), - ).toBe('export {};\n'); - }, - }); - - expect(existsSync(marker)).toBe(false); - }); - - it('cleans the created container when canonicalization fails', async () => { - const cwd = createRepository(); - const created = path.join( - tmpdir(), - `verify-pr-loop-${process.pid}-${Date.now()}`, - ); - symlinkSync(created, created); - tempDirs.push(created); - const removed = []; - - await expect( - withTemporaryWorktree({ - cwd, - head: git(cwd, ['rev-parse', 'HEAD']), - makeContainer: () => created, - removeContainer: (container) => removed.push(container), - validate: async () => {}, - }), - ).rejects.toThrow(); - - expect(removed).toEqual([created]); - }); - - it('runs prepare during the first npm ci despite caller skip settings', async () => { - const cwd = createRepository(); - writeFileSync( - path.join(cwd, 'package.json'), - JSON.stringify({ - name: 'verify-pr-install-fixture', - private: true, - scripts: { prepare: 'node prepare.mjs' }, - type: 'module', - version: '1.0.0', - }), - ); - writeFileSync( - path.join(cwd, 'prepare.mjs'), - `import { writeFileSync } from 'node:fs'; -writeFileSync('prepare-marker.json', JSON.stringify({ - husky: process.env.HUSKY ?? null, - ignoreScripts: process.env.npm_config_ignore_scripts ?? null, - skipPrepare: process.env.QWEN_SKIP_PREPARE ?? null, -})); -`, - ); - const npmHome = mkdtempSync(path.join(tmpdir(), 'verify-pr-npm-home-')); - tempDirs.push(npmHome); - const lockResult = spawnSync( - 'npm', - ['install', '--package-lock-only', '--ignore-scripts'], - { - cwd, - encoding: 'utf8', - env: createStepEnvironment({ - baseEnv: process.env, - home: npmHome, - step: { installEnvironment: true }, - }), - }, - ); - expect(lockResult.status, lockResult.stderr).toBe(0); - git(cwd, ['add', 'package.json', 'package-lock.json', 'prepare.mjs']); - git(cwd, ['commit', '--quiet', '-m', 'add install fixture']); - let marker; - let ownedContainer; - - await withTemporaryWorktree({ - cwd, - head: git(cwd, ['rev-parse', 'HEAD']), - validate: async (paths) => { - ownedContainer = paths.container; - await runSteps({ - allocatePort: async () => 43123, - baseEnv: { - ...process.env, - HUSKY: 'caller-value', - QWEN_SKIP_PREPARE: '1', - npm_config_ignore_scripts: 'true', - }, - cwd: paths.worktree, - home: paths.home, - log: () => {}, - steps: createValidationSteps({ profile: 'full' }).slice(0, 1), - }); - marker = JSON.parse( - readFileSync( - path.join(paths.worktree, 'prepare-marker.json'), - 'utf8', - ), - ); - }, - }); - - expect(marker).toMatchObject({ - husky: '0', - skipPrepare: null, - }); - expect(marker.ignoreScripts).not.toBe('true'); - expect(existsSync(path.join(cwd, 'prepare-marker.json'))).toBe(false); - expect(git(cwd, ['status', '--porcelain'])).toBe(''); - expect(existsSync(ownedContainer)).toBe(false); - }); - - it('keeps the caller worktree unchanged and removes owned temporary files', async () => { - const cwd = createRepository(); - let ownedPaths; - - await withTemporaryWorktree({ - cwd, - head: git(cwd, ['rev-parse', 'HEAD']), - validate: async (paths) => { - ownedPaths = paths; - expect(paths.container).toBe(realpathSync(paths.container)); - expect(path.dirname(paths.container)).toBe( - realpathSync(process.platform === 'win32' ? tmpdir() : '/tmp'), - ); - expect(existsSync(paths.temp)).toBe(true); - writeFileSync(path.join(paths.worktree, 'source.js'), 'changed\n'); - }, - }); - - expect(readFileSync(path.join(cwd, 'source.js'), 'utf8')).toBe( - 'export {};\n', - ); - expect(existsSync(ownedPaths.container)).toBe(false); - expect(git(cwd, ['worktree', 'list', '--porcelain'])).not.toContain( - ownedPaths.worktree, - ); - }); - - it.skipIf(process.platform === 'win32')( - 'uses short canonical temporary paths instead of a long caller TMPDIR', - async () => { - const cwd = createRepository(); - const longTemp = path.join(cwd, 'a'.repeat(120)); - mkdirSync(longTemp); - const previousTemp = process.env.TMPDIR; - let container; - - process.env.TMPDIR = longTemp; - try { - await withTemporaryWorktree({ - cwd, - head: git(cwd, ['rev-parse', 'HEAD']), - validate: async (paths) => { - container = paths.container; - }, - }); - } finally { - if (previousTemp === undefined) delete process.env.TMPDIR; - else process.env.TMPDIR = previousTemp; - } - - expect(path.dirname(container)).toBe(realpathSync('/tmp')); - }, - ); - - it('fails when owned-worktree cleanup fails after validation succeeds', async () => { - const cwd = createRepository(); - const commands = []; - - await expect( - withTemporaryWorktree({ - cwd, - head: git(cwd, ['rev-parse', 'HEAD']), - gitCommand: ({ args, cwd: gitCwd, env }) => { - commands.push(args); - if (args[1] === 'remove') return { status: 9 }; - return spawnSync('git', args, { cwd: gitCwd, env }); - }, - validate: async () => {}, - }), - ).rejects.toMatchObject({ - exitCode: 9, - stage: 'Clean up temporary worktree', - }); - expect(commands.at(-1).slice(0, 4)).toEqual([ - 'worktree', - 'remove', - '--force', - '--force', - ]); - git(cwd, ['worktree', 'prune']); - }); - - it('preserves validation status and reports a simultaneous cleanup failure', async () => { - const cwd = createRepository(); - const validationFailure = Object.assign(new Error('validation failed'), { - exitCode: 7, - }); - const cleanupReports = []; - const ownedContainer = mkdtempSync( - path.join(tmpdir(), 'verify-pr-cleanup-'), - ); - tempDirs.push(ownedContainer); - let caught; - - try { - await withTemporaryWorktree({ - cwd, - head: git(cwd, ['rev-parse', 'HEAD']), - gitCommand: ({ args, cwd: gitCwd, env }) => - args[1] === 'remove' - ? { status: 9 } - : spawnSync('git', args, { cwd: gitCwd, env }), - makeContainer: () => ownedContainer, - removeContainer: () => { - throw new Error('filesystem cleanup failed'); - }, - reportCleanup: (message) => cleanupReports.push(message), - validate: async () => { - throw validationFailure; - }, - }); - } catch (error) { - caught = error; - } - - expect(caught).toBe(validationFailure); - expect(cleanupReports).toEqual([ - expect.stringMatching(/Cleanup also failed.*status 9/), - expect.stringMatching(/Cleanup also failed.*filesystem cleanup failed/), - ]); - git(cwd, ['worktree', 'prune']); - }); -}); - -describe('verification orchestration', () => { - it('reports and skips dependency installation for auto docs-only changes', async () => { - const cwd = createRepository(); - mkdirSync(path.join(cwd, 'docs')); - writeFileSync(path.join(cwd, 'docs', 'guide.md'), 'guide\n'); - git(cwd, ['add', 'docs/guide.md']); - git(cwd, ['commit', '--quiet', '-m', 'docs']); - const output = []; - - const result = await verifyPullRequest( - { base: 'HEAD^', cwd, requestedProfile: 'auto' }, - { - log: (message) => output.push(message), - nodeVersion: '22.17.0', - temporaryWorktree: () => { - throw new Error('must not create a worktree for docs-only changes'); - }, - }, - ); - - expect(result.profile).toBe('docs_only'); - expect(output.join('\n')).toMatch( - /Base: HEAD\^.*HEAD: [0-9a-f]{40}.*Changed files: 1.*Profile: docs_only/s, - ); - expect(output.join('\n')).toMatch(/full CI skipped/i); - }); - - it('checks Node before inspecting or creating anything', async () => { - let inspected = false; - - await expect( - verifyPullRequest( - { base: 'HEAD', cwd: '/caller', requestedProfile: 'full' }, - { - inspect: () => { - inspected = true; - }, - nodeVersion: '20.19.0', - temporaryWorktree: () => { - throw new Error('must not create a worktree'); - }, - }, - ), - ).rejects.toThrow(/Node 22/); - expect(inspected).toBe(false); - }); - - it('rejects a dirty caller before creating the temporary worktree', async () => { - const cwd = createRepository(); - writeFileSync(path.join(cwd, 'source.js'), 'dirty\n'); - let created = false; - - await expect( - verifyPullRequest( - { base: 'missing-ref', cwd, requestedProfile: 'full' }, - { - nodeVersion: '22.17.0', - temporaryWorktree: () => { - created = true; - }, - }, - ), - ).rejects.toThrow(/working tree/); - expect(created).toBe(false); - }); - - it('checks only existing regular changed files with Prettier', async () => { - const worktree = mkdtempSync(path.join(tmpdir(), 'verify-pr-worktree-')); - tempDirs.push(worktree); - const outside = mkdtempSync(path.join(tmpdir(), 'verify-pr-outside-')); - tempDirs.push(outside); - mkdirSync(path.join(worktree, 'docs')); - mkdirSync(path.join(worktree, 'tracked-directory')); - writeFileSync(path.join(worktree, 'source.js'), 'export {};\n'); - writeFileSync(path.join(worktree, 'replacement.js'), 'export {};\n'); - writeFileSync(path.join(worktree, 'docs', 'path with spaces.md'), 'doc\n'); - writeFileSync(path.join(worktree, '-leading-option.md'), 'option\n'); - writeFileSync(path.join(outside, 'external.js'), 'export {};\n'); - symlinkSync( - path.join(outside, 'external.js'), - path.join(worktree, 'link.js'), - ); - symlinkSync(outside, path.join(worktree, 'linked-directory')); - symlinkSync('loop.js', path.join(worktree, 'loop.js')); - const execution = await captureValidation({ - changedFiles: [ - 'source.js', - 'replacement.js/deleted.js', - 'replacement.js', - 'deleted.js', - 'tracked-directory', - 'docs/path with spaces.md', - '-leading-option.md', - 'link.js', - 'linked-directory/external.js', - 'loop.js', - ], - worktree, - }); - - expect( - execution.steps.find(({ name }) => name === 'Run Prettier').command, - ).toEqual([ - 'npx', - 'prettier', - '--experimental-cli', - '--check', - '--ignore-unknown', - '--', - 'source.js', - 'replacement.js', - 'docs/path with spaces.md', - '-leading-option.md', - ]); - }); - - it('omits Prettier when all changed paths are deleted or directories', async () => { - const worktree = mkdtempSync(path.join(tmpdir(), 'verify-pr-worktree-')); - tempDirs.push(worktree); - mkdirSync(path.join(worktree, 'tracked-directory')); - const execution = await captureValidation({ - changedFiles: ['deleted.js', 'tracked-directory'], - worktree, - }); - - expect(execution.steps.map(({ name }) => name)).not.toContain( - 'Run Prettier', - ); - }); - - it('runs Python-expanded validation steps only in the temporary worktree', async () => { - const worktree = mkdtempSync(path.join(tmpdir(), 'verify-pr-worktree-')); - tempDirs.push(worktree); - mkdirSync(path.join(worktree, 'packages', 'sdk-python', 'src'), { - recursive: true, - }); - writeFileSync( - path.join(worktree, 'packages', 'sdk-python', 'src', 'client.py'), - 'pass\n', - ); - const execution = await captureValidation({ - baseEnv: { RUNNER_TEMP: '/caller/temp', SAFE: 'kept' }, - changedFiles: ['packages/sdk-python/src/client.py'], - worktree, - }); - - expect(execution.cwd).toBe(worktree); - expect(execution.home).toBe('/owned/home'); - expect(execution.baseEnv).toMatchObject({ - RUNNER_TEMP: '/owned/container', - SAFE: 'kept', - TEMP: '/owned/tmp', - TMP: '/owned/tmp', - TMPDIR: '/owned/tmp', - }); - const pythonSteps = execution.steps.filter( - ({ name, uvRequirement }) => uvRequirement || name.includes('(Python '), - ); - expect(pythonSteps).toHaveLength(22); - expect(execution.steps[0].command.join(' ')).toBe( - 'npm ci --prefer-offline --no-audit --progress=false --ignore-scripts=false', - ); - expect(pythonSteps[0].command).toEqual(['uv', '--version']); - }); -}); diff --git a/scripts/verify-pr.js b/scripts/verify-pr.js deleted file mode 100644 index f91b6ae699..0000000000 --- a/scripts/verify-pr.js +++ /dev/null @@ -1,982 +0,0 @@ -#!/usr/bin/env node - -/** - * @license - * Copyright 2026 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { spawn, spawnSync } from 'node:child_process'; -import { - lstatSync, - mkdirSync, - mkdtempSync, - realpathSync, - rmSync, -} from 'node:fs'; -import { createServer } from 'node:net'; -import { constants as osConstants, tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { classifyChangedFiles } from '../.github/scripts/ci/classify-profile.mjs'; - -const SETTINGS_SCHEMA_PATH = - 'packages/vscode-ide-companion/schemas/settings.schema.json'; -const SUPPORTED_HOSTS = new Set(['darwin/arm64', 'darwin/x64', 'linux/x64']); -const PASSTHROUGH_ENV_KEYS = new Set([ - 'ALL_PROXY', - 'HTTP_PROXY', - 'HTTPS_PROXY', - 'NODE_EXTRA_CA_CERTS', - 'NO_PROXY', - 'PATH', - 'QWEN_CI_REAL_GIT', - 'RUNNER_TEMP', - 'SSL_CERT_DIR', - 'SSL_CERT_FILE', - 'TEMP', - 'TMP', - 'TMPDIR', - 'all_proxy', - 'http_proxy', - 'https_proxy', - 'no_proxy', -]); - -export function parseArgs(argv) { - const options = { - base: 'origin/main', - help: false, - profile: 'full', - }; - - for (let index = 0; index < argv.length; index += 1) { - const option = argv[index]; - if (option === '--help') { - options.help = true; - continue; - } - - if (option !== '--base' && option !== '--profile') { - throw new Error(`Unknown option or positional argument: ${option}`); - } - - const value = argv[++index]; - if (!value || value.startsWith('--')) { - throw new Error(`Missing value for ${option}`); - } - - if (option === '--base') options.base = value; - if (option === '--profile') { - if (value !== 'full' && value !== 'auto') { - throw new Error(`Invalid profile: ${value}`); - } - options.profile = value; - } - } - - return options; -} - -export function assertNode22(version) { - if (Number.parseInt(version, 10) !== 22) { - throw new Error(`Node 22 is required; found ${version}`); - } -} - -export function assertSupportedHost( - platform = process.platform, - arch = process.arch, -) { - if (!SUPPORTED_HOSTS.has(`${platform}/${arch}`)) { - throw new Error( - `npm run verify:pr supports macOS x64/ARM64 and Linux x64; found ${platform}/${arch}. Use GitHub CI for unsupported hosts.`, - ); - } -} - -function createBaseEnvironment(baseEnv) { - const env = {}; - for (const [key, value] of Object.entries(baseEnv)) { - if (PASSTHROUGH_ENV_KEYS.has(key) && value !== undefined) env[key] = value; - } - return { ...env, LANG: 'C', LC_ALL: 'C', TZ: 'UTC' }; -} - -export function createGitEnvironment(baseEnv, { home, hooksPath } = {}) { - const env = createBaseEnvironment(baseEnv); - Object.assign(env, { - GIT_CONFIG_GLOBAL: '/dev/null', - GIT_CONFIG_NOSYSTEM: '1', - GIT_CONFIG_SYSTEM: '/dev/null', - }); - if (home) { - env.HOME = home; - env.USERPROFILE = home; - } - if (hooksPath) { - Object.assign(env, { - GIT_CONFIG_COUNT: '1', - GIT_CONFIG_KEY_0: 'core.hooksPath', - GIT_CONFIG_VALUE_0: hooksPath, - }); - } - return env; -} - -function runGit(cwd, args, baseEnv) { - const result = spawnSync('git', args, { - cwd, - encoding: 'utf8', - env: createGitEnvironment(baseEnv), - }); - if (result.error) throw result.error; - if (result.status !== 0) { - throw new Error( - result.stderr.trim() || `git ${args.join(' ')} exited with failure`, - ); - } - return result.stdout; -} - -export function inspectRepository({ base, cwd, env = process.env }) { - const status = runGit( - cwd, - ['status', '--porcelain=v1', '-z', '--untracked-files=all'], - env, - ); - if (status) { - throw new Error( - 'The caller working tree must have no staged, unstaged, or untracked changes.', - ); - } - - const head = runGit(cwd, ['rev-parse', 'HEAD'], env).trim(); - let baseSha; - try { - baseSha = runGit( - cwd, - ['rev-parse', '--verify', '--end-of-options', `${base}^{commit}`], - env, - ).trim(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Unable to resolve base ref ${base}: ${message}`); - } - const mergeBase = runGit(cwd, ['merge-base', baseSha, head], env).trim(); - const changedFiles = runGit( - cwd, - ['diff', '--name-only', '--no-renames', '-z', mergeBase, head, '--'], - env, - ) - .split('\0') - .filter(Boolean); - - if (changedFiles.length === 0) { - throw new Error(`No committed changes found against ${base}.`); - } - - return { baseSha, changedFiles, head, mergeBase }; -} - -export function selectProfile({ - changedFiles, - classify = classifyChangedFiles, - requestedProfile, -}) { - return requestedProfile === 'auto' ? classify(changedFiles) : 'full'; -} - -function step(name, ...command) { - return { command, name }; -} - -const GITHUB_HELPER_TEST = [ - 'Run GitHub CI helper tests', - 'node', - '--test', - '.github/scripts/pr-safety-precheck.test.mjs', - '.github/scripts/ci/classify-profile.test.mjs', - '.github/scripts/resolve-sandbox-image.test.mjs', -]; - -function isRegularWorktreeFile(worktree, file) { - let currentPath = worktree; - let stats; - const segments = file.split('/'); - - for (const [index, segment] of segments.entries()) { - currentPath = join(currentPath, segment); - stats = lstatSync(currentPath, { throwIfNoEntry: false }); - if (!stats || stats.isSymbolicLink()) return false; - if (index < segments.length - 1 && !stats.isDirectory()) return false; - } - - return stats?.isFile() === true; -} - -export function createValidationSteps({ prettierFiles = [], profile }) { - if (profile === 'docs_only') return []; - const commands = - profile === 'github_ci_only' - ? [ - ['Set up linters', 'node', 'scripts/lint.js', '--setup'], - ['Run actionlint', 'node', 'scripts/lint.js', '--actionlint'], - ['Run yamllint', 'node', 'scripts/lint.js', '--yamllint'], - GITHUB_HELPER_TEST, - ] - : [ - [ - 'Install dependencies', - 'npm', - 'ci', - '--prefer-offline', - '--no-audit', - '--progress=false', - '--ignore-scripts=false', - ], - [ - 'Audit critical runtime dependencies', - 'npm', - 'run', - 'audit:runtime:critical', - ], - ['Check lockfile', 'npm', 'run', 'check:lockfile'], - [ - 'Check desktop workspace isolation', - 'npm', - 'run', - 'check:desktop-isolation', - ], - ['Set up linters', 'node', 'scripts/lint.js', '--setup'], - ['Run ESLint', 'node', 'scripts/lint.js', '--eslint'], - ['Run actionlint', 'node', 'scripts/lint.js', '--actionlint'], - ['Run shellcheck', 'node', 'scripts/lint.js', '--shellcheck'], - ['Run yamllint', 'node', 'scripts/lint.js', '--yamllint'], - GITHUB_HELPER_TEST, - ...(prettierFiles.length > 0 - ? [ - [ - 'Run Prettier', - 'npx', - 'prettier', - '--experimental-cli', - '--check', - '--ignore-unknown', - '--', - ...prettierFiles, - ], - ] - : []), - ['Run i18n check', 'npm', 'run', 'check-i18n'], - [ - 'Check settings schema', - 'npm', - 'run', - 'generate:settings-schema', - '--', - '--check', - ], - [ - 'Ensure settings schema is committed', - 'git', - 'cat-file', - '-e', - `HEAD:${SETTINGS_SCHEMA_PATH}`, - ], - [ - 'Check committed settings schema', - 'git', - 'diff', - '--exit-code', - 'HEAD', - '--', - SETTINGS_SCHEMA_PATH, - ], - ['Run typecheck', 'npm', 'run', 'typecheck'], - [ - 'Check serve fast-path bundle closure', - 'npm', - 'run', - 'check:serve-fast-path-bundle', - ], - [ - 'Run unit tests', - 'npx', - 'cross-env', - 'NODE_OPTIONS=--max-old-space-size=3072', - 'npm', - 'run', - 'test:ci', - '--workspaces', - '--if-present', - '--', - '--no-file-parallelism', - ], - [ - 'Run script tests', - 'npm', - 'run', - 'test:scripts', - '--', - '--no-file-parallelism', - ], - [ - 'Run no-AK integration tests', - 'npm', - 'run', - 'test:integration:no-ak:sandbox:none', - ], - [ - 'Install Playwright Chromium', - 'npx', - 'playwright', - 'install', - 'chromium', - ], - [ - 'Run web shell smoke tests', - 'npm', - 'run', - 'test:e2e:smoke', - '--workspace=packages/web-shell', - ], - ]; - const steps = commands.map(([name, ...command]) => step(name, ...command)); - if (profile === 'full') { - steps.find( - ({ name }) => name === 'Install dependencies', - ).installEnvironment = true; - for (const name of [ - 'Install Playwright Chromium', - 'Run web shell smoke tests', - ]) { - steps.find((candidate) => candidate.name === name).playwrightEnvironment = - true; - } - steps.find(({ name }) => name === 'Run web shell smoke tests').playwright = - true; - } - return steps; -} - -export function needsPythonChecks(changedFiles) { - return changedFiles.some((file) => { - const normalized = file.replace(/\\/g, '/'); - return ( - normalized.startsWith('packages/sdk-python/') || - normalized === '.github/workflows/sdk-python.yml' - ); - }); -} - -export function getVenvPythonPath(venv, platform = process.platform) { - return platform === 'win32' - ? join(venv, 'Scripts', 'python.exe') - : join(venv, 'bin', 'python'); -} - -export function createPythonSteps({ platform = process.platform, pythonRoot }) { - const steps = [ - { - ...step('Require uv', 'uv', '--version'), - uvRequirement: true, - }, - ]; - - for (const version of ['3.10', '3.11', '3.12']) { - const venv = join(pythonRoot, version); - const python = getVenvPythonPath(venv, platform); - const commands = [ - ['Create virtualenv', 'uv', 'venv', '--python', version, '--seed', venv], - ['Upgrade pip', python, '-m', 'pip', 'install', '--upgrade', 'pip'], - [ - 'Install SDK test dependencies', - python, - '-m', - 'pip', - 'install', - '-e', - 'packages/sdk-python[dev]', - ], - [ - 'Run Ruff', - python, - '-m', - 'ruff', - 'check', - '--config', - 'packages/sdk-python/pyproject.toml', - 'packages/sdk-python', - ], - [ - 'Run Ruff format', - python, - '-m', - 'ruff', - 'format', - '--check', - '--config', - 'packages/sdk-python/pyproject.toml', - 'packages/sdk-python', - ], - [ - 'Run Mypy', - python, - '-m', - 'mypy', - '--config-file', - 'packages/sdk-python/pyproject.toml', - 'packages/sdk-python/src', - ], - [ - 'Run Pytest', - python, - '-m', - 'pytest', - '-c', - 'packages/sdk-python/pyproject.toml', - 'packages/sdk-python/tests', - '-q', - ], - ]; - steps.push( - ...commands.map(([name, ...command]) => - step(`${name} (Python ${version})`, ...command), - ), - ); - } - - for (const pythonStep of steps) { - pythonStep.pythonEnvironment = true; - } - return steps; -} - -export function createStepEnvironment({ - baseEnv, - home, - playwrightPort, - step: currentStep, -}) { - const env = createBaseEnvironment(baseEnv); - Object.assign(env, { - CI: 'true', - GIT_CONFIG_GLOBAL: '/dev/null', - GIT_CONFIG_NOSYSTEM: '1', - GIT_CONFIG_SYSTEM: '/dev/null', - HOME: home, - NPM_CONFIG_CACHE: join(home, '.npm'), - NPM_CONFIG_GLOBAL: 'false', - NPM_CONFIG_GLOBALCONFIG: '/dev/null', - NPM_CONFIG_PREFIX: join(home, '.npm-prefix'), - NPM_CONFIG_UPDATE_NOTIFIER: 'false', - NPM_CONFIG_USERCONFIG: join(home, '.npmrc'), - NO_COLOR: 'true', - USERPROFILE: home, - }); - - if (currentStep.installEnvironment) { - Object.assign(env, { - HUSKY: '0', - NPM_CONFIG_FETCH_RETRIES: '5', - NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: '120000', - NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: '20000', - NPM_CONFIG_FETCH_TIMEOUT: '300000', - NPM_CONFIG_IGNORE_SCRIPTS: 'false', - }); - } - - if (currentStep.pythonEnvironment) { - Object.assign(env, { - PIP_CONFIG_FILE: '/dev/null', - PIP_DISABLE_PIP_VERSION_CHECK: '1', - PIP_NO_INPUT: '1', - PIP_REQUIRE_VIRTUALENV: 'false', - PIP_USER: 'false', - PYTHONNOUSERSITE: '1', - PYTHONUTF8: '1', - UV_NO_CONFIG: '1', - }); - } - - if (currentStep.playwrightEnvironment) { - env.PLAYWRIGHT_BROWSERS_PATH = join(home, 'playwright'); - } - - if (currentStep.playwright) env.PLAYWRIGHT_PORT = String(playwrightPort); - return env; -} - -function quoteArgument(argument) { - const display = argument.replace( - /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/gu, - (character) => `\\u{${character.codePointAt(0).toString(16)}}`, - ); - return /^[A-Za-z0-9_./:=@%+,-]+$/.test(display) - ? display - : `'${display.replaceAll("'", `'"'"'`)}'`; -} - -function formatCommand(command) { - return command.map(quoteArgument).join(' '); -} - -function signalChild(child, signal) { - try { - if (process.platform !== 'win32' && child.pid) { - process.kill(-child.pid, signal); - } else { - child.kill(signal); - } - } catch (error) { - if (error?.code !== 'ESRCH') throw error; - } -} - -export function executeChild({ command, cwd, env }) { - return new Promise((resolveResult) => { - const child = spawn(command[0], command.slice(1), { - cwd, - detached: process.platform !== 'win32', - env, - stdio: 'inherit', - }); - let forwardedSignal; - let settled = false; - const signalHandlers = new Map(); - - const finish = (result) => { - if (settled) return; - settled = true; - for (const [signal, handler] of signalHandlers) { - process.off(signal, handler); - } - resolveResult(result); - }; - - for (const signal of ['SIGINT', 'SIGTERM']) { - const handler = () => { - if (forwardedSignal) { - signalChild(child, 'SIGKILL'); - return; - } - forwardedSignal = signal; - signalChild(child, signal); - }; - signalHandlers.set(signal, handler); - process.on(signal, handler); - } - - child.once('error', (error) => { - finish({ - error, - relaySignal: forwardedSignal, - status: null, - }); - }); - child.once('exit', (status, signal) => { - finish({ - relaySignal: forwardedSignal, - signal, - status, - }); - }); - }); -} - -class ValidationFailure extends Error { - constructor({ command, detail, exitCode, relaySignal, signal, stage }) { - super(`${stage}: ${detail}`); - this.command = command; - this.detail = detail; - this.exitCode = exitCode; - this.relaySignal = relaySignal; - this.signal = signal; - this.stage = stage; - } -} - -function commandFailure(stage, command, result) { - if ( - !result.error && - !result.relaySignal && - !result.signal && - result.status === 0 - ) { - return undefined; - } - const failureSignal = result.signal ?? result.relaySignal; - const signalNumber = failureSignal - ? osConstants.signals[failureSignal] - : undefined; - const exitCode = - typeof result.status === 'number' && result.status !== 0 - ? result.status - : typeof signalNumber === 'number' - ? 128 + signalNumber - : 1; - const detail = result.error - ? `spawn error: ${result.error.message}` - : result.signal - ? `terminated by signal ${result.signal}` - : result.relaySignal - ? `interrupted after forwarding signal ${result.relaySignal}` - : `exited with status ${exitCode}`; - return new ValidationFailure({ - command, - detail, - exitCode, - relaySignal: result.relaySignal, - signal: result.signal ?? undefined, - stage, - }); -} - -export async function runSteps({ - allocatePort, - baseEnv, - cwd, - execute = executeChild, - home, - log = console.log, - now = Date.now, - steps, -}) { - for (const [index, currentStep] of steps.entries()) { - log(`[${index + 1}/${steps.length}] ${currentStep.name}`); - log(`Command: ${formatCommand(currentStep.command)}`); - const startedAt = now(); - let playwrightPort; - if (currentStep.playwright) { - try { - playwrightPort = await allocatePort(); - } catch (error) { - throw new ValidationFailure({ - command: currentStep.command, - detail: `unable to allocate Playwright port: ${error instanceof Error ? error.message : String(error)}`, - exitCode: 1, - stage: currentStep.name, - }); - } - } - const result = await execute({ - command: currentStep.command, - cwd, - env: createStepEnvironment({ - baseEnv, - home, - playwrightPort, - step: currentStep, - }), - }); - log(`Elapsed: ${((now() - startedAt) / 1000).toFixed(1)}s`); - - const failure = commandFailure( - currentStep.name, - currentStep.command, - result, - ); - if (!failure) continue; - if (currentStep.uvRequirement) { - failure.detail = `uv is required for Python SDK verification. Install uv and ensure it is on PATH. ${failure.detail}`; - failure.message = `${failure.stage}: ${failure.detail}`; - } - throw failure; - } -} - -function runWorktreeGit({ args, cwd, env }) { - return spawnSync('git', args, { cwd, env, stdio: 'inherit' }); -} - -export async function withTemporaryWorktree({ - baseEnv = process.env, - cwd, - gitCommand = runWorktreeGit, - head, - makeContainer = () => - mkdtempSync( - join( - realpathSync(process.platform === 'win32' ? tmpdir() : '/tmp'), - 'qwen-verify-pr-', - ), - ), - removeContainer = (container) => - rmSync(container, { recursive: true, force: true }), - reportCleanup = console.error, - validate, -}) { - if (!head) throw new Error('Temporary worktree requires an inspected HEAD.'); - const createdContainer = makeContainer(); - let container = createdContainer; - let gitBaseEnv; - let paths; - let added = false; - const cleanupFailures = []; - let primaryError; - let validationResult; - - try { - container = realpathSync(createdContainer); - paths = { - container, - home: join(container, 'home'), - hooks: join(container, 'hooks'), - pythonRoot: join(container, 'python'), - temp: join(container, 'tmp'), - worktree: join(container, 'worktree'), - }; - for (const directory of [ - paths.home, - paths.hooks, - paths.pythonRoot, - paths.temp, - ]) { - mkdirSync(directory, { recursive: true }); - } - gitBaseEnv = { - ...baseEnv, - RUNNER_TEMP: container, - TEMP: paths.temp, - TMP: paths.temp, - TMPDIR: paths.temp, - }; - const gitEnv = createGitEnvironment(gitBaseEnv, { - home: paths.home, - hooksPath: paths.hooks, - }); - const addCommand = ['worktree', 'add', '--detach', paths.worktree, head]; - const addFailure = commandFailure( - 'Create temporary worktree', - ['git', ...addCommand], - gitCommand({ args: addCommand, cwd, env: gitEnv }), - ); - if (addFailure) throw addFailure; - added = true; - validationResult = await validate(paths); - } catch (error) { - primaryError = error; - } finally { - if (added && paths) { - const removeCommand = [ - 'worktree', - 'remove', - '--force', - '--force', - paths.worktree, - ]; - const cleanupFailure = commandFailure( - 'Clean up temporary worktree', - ['git', ...removeCommand], - gitCommand({ - args: removeCommand, - cwd, - env: createGitEnvironment(gitBaseEnv, { - home: paths.home, - hooksPath: paths.hooks, - }), - }), - ); - if (cleanupFailure) cleanupFailures.push(cleanupFailure); - } - try { - removeContainer(container); - } catch (error) { - cleanupFailures.push( - new ValidationFailure({ - detail: error instanceof Error ? error.message : String(error), - exitCode: 1, - stage: 'Clean up temporary container', - }), - ); - } - } - - if (primaryError) { - for (const failure of cleanupFailures) { - reportCleanup(`Cleanup also failed: ${failure.message}`); - } - throw primaryError; - } - if (cleanupFailures.length > 0) { - for (const failure of cleanupFailures.slice(1)) { - reportCleanup(`Cleanup also failed: ${failure.message}`); - } - throw cleanupFailures[0]; - } - return validationResult; -} - -function allocateFreePort() { - return new Promise((resolve, reject) => { - const server = createServer(); - server.unref(); - server.once('error', reject); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - server.close((error) => { - if (error) reject(error); - else if (typeof address === 'object' && address) resolve(address.port); - else reject(new Error('Unable to allocate a localhost port.')); - }); - }); - }); -} - -export async function verifyPullRequest( - { base, cwd, requestedProfile }, - { - allocatePort = allocateFreePort, - arch = process.arch, - baseEnv = process.env, - error = console.error, - inspect = inspectRepository, - log = console.log, - nodeVersion = process.versions.node, - now = Date.now, - platform = process.platform, - runValidationSteps = runSteps, - temporaryWorktree = withTemporaryWorktree, - } = {}, -) { - const startedAt = now(); - assertNode22(nodeVersion); - assertSupportedHost(platform, arch); - const repository = inspect({ base, cwd, env: baseEnv }); - const profile = selectProfile({ - changedFiles: repository.changedFiles, - requestedProfile, - }); - - log(`Base: ${base}`); - log(`HEAD: ${repository.head}`); - log(`Changed files: ${repository.changedFiles.length}`); - log(`Profile: ${profile}`); - - if (profile === 'docs_only') { - log('Docs-only change; full CI skipped.'); - log(`Total elapsed: ${((now() - startedAt) / 1000).toFixed(1)}s`); - return { ...repository, profile }; - } - - try { - await temporaryWorktree({ - baseEnv, - cwd, - head: repository.head, - reportCleanup: error, - validate: async ({ container, home, pythonRoot, temp, worktree }) => { - const prettierFiles = - profile === 'full' - ? repository.changedFiles.filter((file) => - isRegularWorktreeFile(worktree, file), - ) - : []; - const steps = createValidationSteps({ prettierFiles, profile }); - if (profile === 'full' && needsPythonChecks(repository.changedFiles)) { - steps.push(...createPythonSteps({ pythonRoot })); - } - await runValidationSteps({ - allocatePort, - baseEnv: { - ...baseEnv, - RUNNER_TEMP: container, - TEMP: temp, - TMP: temp, - TMPDIR: temp, - }, - cwd: worktree, - home, - log, - now, - steps, - }); - }, - }); - } catch (failure) { - if (failure && typeof failure === 'object') { - failure.base = base; - failure.head = repository.head; - failure.profile = profile; - } - throw failure; - } - - log(`Total elapsed: ${((now() - startedAt) / 1000).toFixed(1)}s`); - return { ...repository, profile }; -} - -const USAGE = `Usage: npm run verify:pr -- [options] - -Options: - --base Base ref (default: origin/main) - --profile Validation profile (default: full) - --help Show this help`; - -export async function runCli( - argv, - { - cwd = process.cwd(), - error = console.error, - log = console.log, - relaySignal = (signal) => process.kill(process.pid, signal), - verify = verifyPullRequest, - } = {}, -) { - let options; - try { - options = parseArgs(argv); - } catch (parseError) { - error( - parseError instanceof Error ? parseError.message : String(parseError), - ); - error(USAGE); - return 1; - } - - if (options.help) { - log(USAGE); - return 0; - } - - try { - await verify({ - base: options.base, - cwd, - requestedProfile: options.profile, - }); - return 0; - } catch (failure) { - const context = failure && typeof failure === 'object' ? failure : {}; - const command = Array.isArray(context.command) - ? formatCommand(context.command) - : 'not started'; - error('PR verification failed.'); - error(`Stage: ${context.stage ?? 'Caller guards'}`); - error(`Command: ${command}`); - error(`HEAD: ${context.head ?? 'unresolved'}`); - error(`Base: ${context.base ?? options.base}`); - error(`Profile: ${context.profile ?? options.profile}`); - error(`Error: ${context.detail ?? context.message ?? String(failure)}`); - error( - `Rerun: npm run verify:pr -- --base ${quoteArgument(options.base)} --profile ${options.profile}`, - ); - if (typeof context.relaySignal === 'string') { - relaySignal(context.relaySignal); - const signalNumber = osConstants.signals[context.relaySignal]; - return typeof signalNumber === 'number' ? 128 + signalNumber : 1; - } - return Number.isInteger(context.exitCode) && context.exitCode > 0 - ? context.exitCode - : 1; - } -} - -if ( - process.argv[1] && - resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)) -) { - process.exitCode = await runCli(process.argv.slice(2)); -}