diff --git a/.github/workflows/qwen-triage.yml b/.github/workflows/qwen-triage.yml index cccc7a3c5f..65919860bc 100644 --- a/.github/workflows/qwen-triage.yml +++ b/.github/workflows/qwen-triage.yml @@ -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" diff --git a/docs/users/features/followup-suggestions.md b/docs/users/features/followup-suggestions.md index 2023adea0e..80ee359bd7 100644 --- a/docs/users/features/followup-suggestions.md +++ b/docs/users/features/followup-suggestions.md @@ -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: diff --git a/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.test.mjs b/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.test.mjs index c1a89fdaec..c006ed9eb1 100644 --- a/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.test.mjs +++ b/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.test.mjs @@ -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); +}); diff --git a/package-lock.json b/package-lock.json index 284bdffacf..25d01a0853 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/web-shell/package.json b/packages/web-shell/package.json index 7bcdb161e9..596cd0a974 100644 --- a/packages/web-shell/package.json +++ b/packages/web-shell/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 684fca6864..100567292e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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:* diff --git a/scripts/check-lockfile.js b/scripts/check-lockfile.js index 2d092a1cfa..ddc5cb0dae 100644 --- a/scripts/check-lockfile.js +++ b/scripts/check-lockfile.js @@ -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.'); +} diff --git a/scripts/tests/check-lockfile.test.js b/scripts/tests/check-lockfile.test.js new file mode 100644 index 0000000000..bcc4021e2a --- /dev/null +++ b/scripts/tests/check-lockfile.test.js @@ -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(); + } + }); +}); diff --git a/scripts/tests/package-scripts.test.js b/scripts/tests/package-scripts.test.js index a3559d8a1d..013e2107e1 100644 --- a/scripts/tests/package-scripts.test.js +++ b/scripts/tests/package-scripts.test.js @@ -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', () => { diff --git a/scripts/tests/qwen-triage-workflow.test.js b/scripts/tests/qwen-triage-workflow.test.js index 8a09642c3e..7d8dbaa6b1 100644 --- a/scripts/tests/qwen-triage-workflow.test.js +++ b/scripts/tests/qwen-triage-workflow.test.js @@ -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',