mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-20 21:31:47 +00:00
chore: enforce Playwright version parity and close the deferred #11101 follow-ups (#11371)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Lint & Static (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / web-shell E2E Smoke (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Integration Tests (no-AK, No Sandbox) (push) Blocked by required conditions
Qwen Code CI / Integration Tests (CLI, No Sandbox) (push) Blocked by required conditions
Qwen Code CI / Desktop Shell (ubuntu-22.04) (push) Blocked by required conditions
Qwen Code CI / Desktop Shell (windows-2022) (push) Blocked by required conditions
E2E Tests / Build for E2E (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/1 (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/1 (push) Blocked by required conditions
E2E Tests / E2E Test - macOS - shard 1/2 (push) Blocked by required conditions
E2E Tests / E2E Test - macOS - shard 2/2 (push) Blocked by required conditions
E2E Tests / E2E Interactive - OpenTUI renderer (bun) (push) Blocked by required conditions
E2E Tests / channel-plugin E2E (nightly) (push) Blocked by required conditions
E2E Tests / cron-interactive E2E (nightly) (push) Blocked by required conditions
E2E Tests / web-shell Browser Regression (push) Waiting to run
npm cache producer / Save npm cache (push) Waiting to run
pnpm Worktree Smoke / Install (macos-latest) (push) Waiting to run
pnpm Worktree Smoke / Install (ubuntu-latest) (push) Waiting to run
pnpm Worktree Smoke / Install (windows-latest) (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Lint & Static (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / web-shell E2E Smoke (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Integration Tests (no-AK, No Sandbox) (push) Blocked by required conditions
Qwen Code CI / Integration Tests (CLI, No Sandbox) (push) Blocked by required conditions
Qwen Code CI / Desktop Shell (ubuntu-22.04) (push) Blocked by required conditions
Qwen Code CI / Desktop Shell (windows-2022) (push) Blocked by required conditions
E2E Tests / Build for E2E (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/1 (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/1 (push) Blocked by required conditions
E2E Tests / E2E Test - macOS - shard 1/2 (push) Blocked by required conditions
E2E Tests / E2E Test - macOS - shard 2/2 (push) Blocked by required conditions
E2E Tests / E2E Interactive - OpenTUI renderer (bun) (push) Blocked by required conditions
E2E Tests / channel-plugin E2E (nightly) (push) Blocked by required conditions
E2E Tests / cron-interactive E2E (nightly) (push) Blocked by required conditions
E2E Tests / web-shell Browser Regression (push) Waiting to run
npm cache producer / Save npm cache (push) Waiting to run
pnpm Worktree Smoke / Install (macos-latest) (push) Waiting to run
pnpm Worktree Smoke / Install (ubuntu-latest) (push) Waiting to run
pnpm Worktree Smoke / Install (windows-latest) (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
* chore: enforce Playwright parity and close deferred #11101 follow-ups Works the four items tracked in #11336: - Pin Web Shell's @playwright/test to the same exact version as the root playwright dependency, and fail `npm run check:lockfile` when the two direct manifests drift apart or their resolved root trees stop matching the pin. The check deliberately tolerates the packages/mobile-mcp tree: mobilewright depends on playwright and playwright-core at an exact older revision, so no manifest edit in this repository can dedupe it. - Correct the "Install evidence browser" rationale in qwen-triage.yml and its restatement in the workflow test. The two-trees premise no longer holds now that both manifests are pinned; the resolution behavior is unchanged, since resolving from the harness's own directory still keeps the binary tied to the package it imports. - Cover the ENOENT fallback of the exported isMainModule helper. The existing runner.py assertion takes the realpath success path because that file exists, so the fallback stayed unpinned. - Separate the CLI-only from the daemon-only followup suggestion gates in the documentation instead of pointing daemon clients at the blended CLI-oriented list, keeping the headless/SDK opt-out guidance. * chore: pin the Playwright parity check and correct its scope notes Follow-up to the round-1 review on this PR. The parity block had no test in either direction: the only suite that runs the script asserted the two pre-existing banners, so deleting the block, inverting a comparison, or breaking the importer derivation left both `npm run test:scripts` and CI's `check:lockfile` gate green. That is the defect class this PR exists to remove — the same diff rewrites two rationale comments to lean on the check, so an unpinned guard rots into a comment that lies. Add the third banner to the existing suite and a fixture suite that runs the script against perturbed copies of the real manifests and lockfiles, covering both drift arms and the two shapes that must stay green. Also correct three properties of the block the review found: - The inventory comment claimed two Playwright declarations sit outside the invariant; `packages/mobile-mcp` declares `@playwright/test` as a range directly, in a workspace member, and was not named. It is the only in-workspace range on a name this check pins, so it is what the resolved-version message should point an operator at. - The agreement check destructured exactly two entries while the comment invited maintainers to extend the list, so a third entry would have received the exactness check and silently skipped the comparison the block exists for. Compare every entry against the first and derive the resolved-tree locations from the same list instead of repeating them as a second literal. - The pnpm arm prescribed "regenerate it" for every divergence, but `pnpm-workspace.yaml`'s `overrides:` and `.pnpmfile.mjs` both rewrite the recorded specifier, so regeneration is a no-op for those. Name the deciding layer in the messages and the importer list's source for a missing entry. * docs: stop counting the followup suggestion code paths Merging main brought in the OpenTUI port of ink's AppContainer effect (packages/cli/src/ui/opentui/followup-generation.ts), which is a third non-test `generatePromptSuggestion` call site. The section opener claimed "Two code paths decide this", which a grep now contradicts even though the substance still holds: the OpenTUI hook applies the same CLI gate set as AppContainer — interactive, not SDK mode, the Responding to Idle edge, no error in the last item, no waiting calls, approval mode not plan — and collapses ink's five explicit dialog checks into `waitingCalls.length === 0` because the composer is unmounted while a shell dialog is open. Name the two decision-makers instead of counting implementations, so the sentence stays true when a renderer is added or retired. The CLI/daemon split below is unchanged and was re-verified against the merged tree: the ink gate is byte-identical at AppContainer.tsx:3612-3627, the daemon gate moved only by line number (Session.ts:5080-5095), and the shared MIN_ASSISTANT_TURNS gate is still at suggestionGenerator.ts:83,103. --------- Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
parent
7c1cad5af5
commit
8a21551599
10 changed files with 553 additions and 28 deletions
27
.github/workflows/qwen-triage.yml
vendored
27
.github/workflows/qwen-triage.yml
vendored
|
|
@ -3947,17 +3947,22 @@ jobs:
|
|||
run: |-
|
||||
set -uo pipefail
|
||||
# Download the browser binary for the Playwright package the
|
||||
# capture harness actually imports. This lockfile has TWO
|
||||
# Playwright trees: terminal-capture.ts imports `playwright`, but
|
||||
# node_modules/.bin/playwright is @playwright/test's CLI, which
|
||||
# pins a different chromium revision — so `npx playwright install`
|
||||
# would download a browser the harness cannot launch. Resolve the
|
||||
# CLI from the harness's own directory: require.resolve runs the
|
||||
# same algorithm as its `from 'playwright'` import, so the binary
|
||||
# matches the lockfile even if npm's hoist layout changes (nothing
|
||||
# pins playwright to the root node_modules). cli.js is not in the
|
||||
# package's exports map, so resolve the exported package.json and
|
||||
# join. Runs as node so the tree is agent-readable without chmod.
|
||||
# capture harness actually imports. Root `playwright` and Web
|
||||
# Shell's `@playwright/test` carry the same exact pin, and
|
||||
# `npm run check:lockfile` fails if the two manifests or their
|
||||
# resolved trees drift apart, so both of those CLIs install the
|
||||
# same chromium revision. The parity stops there: a transitive
|
||||
# dependency of packages/mobile-mcp hoists an older
|
||||
# `playwright-core` to the root, and its own bin pins an older
|
||||
# revision. Resolve the CLI from the harness's own directory
|
||||
# rather than assuming a hoist location anyway: require.resolve
|
||||
# runs the same algorithm as the harness's `from 'playwright'`
|
||||
# import, so the binary keeps matching the imported package if
|
||||
# npm's hoist layout changes, if a workspace later nests its own
|
||||
# copy, or if that parity check is ever bypassed. cli.js is not in
|
||||
# the package's exports map, so resolve the exported package.json
|
||||
# and join. Runs as node so the tree is agent-readable without
|
||||
# chmod.
|
||||
# Best-effort: failure degrades to a text-only report.
|
||||
PW_PATH="${RUNNER_TEMP:?}/pw-browsers"
|
||||
mkdir -p "$PW_PATH"
|
||||
|
|
|
|||
|
|
@ -27,16 +27,28 @@ The suggestion is generated by sending the conversation history to the model, wh
|
|||
|
||||
## When Suggestions Appear
|
||||
|
||||
Suggestions are generated when all of the following conditions are met:
|
||||
The interactive CLI and the daemon decide this separately, and they do not apply the same conditions: the CLI gates generation itself, in each of its renderers, while the daemon gates it server-side for every client attached to the session.
|
||||
|
||||
Both sides require all of the following:
|
||||
|
||||
- The model has completed its response (not during streaming)
|
||||
- At least 2 model turns have occurred in the conversation
|
||||
- There are no errors in the most recent response
|
||||
- No confirmation dialogs are pending (e.g., shell confirmation, permissions)
|
||||
- The approval mode is not set to `plan`
|
||||
- The feature is enabled (on by default — set `ui.enableFollowupSuggestions` to `false` to turn it off)
|
||||
|
||||
Suggestions will not appear in the CLI's non-interactive mode (e.g., headless/SDK mode). In the daemon, generation is server-side and runs after every turn that meets the conditions above, so a headless or SDK client that cannot render the suggestion should set `ui.enableFollowupSuggestions` to `false` to avoid the per-turn LLM cost.
|
||||
The interactive CLI additionally requires:
|
||||
|
||||
- The session is interactive — the CLI never generates suggestions in its own non-interactive or SDK mode
|
||||
- The model has completed its response (not during streaming)
|
||||
- There are no errors in the most recent response
|
||||
- No confirmation dialogs are pending (e.g., shell confirmation, permissions)
|
||||
|
||||
The daemon additionally requires:
|
||||
|
||||
- The turn ended cleanly, meaning its stop reason is `end_turn` — a cancelled, refused, or truncated turn gets no suggestion
|
||||
- Automatic turns are not being held by the todo stop guard, and no queued prompt is waiting to run
|
||||
- The most recent entry in the conversation history is a model response
|
||||
|
||||
Because daemon-side generation happens for every client attached to the session, it also happens for clients that cannot render the result. Such a client — a headless or SDK consumer of a daemon session, which is not the CLI's own non-interactive mode above — should set `ui.enableFollowupSuggestions` to `false` to avoid paying the per-turn LLM cost for output it discards.
|
||||
|
||||
Suggestions are automatically dismissed when:
|
||||
|
||||
|
|
|
|||
|
|
@ -48,3 +48,13 @@ test('does not treat an imported module as the main module', () => {
|
|||
);
|
||||
assert.equal(isMainModule(undefined, exporterUrl), false);
|
||||
});
|
||||
|
||||
test('returns false for a path that is not on disk instead of throwing', () => {
|
||||
// `runner.py` above exists, so it exercises the realpath success path and
|
||||
// leaves the ENOENT fallback unpinned. The fallback is reachable only
|
||||
// because the helper is exported, so pin it here: without it a caller
|
||||
// passing a stale or mistyped path gets an exception instead of `false`.
|
||||
const missingPath = path.join(path.dirname(exporterPath), 'no-such-entry.js');
|
||||
assert.equal(fs.existsSync(missingPath), false);
|
||||
assert.equal(isMainModule(missingPath, exporterUrl), false);
|
||||
});
|
||||
|
|
|
|||
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -31119,7 +31119,7 @@
|
|||
"vaul": "^1.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@qwen-code/sdk": "file:../sdk-typescript",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^22.0.0",
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@
|
|||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@qwen-code/sdk": "file:../sdk-typescript",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^22.0.0",
|
||||
|
|
|
|||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
|
|
@ -1375,7 +1375,7 @@ importers:
|
|||
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: ^1.57.0
|
||||
specifier: 1.61.1
|
||||
version: 1.61.1
|
||||
'@qwen-code/sdk':
|
||||
specifier: workspace:*
|
||||
|
|
|
|||
|
|
@ -111,3 +111,141 @@ if (invalidPnpmPackages.length > 0) {
|
|||
} else {
|
||||
console.log('pnpm lockfile check passed.');
|
||||
}
|
||||
|
||||
console.log('Checking Playwright parity...');
|
||||
|
||||
// The root `playwright` and Web Shell's `@playwright/test` both drive the
|
||||
// chromium revision the capture harness launches, so they must stay on one
|
||||
// version. Both manifests carry an exact pin: a range on either side lets a
|
||||
// regeneration resolve the pair apart and re-nest a second tree, which leaves
|
||||
// the installed browser one the harness cannot launch.
|
||||
//
|
||||
// Three other Playwright declarations are deliberately outside this invariant:
|
||||
// - `packages/mobile-mcp` declares `@playwright/test` as a range directly.
|
||||
// npm satisfies it from the single hoisted copy at the pinned version
|
||||
// today, and the harness never resolves into that workspace, so it stays
|
||||
// out of scope here — but it is the only in-workspace range on a name this
|
||||
// check pins, which makes it the first manifest to look at when the
|
||||
// resolved-version assertion below fires. Do NOT append it to
|
||||
// `playwrightManifests` as it stands: the exactness check rejects its range
|
||||
// and would turn `check:lockfile` red immediately.
|
||||
// - `packages/mobile-mcp` also depends on `mobilewright`, which pins
|
||||
// `playwright` and `playwright-core` to an exact older revision no manifest
|
||||
// edit here can dedupe. That tree hoists the older `playwright-core` to the
|
||||
// root, so the root `playwright-core` bin is NOT part of this parity — only
|
||||
// the `playwright` CLIs are.
|
||||
// - `integration-tests/terminal-capture` declares a `playwright` range but is
|
||||
// not a workspace member, so it enters neither lockfile. Bringing it inside
|
||||
// the invariant takes three steps, not one: pin it exact, list the directory
|
||||
// in BOTH the root `workspaces` and `pnpm-workspace.yaml`'s `packages:`
|
||||
// (scripts/tests/package-scripts.test.js asserts the two lists are equal),
|
||||
// then append it below and regenerate both lockfiles.
|
||||
const EXACT_VERSION = /^\d+\.\d+\.\d+(-[\w.-]+)?$/;
|
||||
const playwrightManifests = [
|
||||
{ manifest: 'package.json', name: 'playwright' },
|
||||
{ manifest: 'packages/web-shell/package.json', name: '@playwright/test' },
|
||||
];
|
||||
|
||||
const playwrightSpecs = playwrightManifests.map(({ manifest, name }) => {
|
||||
const pkg = readJsonFile(join(root, manifest));
|
||||
if (pkg === null) {
|
||||
process.exit(1);
|
||||
}
|
||||
const spec = pkg.devDependencies?.[name] ?? pkg.dependencies?.[name] ?? null;
|
||||
return { manifest, name, spec, exact: EXACT_VERSION.test(spec ?? '') };
|
||||
});
|
||||
|
||||
const parityErrors = [];
|
||||
for (const { manifest, name, spec, exact } of playwrightSpecs) {
|
||||
if (spec === null) {
|
||||
parityErrors.push(`${manifest} does not declare ${name}`);
|
||||
} else if (!exact) {
|
||||
parityErrors.push(
|
||||
`${manifest} declares ${name} as "${spec}"; expected an exact version so it cannot resolve apart from the others`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Every entry is compared against the first rather than the second against the
|
||||
// first: `playwrightManifests` is a list a maintainer extends, and a positional
|
||||
// destructure hands a third entry the exactness check while silently skipping
|
||||
// the agreement check this block exists for.
|
||||
const [pinEntry, ...restEntries] = playwrightSpecs;
|
||||
const pinned = pinEntry.spec;
|
||||
for (const { manifest, name, spec, exact } of restEntries) {
|
||||
if (exact && pinEntry.exact && spec !== pinned) {
|
||||
parityErrors.push(
|
||||
`${pinEntry.name} ${pinned} (${pinEntry.manifest}) and ${name} ${spec} (${manifest}) must declare the same version`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Manifest agreement is not enough on its own: a stale or hand-edited lockfile
|
||||
// can still resolve the set apart. Assert the resolved outcome too, deriving
|
||||
// each location from the manifest list so an entry added above is covered by
|
||||
// both halves rather than only by the exactness check.
|
||||
if (pinEntry.exact) {
|
||||
for (const { name } of playwrightSpecs) {
|
||||
const location = `node_modules/${name}`;
|
||||
const actual = packages[location]?.version;
|
||||
if (actual === undefined) {
|
||||
parityErrors.push(
|
||||
`package-lock.json has no ${location} entry, so nothing hoists the package the harness imports; regenerate the lockfile`,
|
||||
);
|
||||
} else if (actual !== pinned) {
|
||||
parityErrors.push(
|
||||
`package-lock.json resolves ${location} to ${actual} instead of ${pinned}; regenerate the lockfile, and if that does not settle it then a manifest range is resolving above the pin — packages/mobile-mcp's @playwright/test is the only in-workspace one`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// A nested copy at the pinned version is a redundant install, not a split
|
||||
// revision, so only a differing one is an error.
|
||||
const nested = 'node_modules/@playwright/test/node_modules/playwright';
|
||||
if (packages[nested] && packages[nested].version !== pinned) {
|
||||
parityErrors.push(
|
||||
`package-lock.json nests ${nested} at ${packages[nested].version}, splitting the chromium revision; regenerate the lockfile`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Both lockfiles are committed together, so a specifier or a resolved version
|
||||
// that lands in one and not the other is the same drift. pnpm's
|
||||
// --frozen-lockfile validates specifiers only, so the version is asserted too.
|
||||
// The recorded value is not always the manifest string: pnpm-workspace.yaml's
|
||||
// `overrides:` and .pnpmfile.mjs's readPackage hook both rewrite it — web-shell
|
||||
// declares `typescript: ^5.3.3` while the lockfile records `5.8.3`, because
|
||||
// `overrides:` pins it. Neither layer touches Playwright today, so a divergence
|
||||
// here is drift, but the messages name those layers because "regenerate it" is
|
||||
// a no-op when one of them is what decides the value.
|
||||
const pnpmImporters = pnpmLockfile?.importers ?? {};
|
||||
for (const { manifest, name, spec, exact } of playwrightSpecs) {
|
||||
const importer = dirname(manifest);
|
||||
const entry =
|
||||
pnpmImporters[importer]?.devDependencies?.[name] ??
|
||||
pnpmImporters[importer]?.dependencies?.[name] ??
|
||||
null;
|
||||
if (entry === null) {
|
||||
parityErrors.push(
|
||||
`pnpm-lock.yaml has no ${name} entry for importer "${importer}"; importers come from pnpm-workspace.yaml's packages:, so check that ${importer} is listed there, then regenerate`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (entry.specifier !== spec) {
|
||||
parityErrors.push(
|
||||
`pnpm-lock.yaml records ${name} in "${importer}" as "${entry.specifier}" but ${manifest} declares "${spec}"; regenerate it — unless pnpm-workspace.yaml's overrides: or .pnpmfile.mjs rewrites this package, in which case that layer decides the value and the manifest is not the source of truth`,
|
||||
);
|
||||
}
|
||||
if (exact && entry.version !== spec) {
|
||||
parityErrors.push(
|
||||
`pnpm-lock.yaml resolves ${name} in "${importer}" to ${entry.version} instead of ${spec}; regenerate it — unless an overrides: entry pins a different version`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (parityErrors.length > 0) {
|
||||
console.error('\nError: Playwright version parity is broken:');
|
||||
parityErrors.forEach((message) => console.error(`- ${message}`));
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('Playwright parity check passed.');
|
||||
}
|
||||
|
|
|
|||
353
scripts/tests/check-lockfile.test.js
Normal file
353
scripts/tests/check-lockfile.test.js
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
cpSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const script = join(root, 'scripts', 'check-lockfile.js');
|
||||
|
||||
// The script derives the tree it inspects from its own location, with no
|
||||
// injectable override, so the fixtures run a copy of it inside a throwaway
|
||||
// tree. That tree sits under the repository's node_modules for two reasons: it
|
||||
// is gitignored, so a crashed run cannot leave tracked residue, and Node
|
||||
// resolves the script's `yaml` import by walking up into the real
|
||||
// node_modules. Copying the real manifests and lockfiles in — rather than
|
||||
// writing minimal ones — keeps the script's two integrity sections green, so
|
||||
// the parity section is the only thing under test.
|
||||
let fixtureRoot;
|
||||
|
||||
const FILES = [
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'pnpm-lock.yaml',
|
||||
'packages/web-shell/package.json',
|
||||
];
|
||||
|
||||
function pristine() {
|
||||
return Object.fromEntries(
|
||||
FILES.map((rel) => [rel, readFileSync(join(fixtureRoot, rel), 'utf8')]),
|
||||
);
|
||||
}
|
||||
|
||||
let saved;
|
||||
|
||||
function runCheck() {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[join(fixtureRoot, 'scripts', 'check-lockfile.js')],
|
||||
{ cwd: fixtureRoot, encoding: 'utf8' },
|
||||
);
|
||||
return { status: result.status, out: result.stdout + result.stderr };
|
||||
}
|
||||
|
||||
// The parity banner is what separates this section's verdict from the two
|
||||
// integrity sections', so every arm asserts on it rather than on the exit code
|
||||
// alone.
|
||||
function parityLines(out) {
|
||||
return out.slice(out.indexOf('Checking Playwright parity...'));
|
||||
}
|
||||
|
||||
function writeFixture(rel, contents) {
|
||||
writeFileSync(join(fixtureRoot, rel), contents);
|
||||
}
|
||||
|
||||
function perturbNpmLockfile(mutate) {
|
||||
const lockfile = JSON.parse(
|
||||
readFileSync(join(fixtureRoot, 'package-lock.json'), 'utf8'),
|
||||
);
|
||||
mutate(lockfile.packages);
|
||||
writeFixture('package-lock.json', JSON.stringify(lockfile, null, 2));
|
||||
}
|
||||
|
||||
// Scoped to the web-shell importer block: the same specifier string appears
|
||||
// under other importers, and a global replace would perturb the wrong one.
|
||||
function perturbPnpmWebShell(field, value) {
|
||||
const text = readFileSync(join(fixtureRoot, 'pnpm-lock.yaml'), 'utf8');
|
||||
const start = text.indexOf(' packages/web-shell:');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const block = text.slice(start);
|
||||
const target = ` '@playwright/test':\n specifier: 1.61.1\n version: 1.61.1\n`;
|
||||
expect(block).toContain(target);
|
||||
const patched = block.replace(
|
||||
target,
|
||||
target.replace(`${field}: 1.61.1`, `${field}: ${value}`),
|
||||
);
|
||||
writeFixture('pnpm-lock.yaml', text.slice(0, start) + patched);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
fixtureRoot = mkdtempSync(join(root, 'node_modules', '.tmp-check-lockfile-'));
|
||||
mkdirSync(join(fixtureRoot, 'scripts'), { recursive: true });
|
||||
cpSync(script, join(fixtureRoot, 'scripts', 'check-lockfile.js'));
|
||||
for (const rel of FILES) {
|
||||
const from = join(root, rel);
|
||||
const to = join(fixtureRoot, rel);
|
||||
mkdirSync(dirname(to), { recursive: true });
|
||||
cpSync(from, to);
|
||||
}
|
||||
saved = pristine();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (fixtureRoot) {
|
||||
rmSync(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function restore() {
|
||||
for (const [rel, contents] of Object.entries(saved)) {
|
||||
writeFixture(rel, contents);
|
||||
}
|
||||
}
|
||||
|
||||
describe('check-lockfile Playwright parity', () => {
|
||||
it('passes on the committed tree', () => {
|
||||
const { status, out } = runCheck();
|
||||
|
||||
expect(parityLines(out)).toContain('Playwright parity check passed.');
|
||||
expect(status).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects two manifests pinned to different exact versions', () => {
|
||||
try {
|
||||
writeFixture(
|
||||
'packages/web-shell/package.json',
|
||||
saved['packages/web-shell/package.json'].replace(
|
||||
'"@playwright/test": "1.61.1"',
|
||||
'"@playwright/test": "1.62.0"',
|
||||
),
|
||||
);
|
||||
|
||||
const { status, out } = runCheck();
|
||||
|
||||
expect(parityLines(out)).toContain('must declare the same version');
|
||||
expect(status).toBe(1);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a range on either manifest', () => {
|
||||
try {
|
||||
// The state main was in before the pin: both sides equal as strings, but
|
||||
// a caret on both, which is exactly how the two trees came to resolve
|
||||
// apart in the first place.
|
||||
writeFixture(
|
||||
'packages/web-shell/package.json',
|
||||
saved['packages/web-shell/package.json'].replace(
|
||||
'"@playwright/test": "1.61.1"',
|
||||
'"@playwright/test": "^1.57.0"',
|
||||
),
|
||||
);
|
||||
|
||||
const { status, out } = runCheck();
|
||||
|
||||
expect(parityLines(out)).toContain('expected an exact version');
|
||||
expect(status).toBe(1);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a lockfile that resolves the hoisted package off the pin', () => {
|
||||
try {
|
||||
perturbNpmLockfile((packages) => {
|
||||
packages['node_modules/playwright'].version = '1.58.2';
|
||||
});
|
||||
|
||||
const { status, out } = runCheck();
|
||||
|
||||
expect(parityLines(out)).toContain(
|
||||
'resolves node_modules/playwright to 1.58.2 instead of 1.61.1',
|
||||
);
|
||||
expect(status).toBe(1);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a lockfile that lost the hoisted package entirely', () => {
|
||||
try {
|
||||
perturbNpmLockfile((packages) => {
|
||||
delete packages['node_modules/@playwright/test'];
|
||||
});
|
||||
|
||||
const { status, out } = runCheck();
|
||||
|
||||
expect(parityLines(out)).toContain(
|
||||
'has no node_modules/@playwright/test entry',
|
||||
);
|
||||
expect(status).toBe(1);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a nested copy at a different revision', () => {
|
||||
try {
|
||||
perturbNpmLockfile((packages) => {
|
||||
packages['node_modules/@playwright/test/node_modules/playwright'] = {
|
||||
version: '1.62.0',
|
||||
dev: true,
|
||||
resolved:
|
||||
'https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz',
|
||||
integrity: 'sha512-fixture=',
|
||||
};
|
||||
});
|
||||
|
||||
const { status, out } = runCheck();
|
||||
|
||||
expect(parityLines(out)).toContain('splitting the chromium revision');
|
||||
expect(status).toBe(1);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('tolerates a nested copy at the pinned revision', () => {
|
||||
try {
|
||||
// A same-version duplicate is a redundant install, not a split revision,
|
||||
// so it must not be reported as one.
|
||||
perturbNpmLockfile((packages) => {
|
||||
packages['node_modules/@playwright/test/node_modules/playwright'] = {
|
||||
version: '1.61.1',
|
||||
dev: true,
|
||||
resolved:
|
||||
'https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz',
|
||||
integrity: 'sha512-fixture=',
|
||||
};
|
||||
});
|
||||
|
||||
const { status, out } = runCheck();
|
||||
|
||||
expect(parityLines(out)).toContain('Playwright parity check passed.');
|
||||
expect(status).toBe(0);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a pnpm specifier that lags the manifest', () => {
|
||||
try {
|
||||
perturbPnpmWebShell('specifier', '^1.57.0');
|
||||
|
||||
const { status, out } = runCheck();
|
||||
|
||||
expect(parityLines(out)).toContain(
|
||||
'records @playwright/test in "packages/web-shell" as "^1.57.0"',
|
||||
);
|
||||
expect(status).toBe(1);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a pnpm resolved version that lags the specifier', () => {
|
||||
try {
|
||||
// pnpm's --frozen-lockfile validates specifiers only, so this is the arm
|
||||
// that would otherwise install a different browser silently.
|
||||
perturbPnpmWebShell('version', '1.62.0');
|
||||
|
||||
const { status, out } = runCheck();
|
||||
|
||||
expect(parityLines(out)).toContain(
|
||||
'resolves @playwright/test in "packages/web-shell" to 1.62.0 instead of 1.61.1',
|
||||
);
|
||||
expect(status).toBe(1);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('names the layer that decides the value when an importer entry is missing', () => {
|
||||
try {
|
||||
const text = readFileSync(join(fixtureRoot, 'pnpm-lock.yaml'), 'utf8');
|
||||
const start = text.indexOf(' packages/web-shell:');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const block = text.slice(start);
|
||||
const target = ` '@playwright/test':\n specifier: 1.61.1\n version: 1.61.1\n`;
|
||||
expect(block).toContain(target);
|
||||
writeFixture(
|
||||
'pnpm-lock.yaml',
|
||||
text.slice(0, start) + block.replace(target, ''),
|
||||
);
|
||||
|
||||
const { status, out } = runCheck();
|
||||
|
||||
// "regenerate it" is a no-op for this arm — the importer list comes from
|
||||
// pnpm-workspace.yaml, so the message has to say so.
|
||||
expect(parityLines(out)).toContain(
|
||||
'pnpm-lock.yaml has no @playwright/test entry for importer "packages/web-shell"',
|
||||
);
|
||||
expect(parityLines(out)).toContain(
|
||||
"importers come from pnpm-workspace.yaml's packages:",
|
||||
);
|
||||
expect(status).toBe(1);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('applies the agreement check to a third manifest, not just the second', () => {
|
||||
try {
|
||||
// The list is documented as extensible, so extending it must buy the
|
||||
// coverage the first two rows get. A positional destructure of [0] and
|
||||
// [1] hands a third entry the exactness check while silently skipping
|
||||
// the agreement check — which is the divergence that matters here,
|
||||
// because the triage workflow resolves playwright from
|
||||
// terminal-capture's own directory.
|
||||
const thirdManifest = 'integration-tests/terminal-capture/package.json';
|
||||
mkdirSync(dirname(join(fixtureRoot, thirdManifest)), { recursive: true });
|
||||
writeFixture(
|
||||
thirdManifest,
|
||||
JSON.stringify(
|
||||
{
|
||||
name: '@qwen-code/terminal-capture',
|
||||
dependencies: { playwright: '1.62.0' },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
writeFixture(
|
||||
'scripts/check-lockfile.js',
|
||||
readFileSync(
|
||||
join(fixtureRoot, 'scripts', 'check-lockfile.js'),
|
||||
'utf8',
|
||||
).replace(
|
||||
" { manifest: 'packages/web-shell/package.json', name: '@playwright/test' },",
|
||||
" { manifest: 'packages/web-shell/package.json', name: '@playwright/test' },\n" +
|
||||
` { manifest: '${thirdManifest}', name: 'playwright' },`,
|
||||
),
|
||||
);
|
||||
|
||||
const { out } = runCheck();
|
||||
|
||||
expect(parityLines(out)).toContain(
|
||||
'playwright 1.61.1 (package.json) and playwright 1.62.0 (integration-tests/terminal-capture/package.json) must declare the same version',
|
||||
);
|
||||
} finally {
|
||||
cpSync(script, join(fixtureRoot, 'scripts', 'check-lockfile.js'));
|
||||
rmSync(join(fixtureRoot, 'integration-tests'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -163,6 +163,10 @@ describe('package scripts', () => {
|
|||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('Lockfile check passed.');
|
||||
expect(result.stdout).toContain('pnpm lockfile check passed.');
|
||||
// Pins the Playwright parity block's existence and happy path: deleting it,
|
||||
// or returning before it, goes red here. Its drift arms live in
|
||||
// check-lockfile.test.js, which runs the script against perturbed fixtures.
|
||||
expect(result.stdout).toContain('Playwright parity check passed.');
|
||||
});
|
||||
|
||||
it('keeps the internal release-age exception independent of the version', () => {
|
||||
|
|
|
|||
|
|
@ -5576,16 +5576,19 @@ describe('qwen-triage verify round-3 hardening', () => {
|
|||
|
||||
// Browser binary: downloaded after npm ci, and by the CLI of the
|
||||
// package the capture harness actually imports — never a hardcoded pin
|
||||
// (M5). This lockfile has TWO Playwright trees: terminal-capture.ts
|
||||
// imports `playwright`, but node_modules/.bin/playwright (what `npx
|
||||
// playwright` resolves) is @playwright/test's CLI, which pins a
|
||||
// different chromium revision. The install must therefore resolve the
|
||||
// (M5). Root `playwright` and Web Shell's `@playwright/test` carry the
|
||||
// same exact pin, and `npm run check:lockfile` fails if the manifests
|
||||
// or their resolved trees drift, so both of those CLIs install one
|
||||
// chromium revision. The parity does not reach the root
|
||||
// `playwright-core` bin, which a mobile-mcp transitive dependency
|
||||
// hoists there at an older revision. The install still resolves the
|
||||
// imported package's cli.js from the harness's own directory (the same
|
||||
// algorithm as its import), not assume npm hoists `playwright` to the
|
||||
// root — a hoist nothing pins. cli.js is absent from the package's
|
||||
// exports map, so the workflow resolves the exported package.json and
|
||||
// joins; binding this assertion to the harness's import keeps the two
|
||||
// from drifting apart.
|
||||
// algorithm as its import) instead of assuming npm hoists `playwright`
|
||||
// to the root, so the binary keeps matching the import when the hoist
|
||||
// layout changes or a workspace nests its own copy. cli.js is absent
|
||||
// from the package's exports map, so the workflow resolves the exported
|
||||
// package.json and joins; binding this assertion to the harness's
|
||||
// import keeps the two from drifting apart.
|
||||
const capture = readFileSync(
|
||||
'integration-tests/terminal-capture/terminal-capture.ts',
|
||||
'utf8',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue