mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
perf(dev): add pnpm worktree bootstrap foundation (#10449)
* perf(dev): add pnpm worktree bootstrap foundation Co-authored-by: Qwen-Coder <[email protected]> * fix(dev): harden worktree bootstrap process handling Co-authored-by: Qwen-Coder <[email protected]> * perf(dev): prefer cache-only worktree installs Co-authored-by: Qwen-Coder <[email protected]> * fix(dev): preserve cached install cancellation Co-authored-by: Qwen-Coder <[email protected]> * fix(build): harden pnpm worktree bootstrap Validate pnpm lock updates in releases and exercise real installs and builds across supported hosts. Preserve npm release compatibility and keep dependency-only setup from rewriting npm-layout notices. Co-authored-by: Qwen-Coder <[email protected]> * chore: record merge attribution Record the required attribution for the Stage 1 merge without rewriting published history. Co-authored-by: Qwen-Coder <[email protected]> * fix(dev): keep worktree bootstrap clean Use a bootstrap-scoped notice guard because nested npm lifecycle commands replace npm_lifecycle_event. This preserves explicit notice generation while preventing dependency setup from rewriting tracked output. Co-authored-by: Qwen-Coder <[email protected]> * ci: track pnpm smoke workflow size Register the new workflow in the repository size ratchet as required by the main CI gate. Co-authored-by: Qwen-Coder <[email protected]> * fix(ci): drop stale importers from the new pnpm lockfile The lockfile was generated before the WebShell cutover (#9811) removed @qwen-code/webui from web-shell and the tailwind tooling plus @qwen-code/webui from vscode-ide-companion, so frozen-lockfile installs fail on all three smoke platforms with ERR_PNPM_OUTDATED_LOCKFILE. Drop the five stale importer entries so the lockfile matches the current package.json manifests; verified with pnpm 11.24.0 install --frozen-lockfile --lockfile-only. Co-authored-by: Qwen-Coder <[email protected]> * fix(ci): refresh pnpm lockfile after merging main The merge of origin/main added remend@^1.3.1 to packages/cli/package.json without updating pnpm-lock.yaml, breaking the pnpm Worktree Smoke workflow frozen-lockfile install. Regenerated with pnpm install. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-conflict/jmtid3rse9h * fix(scripts): resolve the path variable case-insensitively on Windows A spread of process.env is an ordinary object, and native Windows shells expose the path variable as `Path`, so `env.PATH` was undefined there and findOnPath never located corepack — the offline-first Corepack bootstrap silently degraded to npx on exactly the hosts it exists for. Co-authored-by: Qwen-Coder <[email protected]> * test(scripts): pin the worktree bootstrap guards against mutation - Split the fallback log on /\r?\n/ like the sibling cmd.exe mocks, so the assertion holds on the Windows lane's CRLF output. - Add a win32 variant bootstrapping with the native `Path` casing to pin the case-insensitive lookup. - Assert the smoke workflow's fail-fast flag, the install-before-clean step order, and the no-build guard via a substring on the raw job text. - Extend the pnpmfile rewrite fixture to devDependencies and optionalDependencies, which the committed lockfile already uses. - Assert the notice-skip guard by effect (writeFile never called) and add the flag-absent companion test with I/O stubbed. Co-authored-by: Qwen-Coder <[email protected]> * chore(deps): sync pnpm-lock.yaml with qwen-live ACP backend deps Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-conflict/jmtjyz3tdco * fix(dev): stop patch-package from corrupting the pnpm store The root postinstall runs patch-package, which rewrites files under node_modules in place. With pnpm's default import method those files are hard links into the content-addressable store, so the patch rewrites the store entry too and its contents stop matching the sha512 it is filed under. Every subsequent fresh worktree then fails its --offline stage with ERR_PNPM_NO_OFFLINE_TARBALL, silently falls back to the registry (exit 0), and the fallback's postinstall corrupts the store again, so the offline path never hits. The advertised offline timing was only reachable on a same-tree reinstall, which needs no relinking. Set packageImportMethod to clone-or-copy: a copy-on-write clone where the filesystem supports it, a plain copy elsewhere. Either way patch-package edits only the worktree's own copy and the store entry stays intact, so later worktrees resolve entirely from the store. Verified on an isolated fixture store: under the default method the node_modules file and its store entry share an inode, and an in-place edit moves the store file's sha512 off its address, after which a fresh offline install fails with ERR_PNPM_NO_OFFLINE_TARBALL; under clone-or-copy the same edit leaves the store hash unchanged and the fresh offline install reports reused 1, downloaded 0. Also add pnpm-lock.yaml to .prettierignore. prettier reflows it into a shape pnpm does not emit, so `npm run format` and `pnpm install` fight over the file; .yamllint.yml already ignores it for the same reason. Both facts are pinned by tests in scripts/tests/package-scripts.test.js. Claude-Session: https://claude.ai/code/session_012797rgiteWJxLT9TLkKq8G * fix(ci): refresh pnpm-lock.yaml for qwen-live prompts deps Main added prompts/@types/prompts (and the ACP sdk) to packages/qwen-live/package.json after this branch generated its lockfile, so the pnpm Worktree Smoke frozen-lockfile install failed with ERR_PNPM_OUTDATED_LOCKFILE. Regenerated with pnpm 11.24.0 (--lockfile-only); frozen-lockfile and supply-chain policy checks pass. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-conflict/jmtlc9sozfg * fix(pnpm): close review findings on worktree bootstrap - Declare @qwen-code/qwen-code-core in vscode-ide-companion so the pnpm linker materializes it; regenerate both lockfiles and assert the link in the smoke workflow (R5-1) - Pin @types/node 20.19.1 in packages/core so the pnpm layout compiles against the same types npm hoists (D6-1) - Accept and pin the corepack +sha512 integrity suffix on packageManager; strip it for the npx fallback spec (D6-2) - Cover every workspace member in the pnpmfile rewrite set, mirror the npm channel list in pnpm-workspace.yaml, and cross-check both in tests - Run setup-worktree.js installs with cwd pinned to the checkout, gate the smoke clean-check on git status --porcelain, stop post-merge runs from cancelling each other, and extend check-lockfile.js to pnpm-lock.yaml * merge(main): realign pnpm workspace set and lockfiles Main removed the webui and cua-driver packages; drop both from the pnpm rewrite set and regenerate the pnpm lockfile against the merged manifests (picks up playwright, react-markdown, and the other main-side dependency changes) so the frozen bootstrap matches what the PR merge ref will run. * style(ci): quote smoke workflow scalars per yamllint * fix(dev): require Corepack for pnpm bootstrap * fix(dev): close pnpm bootstrap review blockers --------- Co-authored-by: Qwen-Coder <[email protected]> Co-authored-by: yiliang114 <[email protected]>
This commit is contained in:
20 files changed
+21617
-2
No files matched your search
@@ -31,6 +31,7 @@
|
||||
5950 live-host.yml
|
||||
7642 main-ci-failure-issue.yml
|
||||
1686 npm-cache.yml
|
||||
2489 pnpm-worktree-smoke.yml
|
||||
7299 pr-force-push-reminder.yml
|
||||
6495 pr-self-report-label.yml
|
||||
9646 qwen-autofix-fork-bridge.yml
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
name: 'pnpm Worktree Smoke'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'release/**'
|
||||
paths:
|
||||
- '.github/workflows/pnpm-worktree-smoke.yml'
|
||||
- '.npmrc'
|
||||
- '.pnpmfile.mjs'
|
||||
- 'package.json'
|
||||
- 'packages/*/package.json'
|
||||
- '!packages/desktop-shell/package.json'
|
||||
- '!packages/live-host/package.json'
|
||||
- 'packages/channels/*/package.json'
|
||||
- 'integrations/*/package.json'
|
||||
- 'patches/**'
|
||||
- 'packages/audio-capture/install.js'
|
||||
- 'packages/core/scripts/postinstall.js'
|
||||
- 'packages/vscode-ide-companion/scripts/generate-notices.js'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'scripts/generate-git-commit-info.js'
|
||||
- 'scripts/prepare.js'
|
||||
- 'scripts/pnpm-package.js'
|
||||
- 'scripts/setup-worktree.js'
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
paths:
|
||||
- '.github/workflows/pnpm-worktree-smoke.yml'
|
||||
- '.npmrc'
|
||||
- '.pnpmfile.mjs'
|
||||
- 'package.json'
|
||||
- 'packages/*/package.json'
|
||||
- '!packages/desktop-shell/package.json'
|
||||
- '!packages/live-host/package.json'
|
||||
- 'packages/channels/*/package.json'
|
||||
- 'integrations/*/package.json'
|
||||
- 'patches/**'
|
||||
- 'packages/audio-capture/install.js'
|
||||
- 'packages/core/scripts/postinstall.js'
|
||||
- 'packages/vscode-ide-companion/scripts/generate-notices.js'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'scripts/generate-git-commit-info.js'
|
||||
- 'scripts/prepare.js'
|
||||
- 'scripts/pnpm-package.js'
|
||||
- 'scripts/setup-worktree.js'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
# Post-merge runs must not cancel each other: a cancelled push run would
|
||||
# silently erase the only post-merge witness of a regression.
|
||||
group: '${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}'
|
||||
cancel-in-progress: "${{ github.event_name == 'pull_request' }}"
|
||||
|
||||
permissions:
|
||||
contents: 'read'
|
||||
|
||||
jobs:
|
||||
install:
|
||||
name: 'Install (${{ matrix.os }})'
|
||||
runs-on: '${{ matrix.os }}'
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- 'ubuntu-latest'
|
||||
- 'macos-latest'
|
||||
- 'windows-latest'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
|
||||
- name: 'Set up Node.js 22.x'
|
||||
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
||||
with:
|
||||
node-version: '22.x'
|
||||
|
||||
- name: 'Install frozen pnpm worktree dependencies'
|
||||
run: 'node scripts/setup-worktree.js'
|
||||
|
||||
- name: 'Verify workspace links resolve'
|
||||
shell: 'bash'
|
||||
run: >-
|
||||
node -e "require.resolve('@qwen-code/qwen-code-core/package.json',
|
||||
{ paths: ['packages/vscode-ide-companion'] })"
|
||||
|
||||
- name: 'Ensure bootstrap keeps the worktree clean'
|
||||
shell: 'bash'
|
||||
run: |
|
||||
status="$(git status --porcelain)"
|
||||
if [ -n "$status" ]; then
|
||||
echo 'Bootstrap left the worktree dirty:'
|
||||
echo "$status"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Every pnpm workspace member name, so any spelling of an internal
|
||||
// dependency (file:, an exact release version, or *) rewrites to
|
||||
// workspace:*. scripts/tests/package-scripts.test.js pins this set against
|
||||
// the workspace manifests, so a newly added package fails that test until
|
||||
// it is listed here.
|
||||
export const workspacePackageNames = new Set([
|
||||
'@qwen-code/acp-bridge',
|
||||
'@qwen-code/audio-capture',
|
||||
'@qwen-code/channel-base',
|
||||
'@qwen-code/channel-dingtalk',
|
||||
'@qwen-code/channel-dws',
|
||||
'@qwen-code/channel-feishu',
|
||||
'@qwen-code/channel-github',
|
||||
'@qwen-code/channel-gitlab',
|
||||
'@qwen-code/channel-plugin-example',
|
||||
'@qwen-code/channel-qqbot',
|
||||
'@qwen-code/channel-telegram',
|
||||
'@qwen-code/channel-wecom',
|
||||
'@qwen-code/channel-weixin',
|
||||
'@qwen-code/chrome-bridge',
|
||||
'@qwen-code/external-context',
|
||||
'@qwen-code/external-context-mem0',
|
||||
'@qwen-code/mobile-mcp',
|
||||
'@qwen-code/node-repl-mcp',
|
||||
'@qwen-code/qwen-code',
|
||||
'@qwen-code/qwen-code-core',
|
||||
'@qwen-code/qwen-live',
|
||||
'@qwen-code/sdk',
|
||||
'@qwen-code/web-shell',
|
||||
'@qwen-code/web-templates',
|
||||
'qwen-code-vscode-ide-companion',
|
||||
]);
|
||||
|
||||
const dependencyFields = [
|
||||
'dependencies',
|
||||
'devDependencies',
|
||||
'optionalDependencies',
|
||||
];
|
||||
|
||||
export const hooks = {
|
||||
readPackage(packageJson) {
|
||||
for (const field of dependencyFields) {
|
||||
const dependencies = packageJson[field];
|
||||
if (!dependencies) continue;
|
||||
|
||||
for (const name of Object.keys(dependencies)) {
|
||||
if (workspacePackageNames.has(name)) {
|
||||
dependencies[name] = 'workspace:*';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return packageJson;
|
||||
},
|
||||
};
|
||||
@@ -19,6 +19,10 @@ eslint.config.js
|
||||
**/generated
|
||||
gha-creds-*.json
|
||||
junit.xml
|
||||
# Generated by pnpm; prettier reflows it into a shape pnpm does not emit,
|
||||
# so formatting and `pnpm install` fight over the file. .yamllint.yml
|
||||
# already ignores it for the same reason.
|
||||
pnpm-lock.yaml
|
||||
Thumbs.db
|
||||
packages/vscode-ide-companion/schemas/settings.schema.json
|
||||
packages/cli/src/services/insight/templates/insightTemplate.ts
|
||||
|
||||
@@ -83,6 +83,7 @@ rules:
|
||||
check-keys: true
|
||||
|
||||
ignore:
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'thirdparty/'
|
||||
- 'third_party/'
|
||||
- 'vendor/'
|
||||
|
||||
@@ -87,6 +87,16 @@ the run and names the fix; build once from the repository root:
|
||||
npm run build
|
||||
```
|
||||
|
||||
**pnpm worktree bootstrap (opt-in):** an additional Git worktree can install
|
||||
dependencies with `node scripts/setup-worktree.js`, which runs the pinned
|
||||
pnpm with `--frozen-lockfile` (warm store ≈ 99 MiB on copy-on-write
|
||||
filesystems such as APFS, btrfs, and XFS with reflink; without reflink it is
|
||||
≈ 1.2 GiB). npm remains the authoritative path for build, CI, packaging, and
|
||||
release; the pnpm layout is install-only for now. When dependencies change,
|
||||
regenerate the pnpm lockfile with
|
||||
`corepack pnpm install --lockfile-only --no-frozen-lockfile` and commit both
|
||||
lockfiles together.
|
||||
|
||||
**Run individual test files** (always preferred):
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# pnpm worktree bootstrap
|
||||
|
||||
## Problem
|
||||
|
||||
Every npm-backed Git worktree materializes another full dependency tree. The
|
||||
root `prepare` lifecycle also builds and bundles the repository unless the
|
||||
caller knows to set `QWEN_SKIP_PREPARE`, so a worktree pays for generated
|
||||
artifacts before source-based development needs them.
|
||||
|
||||
On the same commit and APFS volume, a warm-cache npm install added about 1.44
|
||||
GiB while a warm-store pnpm install added about 99 MiB. The dependency-only
|
||||
installs took 27 and 22 seconds respectively; a separate full build took about
|
||||
129 seconds.
|
||||
|
||||
## Design
|
||||
|
||||
The repository declares pnpm as its package manager and mirrors the existing
|
||||
npm workspace boundaries in `pnpm-workspace.yaml`. A hoisted linker is used
|
||||
for the initial migration because current build and packaging scripts still
|
||||
contain assumptions inherited from npm's layout.
|
||||
|
||||
The committed pnpm lockfile is the source of dependency resolution for this
|
||||
bootstrap only. pnpm-specific overrides preserve the dependency versions used
|
||||
by the current npm installation without changing npm's manifest or lockfile.
|
||||
Two versions cannot be preserved by overrides alone and are pinned in
|
||||
manifests instead: `packages/core` declares the exact `@types/node` release
|
||||
it compiles against under npm (20.19.1, previously supplied only by
|
||||
hoisting), and every package `vscode-ide-companion` imports at runtime is
|
||||
declared in its manifest so the pnpm linker materializes it.
|
||||
Dependency install scripts are denied unless they are explicitly listed in
|
||||
`allowBuilds`; the allowlist contains only packages whose scripts run in the
|
||||
current npm installation.
|
||||
|
||||
During the dual-lock transition, `.pnpmfile.mjs` rewrites known internal
|
||||
dependencies to pnpm's `workspace:*` protocol in memory. This covers both the
|
||||
existing `file:` dependencies and the exact channel dependency versions that
|
||||
the release script updates. As a result, release version bumps do not stale the
|
||||
pnpm lockfile, while the checked-in manifests and npm lockfile remain untouched.
|
||||
The compatibility hook can be removed when the manifests adopt `workspace:`
|
||||
during the final cutover.
|
||||
|
||||
New worktrees use `node scripts/setup-worktree.js`. The script uses Corepack so
|
||||
an existing pnpm cache can stay fully offline and the integrity-pinned pnpm
|
||||
package declared by `packageManager` is verified before execution. It fails
|
||||
closed when Corepack is unavailable; Node versions that no longer bundle it
|
||||
must install it separately. The script freezes the lockfile and first
|
||||
attempts an offline install from the shared local store. It retries with
|
||||
registry access only when that cache-only attempt is incomplete.
|
||||
This avoids waiting for pnpm to prefetch optional binaries for other platforms
|
||||
on the common warm-store path. The script sets `QWEN_SKIP_PREPARE=1` plus a
|
||||
bootstrap-private notice-generation guard, keeping dependency install scripts
|
||||
enabled while skipping repository build, bundle, Husky setup, and npm-layout
|
||||
notice generation. Script execution does not
|
||||
implicitly install stale dependencies; the bootstrap command is the explicit
|
||||
installation boundary. Building from this pnpm layout is deferred to Stage 2.
|
||||
|
||||
## Migration boundary
|
||||
|
||||
Stage 1 applies only to dependency installation in additional Git worktrees.
|
||||
It does not change repository build commands, CI build orchestration, release
|
||||
versioning, packaging, or publishing. `package-lock.json` remains authoritative
|
||||
for every existing npm path. A path-filtered workflow exercises the real frozen
|
||||
bootstrap on Linux, macOS, and Windows whenever a pnpm installation input
|
||||
changes. The generated pnpm lockfile is excluded from the repository's
|
||||
human-authored YAML style rules.
|
||||
|
||||
Stage 2 can separately make the pnpm layout a supported build and development
|
||||
path, then migrate CI installation and caches. Stage 3 can address release
|
||||
installation and build orchestration. npm remains the product boundary for
|
||||
package creation, registry publication, and clean artifact installation until
|
||||
the scripts that intentionally read `package-lock.json` are migrated.
|
||||
|
||||
## Verification
|
||||
|
||||
The Stage 1 pnpm path must complete a frozen install without modifying tracked
|
||||
files. Script tests cover the bootstrap command, its prepare-skip environment,
|
||||
the version-independent workspace rewrite, and process failure behavior. The
|
||||
cross-platform workflow provides the real install gate. Building from the pnpm
|
||||
layout is intentionally deferred to Stage 2; existing npm build and release
|
||||
validation remain unchanged.
|
||||
Generated
+3
@@ -27241,6 +27241,7 @@
|
||||
"@qwen-code/qwen-code-core": "file:../core"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.19.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
@@ -27950,6 +27951,7 @@
|
||||
"@types/dotenv": "^6.1.1",
|
||||
"@types/fast-levenshtein": "^0.0.4",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/node": "20.19.1",
|
||||
"@types/picomatch": "^4.0.1",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/tar": "^6.1.13",
|
||||
@@ -30949,6 +30951,7 @@
|
||||
"@agentclientprotocol/sdk": "^0.14.1",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@qwen-code/acp-bridge": "*",
|
||||
"@qwen-code/qwen-code-core": "*",
|
||||
"@qwen-code/sdk": "*",
|
||||
"@qwen-code/web-shell": "*",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.23.0",
|
||||
"packageManager": "[email protected]+sha512.bd27e345e976dcb0be0b7a1228217b049a817e21b1f355c90dbe7dc46671895a8bc1e6d06c24554505ea93ea0b45f489a27ec1bfbc8de6a9659fca0f16fa0000",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
|
||||
@@ -159,6 +159,7 @@
|
||||
"@qwen-code/qwen-code-core": "file:../core"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.19.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
"@types/dotenv": "^6.1.1",
|
||||
"@types/fast-levenshtein": "^0.0.4",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/node": "20.19.1",
|
||||
"@types/picomatch": "^4.0.1",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/tar": "^6.1.13",
|
||||
|
||||
@@ -288,6 +288,7 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.14.1",
|
||||
"@qwen-code/acp-bridge": "*",
|
||||
"@qwen-code/qwen-code-core": "*",
|
||||
"@qwen-code/sdk": "*",
|
||||
"@qwen-code/web-shell": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
|
||||
@@ -506,11 +506,26 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runNoticeGeneration(env = process.env) {
|
||||
const skipGeneration = ['1', 'true'].includes(
|
||||
(env.QWEN_SKIP_NOTICE_GENERATION ?? '').toLowerCase(),
|
||||
);
|
||||
|
||||
if (skipGeneration) {
|
||||
console.log(
|
||||
'Skipping VS Code notice generation during worktree bootstrap.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await main();
|
||||
}
|
||||
|
||||
// Only run when executed directly (e.g. `npm run generate:notices`), not when
|
||||
// imported by tests.
|
||||
if (
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
) {
|
||||
main().catch(console.error);
|
||||
runNoticeGeneration().catch(console.error);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -15,8 +15,65 @@ import {
|
||||
findSupplementaryLicenseFiles,
|
||||
getFallbackLicenseText,
|
||||
normalizeRepositoryUrl,
|
||||
runNoticeGeneration,
|
||||
} from './generate-notices.js';
|
||||
|
||||
describe('runNoticeGeneration', () => {
|
||||
it('skips generation during dependency-only worktree setup', async () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
// Stubbed no-op so a regression that drops the early return rewrites the
|
||||
// tracked NOTICES.txt instead of merely failing this assertion.
|
||||
const write = vi.spyOn(fs, 'writeFile').mockImplementation(async () => {});
|
||||
|
||||
try {
|
||||
await runNoticeGeneration({
|
||||
npm_lifecycle_event: 'generate:notices',
|
||||
QWEN_SKIP_NOTICE_GENERATION: '1',
|
||||
});
|
||||
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
'Skipping VS Code notice generation during worktree bootstrap.',
|
||||
);
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
write.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('still generates notices when the skip flag is absent', async () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
// Empty dependency graph: main() reaches the write without touching the
|
||||
// real node_modules, and a failing main() cannot process.exit the worker.
|
||||
const read = vi.spyOn(fs, 'readFile').mockImplementation(async (file) => {
|
||||
const name = String(file);
|
||||
if (name.endsWith('package.json')) {
|
||||
return JSON.stringify({ dependencies: {} });
|
||||
}
|
||||
if (name.endsWith('package-lock.json')) {
|
||||
return JSON.stringify({ packages: {} });
|
||||
}
|
||||
throw Object.assign(new Error(`ENOENT: ${name}`), { code: 'ENOENT' });
|
||||
});
|
||||
const write = vi.spyOn(fs, 'writeFile').mockImplementation(async () => {});
|
||||
|
||||
try {
|
||||
await runNoticeGeneration({ npm_lifecycle_event: 'generate:notices' });
|
||||
|
||||
expect(write).toHaveBeenCalledTimes(1);
|
||||
expect(String(write.mock.calls[0]?.[1])).toContain(
|
||||
'third-party software notices and license terms',
|
||||
);
|
||||
} finally {
|
||||
log.mockRestore();
|
||||
error.mockRestore();
|
||||
read.mockRestore();
|
||||
write.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findLicenseFile', () => {
|
||||
let packageDir;
|
||||
|
||||
|
||||
Generated
+20603
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,61 @@
|
||||
# Mirrors the workspaces list in package.json exactly; a new channel must be
|
||||
# added to both files, the same as under npm. A glob here would let a
|
||||
# channel join the pnpm layout silently while npm still ignores it.
|
||||
packages:
|
||||
- 'packages/*'
|
||||
- 'packages/channels/base'
|
||||
- 'packages/channels/dingtalk'
|
||||
- 'packages/channels/dws'
|
||||
- 'packages/channels/feishu'
|
||||
- 'packages/channels/github'
|
||||
- 'packages/channels/gitlab'
|
||||
- 'packages/channels/plugin-example'
|
||||
- 'packages/channels/qqbot'
|
||||
- 'packages/channels/telegram'
|
||||
- 'packages/channels/wecom'
|
||||
- 'packages/channels/weixin'
|
||||
- 'integrations/external-context'
|
||||
- 'integrations/external-context-mem0'
|
||||
- '!packages/desktop-shell'
|
||||
- '!packages/live-host'
|
||||
|
||||
nodeLinker: 'hoisted'
|
||||
# The root postinstall runs patch-package, which rewrites files inside
|
||||
# node_modules in place. Under the default import method those files are
|
||||
# hard links into the content-addressable store, so the patch also rewrites
|
||||
# the store entry -- its contents stop matching the sha512 it is filed
|
||||
# under. Every later worktree then fails its --offline stage with
|
||||
# ERR_PNPM_NO_OFFLINE_TARBALL, falls back to the registry, and re-patches
|
||||
# the store, so the offline path never hits again. Cloning (copy-on-write
|
||||
# where the filesystem supports it, a plain copy elsewhere) keeps the store
|
||||
# entry untouched while patch-package edits only the worktree's own copy.
|
||||
packageImportMethod: 'clone-or-copy'
|
||||
linkWorkspacePackages: true
|
||||
verifyDepsBeforeRun: false
|
||||
|
||||
overrides:
|
||||
typescript: '5.8.3'
|
||||
'@microsoft/api-extractor>typescript': '5.9.3'
|
||||
'@qwen-code/mobile-mcp>typescript': '5.8.2'
|
||||
ansi-regex: '6.2.2'
|
||||
'cliui>wrap-ansi': '7.0.0'
|
||||
baseline-browser-mapping: '^2.9.19'
|
||||
normalize-package-data: '^7.0.1'
|
||||
react: '^19.2.4'
|
||||
react-dom: '^19.2.4'
|
||||
'@types/react': '^19.2.0'
|
||||
'@types/react-dom': '^19.2.0'
|
||||
axios: '^1.19.0'
|
||||
react-markdown: '^9.0.0'
|
||||
|
||||
allowBuilds:
|
||||
'@google/genai': true
|
||||
'@qwen-code/qwen-code-core@file:packages/core': true
|
||||
'@vscode/vsce-sign': true
|
||||
esbuild: true
|
||||
keytar: true
|
||||
msw: true
|
||||
protobufjs: true
|
||||
|
||||
minimumReleaseAgeExclude:
|
||||
- '@qwen-code/channel-base'
|
||||
@@ -8,6 +8,8 @@ import fs from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
const lockfilePath = join(root, 'package-lock.json');
|
||||
@@ -72,3 +74,40 @@ if (invalidPackages.length > 0) {
|
||||
console.log('Lockfile check passed.');
|
||||
process.exitCode = 0;
|
||||
}
|
||||
|
||||
console.log('Checking pnpm lockfile...');
|
||||
|
||||
const pnpmLockfilePath = join(root, 'pnpm-lock.yaml');
|
||||
let pnpmLockfile;
|
||||
try {
|
||||
pnpmLockfile = parseYaml(fs.readFileSync(pnpmLockfilePath, 'utf-8'));
|
||||
} catch (error) {
|
||||
console.error(`Error reading or parsing ${pnpmLockfilePath}:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const invalidPnpmPackages = [];
|
||||
for (const [key, details] of Object.entries(pnpmLockfile?.packages ?? {})) {
|
||||
const resolution = details?.resolution ?? {};
|
||||
// Registry packages carry a sha512 integrity hash; git and tarball
|
||||
// resolutions identify their source directly, mirroring the npm rules
|
||||
// above.
|
||||
const hasIntegrity =
|
||||
typeof resolution.integrity === 'string' &&
|
||||
resolution.integrity.startsWith('sha512-');
|
||||
const isGitOrTarball =
|
||||
resolution.type === 'git' || typeof resolution.tarball === 'string';
|
||||
if (!hasIntegrity && !isGitOrTarball) {
|
||||
invalidPnpmPackages.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidPnpmPackages.length > 0) {
|
||||
console.error(
|
||||
'\nError: The following dependencies in pnpm-lock.yaml are missing "resolution.integrity":',
|
||||
);
|
||||
invalidPnpmPackages.forEach((pkg) => console.error(`- ${pkg}`));
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('pnpm lockfile check passed.');
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export function getPinnedPnpmPackage(packageJson) {
|
||||
const packageManager = packageJson.packageManager;
|
||||
// `corepack use [email protected]` appends an integrity suffix
|
||||
// (+sha512.<128 hex chars>); accept it so the command corepack itself
|
||||
// writes cannot break the bootstrap, but reject everything else.
|
||||
if (
|
||||
!/^pnpm@\d+\.\d+\.\d+(\+sha512\.[0-9a-f]{128})?$/.test(packageManager ?? '')
|
||||
) {
|
||||
throw new Error('packageManager must pin an exact pnpm version');
|
||||
}
|
||||
|
||||
return packageManager;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { constants as osConstants } from 'node:os';
|
||||
import { delimiter, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { getPinnedPnpmPackage } from './pnpm-package.js';
|
||||
|
||||
const corepack = process.platform === 'win32' ? 'corepack.cmd' : 'corepack';
|
||||
// The script lives in <repo>/scripts, so it bootstraps the checkout it
|
||||
// belongs to no matter which directory the caller runs it from.
|
||||
const rootDir = fileURLToPath(new URL('..', import.meta.url));
|
||||
getPinnedPnpmPackage(
|
||||
JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')),
|
||||
);
|
||||
const env = {
|
||||
...process.env,
|
||||
QWEN_SKIP_PREPARE: '1',
|
||||
QWEN_SKIP_NOTICE_GENERATION: '1',
|
||||
};
|
||||
|
||||
// A spread of process.env is an ordinary object: on Windows the path
|
||||
// variable canonically arrives as `Path`, so a case-sensitive `env.PATH`
|
||||
// read misses it and corepack is never found.
|
||||
function pathValue() {
|
||||
if (process.platform !== 'win32') return env.PATH ?? '';
|
||||
const key = Object.keys(env).find((name) => name.toUpperCase() === 'PATH');
|
||||
return (key !== undefined ? env[key] : env.PATH) ?? '';
|
||||
}
|
||||
|
||||
function findOnPath(command) {
|
||||
for (const entry of pathValue().split(delimiter)) {
|
||||
const directory = entry.replace(/^"(.*)"$/, '$1');
|
||||
const candidate = resolve(directory || '.', command);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const corepackPath = findOnPath(corepack);
|
||||
if (!corepackPath) {
|
||||
console.error(
|
||||
'worktree setup failed: Corepack is required to verify the pinned pnpm package',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function runPnpm(args) {
|
||||
return spawnSync(corepack, ['pnpm', ...args], {
|
||||
cwd: rootDir,
|
||||
env,
|
||||
shell: process.platform === 'win32',
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
function install(cacheMode) {
|
||||
return runPnpm(['install', '--frozen-lockfile', cacheMode]);
|
||||
}
|
||||
|
||||
function exitWithResult(result) {
|
||||
if (result.error) {
|
||||
console.error(`worktree setup failed: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (result.signal) {
|
||||
console.error(`worktree setup killed by signal ${result.signal}`);
|
||||
const signalNumber = osConstants.signals[result.signal];
|
||||
process.exit(signalNumber ? 128 + signalNumber : 1);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
const cachedInstall = install('--offline');
|
||||
if (cachedInstall.status === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (
|
||||
cachedInstall.error ||
|
||||
cachedInstall.signal ||
|
||||
(cachedInstall.status !== null && cachedInstall.status >= 128)
|
||||
) {
|
||||
exitWithResult(cachedInstall);
|
||||
}
|
||||
|
||||
console.warn('Cached install unavailable; retrying with registry access.');
|
||||
exitWithResult(install('--prefer-offline'));
|
||||
@@ -6,7 +6,10 @@
|
||||
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
@@ -16,6 +19,10 @@ import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parse } from 'yaml';
|
||||
|
||||
import { hooks as pnpmHooks, workspacePackageNames } from '../../.pnpmfile.mjs';
|
||||
import { getPinnedPnpmPackage } from '../pnpm-package.js';
|
||||
|
||||
import { getWorkflowJob, getWorkflowStep } from './workflow-helpers.js';
|
||||
|
||||
@@ -31,6 +38,365 @@ function readWorkflow(relativePath) {
|
||||
}
|
||||
|
||||
describe('package scripts', () => {
|
||||
it('accepts only an exact pnpm package-manager version', () => {
|
||||
expect(getPinnedPnpmPackage({ packageManager: '[email protected]' })).toBe(
|
||||
'[email protected]',
|
||||
);
|
||||
// `corepack use [email protected]` appends the tarball's integrity hash; the
|
||||
// bootstrap must accept the string corepack itself writes.
|
||||
const hashed =
|
||||
'[email protected]+sha512.bd27e345e976dcb0be0b7a1228217b049a817e21b1f355c90dbe7dc46671895a8bc1e6d06c24554505ea93ea0b45f489a27ec1bfbc8de6a9659fca0f16fa0000';
|
||||
expect(getPinnedPnpmPackage({ packageManager: hashed })).toBe(hashed);
|
||||
expect(() =>
|
||||
getPinnedPnpmPackage({ packageManager: 'pnpm@latest' }),
|
||||
).toThrow('packageManager must pin an exact pnpm version');
|
||||
expect(() =>
|
||||
getPinnedPnpmPackage({
|
||||
packageManager: '[email protected]+sha512.not-hex',
|
||||
}),
|
||||
).toThrow('packageManager must pin an exact pnpm version');
|
||||
});
|
||||
|
||||
it('pins pnpm with the corepack integrity hash', () => {
|
||||
expect(readPackageJson().packageManager).toMatch(
|
||||
/^pnpm@\d+\.\d+\.\d+\+sha512\.[0-9a-f]{128}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps internal pnpm workspaces independent of manifest versions', () => {
|
||||
const packageJson = {
|
||||
dependencies: {
|
||||
'@qwen-code/qwen-code-core': 'file:../core',
|
||||
'@qwen-code/channel-base': '0.22.4',
|
||||
fixture: 'file:../fixture',
|
||||
},
|
||||
devDependencies: {
|
||||
'@qwen-code/acp-bridge': 'file:../acp-bridge',
|
||||
},
|
||||
optionalDependencies: {
|
||||
'@qwen-code/sdk': '0.22.4',
|
||||
},
|
||||
};
|
||||
|
||||
expect(pnpmHooks.readPackage(packageJson)).toEqual({
|
||||
dependencies: {
|
||||
'@qwen-code/qwen-code-core': 'workspace:*',
|
||||
'@qwen-code/channel-base': 'workspace:*',
|
||||
fixture: 'file:../fixture',
|
||||
},
|
||||
devDependencies: {
|
||||
'@qwen-code/acp-bridge': 'workspace:*',
|
||||
},
|
||||
optionalDependencies: {
|
||||
'@qwen-code/sdk': 'workspace:*',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the pnpm rewrite set in sync with the workspace manifests', () => {
|
||||
const { workspaces } = readPackageJson();
|
||||
const negated = workspaces
|
||||
.filter((entry) => entry.startsWith('!'))
|
||||
.map((entry) => entry.slice(1));
|
||||
const directories = [];
|
||||
for (const pattern of workspaces) {
|
||||
if (pattern.startsWith('!')) continue;
|
||||
if (pattern.endsWith('/*')) {
|
||||
const parent = pattern.slice(0, -2);
|
||||
for (const entry of readdirSync(path.join(root, parent))) {
|
||||
directories.push(`${parent}/${entry}`);
|
||||
}
|
||||
} else {
|
||||
directories.push(pattern);
|
||||
}
|
||||
}
|
||||
const names = directories
|
||||
.filter((directory) => !negated.includes(directory))
|
||||
.map((directory) => {
|
||||
const manifestPath = path.join(root, directory, 'package.json');
|
||||
if (!existsSync(manifestPath)) return undefined;
|
||||
return JSON.parse(readFileSync(manifestPath, 'utf8')).name;
|
||||
})
|
||||
.filter((name) => name !== undefined);
|
||||
|
||||
// A member missing from the set keeps its release version or file:
|
||||
// specifier under pnpm, which is exactly the lockfile staleness the
|
||||
// rewrite exists to prevent.
|
||||
expect([...workspacePackageNames].sort()).toEqual(
|
||||
[...new Set(names)].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('mirrors the npm workspace boundaries in pnpm-workspace.yaml', () => {
|
||||
const workspace = parse(readWorkflow('pnpm-workspace.yaml'));
|
||||
|
||||
expect([...workspace.packages].sort()).toEqual(
|
||||
[...readPackageJson().workspaces].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('mirrors npm overrides in pnpm-workspace.yaml', () => {
|
||||
const workspace = parse(readWorkflow('pnpm-workspace.yaml'));
|
||||
const { cliui, ...npmOverrides } = readPackageJson().overrides;
|
||||
|
||||
expect(workspace.overrides).toMatchObject({
|
||||
...npmOverrides,
|
||||
'cliui>wrap-ansi': cliui['wrap-ansi'],
|
||||
});
|
||||
});
|
||||
|
||||
it('checks both lockfiles for integrity', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(root, 'scripts/check-lockfile.js')],
|
||||
{ cwd: root, encoding: 'utf8' },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('Lockfile check passed.');
|
||||
expect(result.stdout).toContain('pnpm lockfile check passed.');
|
||||
});
|
||||
|
||||
it('keeps the internal release-age exception independent of the version', () => {
|
||||
const workspace = parse(
|
||||
readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8'),
|
||||
);
|
||||
|
||||
expect(workspace.minimumReleaseAgeExclude).toEqual([
|
||||
'@qwen-code/channel-base',
|
||||
]);
|
||||
});
|
||||
|
||||
it('imports packages without hard links so patch-package cannot corrupt the store', () => {
|
||||
const workspace = parse(
|
||||
readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8'),
|
||||
);
|
||||
|
||||
// The root postinstall rewrites node_modules files in place. Under
|
||||
// 'auto' or 'hardlink' those files are hard links into the
|
||||
// content-addressable store, so the rewrite corrupts the store entry and
|
||||
// every later worktree misses its --offline stage.
|
||||
expect(readPackageJson().scripts.postinstall).toBe('patch-package');
|
||||
expect(['clone-or-copy', 'copy', 'clone']).toContain(
|
||||
workspace.packageImportMethod,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the pnpm lockfile out of prettier so formatting cannot fight pnpm', () => {
|
||||
const ignored = readFileSync(path.join(root, '.prettierignore'), 'utf8')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line !== '' && !line.startsWith('#'));
|
||||
|
||||
expect(ignored).toContain('pnpm-lock.yaml');
|
||||
});
|
||||
|
||||
it('keeps channel workspace lock entries independent of release versions', () => {
|
||||
const lockfile = parse(
|
||||
readFileSync(path.join(root, 'pnpm-lock.yaml'), 'utf8'),
|
||||
);
|
||||
const specifiers = Object.values(lockfile.importers).flatMap((importer) =>
|
||||
['dependencies', 'devDependencies', 'optionalDependencies'].flatMap(
|
||||
(field) => {
|
||||
const entry = importer[field]?.['@qwen-code/channel-base'];
|
||||
return entry ? [entry.specifier] : [];
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(specifiers.length).toBeGreaterThan(0);
|
||||
expect(new Set(specifiers)).toEqual(new Set(['workspace:*']));
|
||||
});
|
||||
|
||||
it('bootstraps worktrees with frozen pnpm dependencies and skips prepare', () => {
|
||||
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-worktree-setup-'));
|
||||
const commandDir = path.join(binDir, 'runner bin');
|
||||
const logFile = path.join(binDir, 'corepack.log');
|
||||
mkdirSync(commandDir);
|
||||
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(
|
||||
path.join(commandDir, 'corepack.cmd'),
|
||||
'@echo %QWEN_SKIP_PREPARE% %QWEN_SKIP_NOTICE_GENERATION% %*>>"%WORKTREE_SETUP_LOG%"\r\n',
|
||||
);
|
||||
} else {
|
||||
writeFileSync(
|
||||
path.join(commandDir, 'corepack'),
|
||||
'#!/bin/sh\necho "$QWEN_SKIP_PREPARE $QWEN_SKIP_NOTICE_GENERATION $*" >> "$WORKTREE_SETUP_LOG"\n',
|
||||
);
|
||||
chmodSync(path.join(commandDir, 'corepack'), 0o755);
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(root, 'scripts/setup-worktree.js')],
|
||||
{
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${commandDir}${path.delimiter}${process.env.PATH ?? ''}`,
|
||||
WORKTREE_SETUP_LOG: logFile,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(readFileSync(logFile, 'utf8').trim()).toBe(
|
||||
'1 1 pnpm install --frozen-lockfile --offline',
|
||||
);
|
||||
} finally {
|
||||
rmSync(binDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'resolves the path variable under its native Windows casing',
|
||||
() => {
|
||||
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-worktree-path-'));
|
||||
const commandDir = path.join(binDir, 'runner bin');
|
||||
const logFile = path.join(binDir, 'corepack.log');
|
||||
mkdirSync(commandDir);
|
||||
|
||||
try {
|
||||
writeFileSync(
|
||||
path.join(commandDir, 'corepack.cmd'),
|
||||
'@echo %QWEN_SKIP_PREPARE% %QWEN_SKIP_NOTICE_GENERATION% %*>>"%WORKTREE_SETUP_LOG%"\r\n',
|
||||
);
|
||||
|
||||
// Native shells expose the path variable as `Path`; a case-sensitive
|
||||
// `env.PATH` read on the spread object would miss it and report
|
||||
// Corepack unavailable.
|
||||
const env = { ...process.env, WORKTREE_SETUP_LOG: logFile };
|
||||
delete env.PATH;
|
||||
delete env.Path;
|
||||
env.Path = `${commandDir}${path.delimiter}${process.env.Path ?? process.env.PATH ?? ''}`;
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(root, 'scripts/setup-worktree.js')],
|
||||
{
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(readFileSync(logFile, 'utf8').trim()).toBe(
|
||||
'1 1 pnpm install --frozen-lockfile --offline',
|
||||
);
|
||||
} finally {
|
||||
rmSync(binDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('fails closed when Corepack is unavailable', () => {
|
||||
const binDir = mkdtempSync(
|
||||
path.join(tmpdir(), 'qwen-worktree-no-corepack-'),
|
||||
);
|
||||
const env = { ...process.env, PATH: binDir };
|
||||
for (const name of Object.keys(env)) {
|
||||
if (name !== 'PATH' && name.toUpperCase() === 'PATH') delete env[name];
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(root, 'scripts/setup-worktree.js')],
|
||||
{ cwd: root, encoding: 'utf8', env },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('Corepack is required');
|
||||
} finally {
|
||||
rmSync(binDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to registry access when the pnpm store is incomplete', () => {
|
||||
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-worktree-fallback-'));
|
||||
const logFile = path.join(binDir, 'corepack.log');
|
||||
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(
|
||||
path.join(binDir, 'corepack.cmd'),
|
||||
'@echo %QWEN_SKIP_PREPARE% %QWEN_SKIP_NOTICE_GENERATION% %*>>"%WORKTREE_SETUP_LOG%"\r\n@if "%4"=="--offline" exit /b 1\r\n',
|
||||
);
|
||||
} else {
|
||||
writeFileSync(
|
||||
path.join(binDir, 'corepack'),
|
||||
'#!/bin/sh\necho "$QWEN_SKIP_PREPARE $QWEN_SKIP_NOTICE_GENERATION $*" >> "$WORKTREE_SETUP_LOG"\n[ "$4" != "--offline" ]\n',
|
||||
);
|
||||
chmodSync(path.join(binDir, 'corepack'), 0o755);
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(root, 'scripts/setup-worktree.js')],
|
||||
{
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: binDir,
|
||||
WORKTREE_SETUP_LOG: logFile,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([
|
||||
'1 1 pnpm install --frozen-lockfile --offline',
|
||||
'1 1 pnpm install --frozen-lockfile --prefer-offline',
|
||||
]);
|
||||
} finally {
|
||||
rmSync(binDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves a worktree bootstrap interrupt status', () => {
|
||||
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-worktree-signal-'));
|
||||
const logFile = path.join(binDir, 'corepack.log');
|
||||
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(
|
||||
path.join(binDir, 'corepack.cmd'),
|
||||
'@echo called>>"%WORKTREE_SETUP_LOG%"\r\n@exit /b 130\r\n',
|
||||
);
|
||||
} else {
|
||||
writeFileSync(
|
||||
path.join(binDir, 'corepack'),
|
||||
'#!/bin/sh\necho called >> "$WORKTREE_SETUP_LOG"\nkill -INT $$\n',
|
||||
);
|
||||
chmodSync(path.join(binDir, 'corepack'), 0o755);
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(root, 'scripts/setup-worktree.js')],
|
||||
{
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
|
||||
WORKTREE_SETUP_LOG: logFile,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(130);
|
||||
expect(readFileSync(logFile, 'utf8').trim()).toBe('called');
|
||||
} finally {
|
||||
rmSync(binDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not couple Node REPL to Qwen release versions', () => {
|
||||
const versionScript = readFileSync(
|
||||
path.join(root, 'scripts/version.js'),
|
||||
@@ -47,6 +413,100 @@ describe('package scripts', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('smoke-tests the real worktree bootstrap on every supported host', () => {
|
||||
const workflow = parse(
|
||||
readWorkflow('.github/workflows/pnpm-worktree-smoke.yml'),
|
||||
);
|
||||
const job = workflow.jobs.install;
|
||||
|
||||
expect(job.strategy.matrix.os).toEqual([
|
||||
'ubuntu-latest',
|
||||
'macos-latest',
|
||||
'windows-latest',
|
||||
]);
|
||||
expect(job.strategy['fail-fast']).toBe(false);
|
||||
expect(
|
||||
job.steps.find(
|
||||
(step) => step.name === 'Install frozen pnpm worktree dependencies',
|
||||
)?.run,
|
||||
).toBe('node scripts/setup-worktree.js');
|
||||
|
||||
// The pnpm linker only materializes declared dependencies, so a
|
||||
// workspace import that npm's hoisting hides breaks typecheck and tests
|
||||
// in the bootstrapped tree; resolve one through the worst offender.
|
||||
const resolveCheck = job.steps.find(
|
||||
(step) => step.name === 'Verify workspace links resolve',
|
||||
);
|
||||
expect(resolveCheck?.run).toContain(
|
||||
'@qwen-code/qwen-code-core/package.json',
|
||||
);
|
||||
expect(resolveCheck?.run).toContain('packages/vscode-ide-companion');
|
||||
|
||||
// `git diff --exit-code` misses untracked files; the install must leave
|
||||
// the porcelain output empty on every host, including Windows (hence
|
||||
// the explicit POSIX shell).
|
||||
const cleanCheck = job.steps.find(
|
||||
(step) => step.name === 'Ensure bootstrap keeps the worktree clean',
|
||||
);
|
||||
expect(cleanCheck?.shell).toBe('bash');
|
||||
expect(cleanCheck?.run).toContain('git status --porcelain');
|
||||
|
||||
// The checks only mean anything after the install; pin the order.
|
||||
const stepNames = job.steps.map((step) => step.name);
|
||||
expect(
|
||||
stepNames.indexOf('Install frozen pnpm worktree dependencies'),
|
||||
).toBeLessThan(stepNames.indexOf('Verify workspace links resolve'));
|
||||
expect(stepNames.indexOf('Verify workspace links resolve')).toBeLessThan(
|
||||
stepNames.indexOf('Ensure bootstrap keeps the worktree clean'),
|
||||
);
|
||||
|
||||
// A post-merge run is the only witness of a regression on main, so
|
||||
// consecutive merges must not cancel each other.
|
||||
expect(workflow.concurrency['cancel-in-progress']).toBe(
|
||||
"${{ github.event_name == 'pull_request' }}",
|
||||
);
|
||||
|
||||
// Substring check on the raw job text: an exact `step.run` match is
|
||||
// bypassed by any other spelling of a build step (block scalar,
|
||||
// compound command).
|
||||
const installJob = getWorkflowJob(
|
||||
readWorkflow('.github/workflows/pnpm-worktree-smoke.yml'),
|
||||
'install',
|
||||
);
|
||||
expect(installJob).not.toContain('npm run build');
|
||||
expect(installJob).not.toContain('node scripts/build.js');
|
||||
});
|
||||
|
||||
it('runs the pnpm smoke workflow when a dependency input changes', () => {
|
||||
const workflow = parse(
|
||||
readWorkflow('.github/workflows/pnpm-worktree-smoke.yml'),
|
||||
);
|
||||
const expectedPaths = [
|
||||
'.github/workflows/pnpm-worktree-smoke.yml',
|
||||
'.npmrc',
|
||||
'.pnpmfile.mjs',
|
||||
'package.json',
|
||||
'packages/*/package.json',
|
||||
'!packages/desktop-shell/package.json',
|
||||
'!packages/live-host/package.json',
|
||||
'packages/channels/*/package.json',
|
||||
'integrations/*/package.json',
|
||||
'patches/**',
|
||||
'packages/audio-capture/install.js',
|
||||
'packages/core/scripts/postinstall.js',
|
||||
'packages/vscode-ide-companion/scripts/generate-notices.js',
|
||||
'pnpm-lock.yaml',
|
||||
'pnpm-workspace.yaml',
|
||||
'scripts/generate-git-commit-info.js',
|
||||
'scripts/prepare.js',
|
||||
'scripts/pnpm-package.js',
|
||||
'scripts/setup-worktree.js',
|
||||
];
|
||||
|
||||
expect(workflow.on.pull_request.paths).toEqual(expectedPaths);
|
||||
expect(workflow.on.push.paths).toEqual(expectedPaths);
|
||||
});
|
||||
|
||||
it('builds the standalone qwen-live daemon in the root build order', () => {
|
||||
const buildScript = readFileSync(
|
||||
path.join(root, 'scripts/build.js'),
|
||||
|
||||
Reference in new issue
Block a user