From 75a4206fa5762bb766b535ecf241d39b171c8da5 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Fri, 5 Jun 2026 17:28:27 +0900 Subject: [PATCH 1/5] feat(cli): add lockfile package-version parsing for all package managers Add the ability to resolve a single package's version from a project's lockfile, scoped to a workspace importer, for every supported package manager. Each PackageManagerDetector gains parsePackageVersionFromLockfile, backed by per-format parsers in lockfile-package-version.ts: - npm/cnpm: package-lock.json v2/v3 packages map (node_modules path walk) - pnpm: pnpm-lock.yaml importers (scalar v5 / map v6/v9, peer-suffix strip) - bun: bun.lock (JSONC; member-name-scoped packages entries) - yarn classic: custom line parser; yarn berry: YAML descriptors Adds the `yaml` dependency for the YAML-based formats. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/package.json | 3 +- .../lockfile-package-version.spec.ts | 248 +++++++++++++ .../package-files/lockfile-package-version.ts | 351 ++++++++++++++++++ .../package-files/package-manager.ts | 98 +++++ pnpm-lock.yaml | 35 +- 5 files changed, 718 insertions(+), 17 deletions(-) create mode 100644 packages/cli/src/services/check-parser/package-files/__tests__/lockfile-package-version.spec.ts create mode 100644 packages/cli/src/services/check-parser/package-files/lockfile-package-version.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index b3026b7c1..160e5474b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -137,7 +137,8 @@ "semver": "^7.8.1", "string-width": "^8.2.1", "tunnel": "^0.0.6", - "uuid": "^14.0.0" + "uuid": "^14.0.0", + "yaml": "^2.9.0" }, "devDependencies": { "@playwright/test": "^1.60.0", diff --git a/packages/cli/src/services/check-parser/package-files/__tests__/lockfile-package-version.spec.ts b/packages/cli/src/services/check-parser/package-files/__tests__/lockfile-package-version.spec.ts new file mode 100644 index 000000000..713b058ba --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/__tests__/lockfile-package-version.spec.ts @@ -0,0 +1,248 @@ +import { describe, it, expect } from 'vitest' + +import { + parseBunLockfileVersion, + parseNpmLockfileVersion, + parsePnpmLockfileVersion, + parseYarnLockfileVersion, +} from '../lockfile-package-version.js' + +const PKG = '@playwright/test' + +// All fixtures below mirror the real output of each package manager (verified +// by generating lockfiles for a workspace with two members declaring +// conflicting @playwright/test versions: the root and pkg-b on 1.40.0, pkg-a +// on 1.41.0). + +describe('parseNpmLockfileVersion (package-lock.json v2/v3)', () => { + const lockfile = JSON.stringify({ + name: 'root', + lockfileVersion: 3, + packages: { + '': { name: 'root', workspaces: ['packages/*'] }, + 'node_modules/@playwright/test': { version: '1.40.0' }, + 'packages/a/node_modules/@playwright/test': { version: '1.41.0' }, + 'packages/a': { name: 'pkg-a' }, + 'packages/b': { name: 'pkg-b' }, + }, + }) + + it('resolves the hoisted version for the root importer', () => { + expect(parseNpmLockfileVersion(lockfile, { packageName: PKG, importerRelPath: '.' })) + .toBe('1.40.0') + }) + + it('resolves a member-specific nested version', () => { + expect(parseNpmLockfileVersion(lockfile, { packageName: PKG, importerRelPath: 'packages/a' })) + .toBe('1.41.0') + }) + + it('walks up to the hoisted version when a member has no nested entry', () => { + expect(parseNpmLockfileVersion(lockfile, { packageName: PKG, importerRelPath: 'packages/b' })) + .toBe('1.40.0') + }) + + it('returns undefined when the package is absent', () => { + expect(parseNpmLockfileVersion(lockfile, { packageName: 'missing-pkg', importerRelPath: '.' })) + .toBeUndefined() + }) + + it('returns undefined for lockfileVersion 1 (no packages map)', () => { + const v1 = JSON.stringify({ + name: 'root', + lockfileVersion: 1, + dependencies: { '@playwright/test': { version: '1.40.0' } }, + }) + expect(parseNpmLockfileVersion(v1, { packageName: PKG, importerRelPath: '.' })) + .toBeUndefined() + }) +}) + +describe('parsePnpmLockfileVersion (pnpm-lock.yaml)', () => { + const lockfile = `lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + +importers: + + .: + devDependencies: + '@playwright/test': + specifier: 1.40.0 + version: 1.40.0 + + packages/a: + dependencies: + '@playwright/test': + specifier: 1.41.0 + version: 1.41.0 + + packages/b: + dependencies: + '@playwright/test': + specifier: 1.40.0 + version: 1.40.0 +` + + it('resolves the version for the root importer', () => { + expect(parsePnpmLockfileVersion(lockfile, { packageName: PKG, importerRelPath: '.' })) + .toBe('1.40.0') + }) + + it('resolves a member importer version', () => { + expect(parsePnpmLockfileVersion(lockfile, { packageName: PKG, importerRelPath: 'packages/a' })) + .toBe('1.41.0') + }) + + it('returns undefined for an unknown importer', () => { + expect(parsePnpmLockfileVersion(lockfile, { packageName: PKG, importerRelPath: 'packages/x' })) + .toBeUndefined() + }) + + it('strips a v6/v9 peer-dependency suffix', () => { + const withPeers = `lockfileVersion: '9.0' +importers: + .: + dependencies: + '@playwright/test': + specifier: ^1.40.0 + version: 1.40.0(@types/node@20.0.0)(typescript@5.4.0) +` + expect(parsePnpmLockfileVersion(withPeers, { packageName: PKG, importerRelPath: '.' })) + .toBe('1.40.0') + }) + + it('handles the v5 scalar dependency form with an underscore peer suffix', () => { + const v5 = `lockfileVersion: 5.4 +importers: + .: + specifiers: + '@playwright/test': ^1.40.0 + devDependencies: + '@playwright/test': 1.40.0_react@16.14.0 +` + expect(parsePnpmLockfileVersion(v5, { packageName: PKG, importerRelPath: '.' })) + .toBe('1.40.0') + }) +}) + +describe('parseBunLockfileVersion (bun.lock)', () => { + // bun.lock is JSONC: object keys are unquoted-where-possible and trailing + // commas are allowed. `packages` nested keys use the member's package name. + const lockfile = `{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "root", + "devDependencies": { "@playwright/test": "1.40.0", }, + }, + "packages/a": { + "name": "pkg-a", + "dependencies": { "@playwright/test": "1.41.0", }, + }, + }, + "packages": { + "@playwright/test": ["@playwright/test@1.40.0", "", { "dependencies": { "playwright": "1.40.0" } }, "sha512-abc=="], + "pkg-a/@playwright/test": ["@playwright/test@1.41.0", "", { "dependencies": { "playwright": "1.41.0" } }, "sha512-def=="], + }, +} +` + + it('resolves the hoisted version for the root importer', () => { + expect(parseBunLockfileVersion(lockfile, { packageName: PKG, importerRelPath: '.' })) + .toBe('1.40.0') + }) + + it('resolves a member-scoped version by package name', () => { + expect(parseBunLockfileVersion(lockfile, { packageName: PKG, importerRelPath: 'packages/a' })) + .toBe('1.41.0') + }) + + it('returns undefined when the package is absent', () => { + expect(parseBunLockfileVersion(lockfile, { packageName: 'missing-pkg', importerRelPath: '.' })) + .toBeUndefined() + }) +}) + +describe('parseYarnLockfileVersion (classic v1)', () => { + const lockfile = `# THIS IS AN AUTOGENERATED FILE. +# yarn lockfile v1 + + +"@playwright/test@1.41.0": + version "1.41.0" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.41.0.tgz#abc" + +"@playwright/test@^1.40.0": + version "1.60.0" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.60.0.tgz#def" +` + + it('matches the exact declared range', () => { + expect(parseYarnLockfileVersion(lockfile, { packageName: PKG, importerRelPath: 'packages/a', declaredRange: '1.41.0' })) + .toBe('1.41.0') + }) + + it('resolves a caret range to its locked version', () => { + expect(parseYarnLockfileVersion(lockfile, { packageName: PKG, importerRelPath: '.', declaredRange: '^1.40.0' })) + .toBe('1.60.0') + }) + + it('handles a merged multi-descriptor header', () => { + const merged = `# yarn lockfile v1 + +"@playwright/test@1.40.0", "@playwright/test@^1.40.0": + version "1.40.0" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.40.0.tgz#x" +` + expect(parseYarnLockfileVersion(merged, { packageName: PKG, importerRelPath: '.', declaredRange: '^1.40.0' })) + .toBe('1.40.0') + }) + + it('uses the sole resolution when no range is declared', () => { + const single = `# yarn lockfile v1 + +"@playwright/test@^1.40.0": + version "1.60.0" + resolved "x" +` + expect(parseYarnLockfileVersion(single, { packageName: PKG, importerRelPath: '.' })) + .toBe('1.60.0') + }) +}) + +describe('parseYarnLockfileVersion (berry v2+)', () => { + const lockfile = `# This file is generated by running "yarn install" +__metadata: + version: 8 + cacheKey: 10c0 + +"@playwright/test@npm:1.41.0": + version: 1.41.0 + resolution: "@playwright/test@npm:1.41.0" + languageName: node + linkType: hard + +"@playwright/test@npm:^1.40.0": + version: 1.60.0 + resolution: "@playwright/test@npm:1.60.0" + languageName: node + linkType: hard +` + + it('matches the exact declared range (npm protocol stripped)', () => { + expect(parseYarnLockfileVersion(lockfile, { packageName: PKG, importerRelPath: 'packages/a', declaredRange: '1.41.0' })) + .toBe('1.41.0') + }) + + it('resolves a caret range to its locked version', () => { + expect(parseYarnLockfileVersion(lockfile, { packageName: PKG, importerRelPath: '.', declaredRange: '^1.40.0' })) + .toBe('1.60.0') + }) + + it('returns undefined when ambiguous and no range is declared', () => { + expect(parseYarnLockfileVersion(lockfile, { packageName: PKG, importerRelPath: '.' })) + .toBeUndefined() + }) +}) diff --git a/packages/cli/src/services/check-parser/package-files/lockfile-package-version.ts b/packages/cli/src/services/check-parser/package-files/lockfile-package-version.ts new file mode 100644 index 000000000..b08775eea --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/lockfile-package-version.ts @@ -0,0 +1,351 @@ +import semver from 'semver' +import { parse as parseYaml } from 'yaml' +import JSON5 from 'json5' + +/** + * Describes the package we want to resolve a version for, scoped to a single + * workspace member (importer). Different lockfile formats key their data + * differently, so a query carries everything the various parsers might need: + * + * - npm and pnpm key by the importer's path relative to the workspace root. + * - bun keys nested entries by the importer's package *name*, which it stores + * in the lockfile keyed by relative path, so the bun parser can look it up. + * - yarn (classic and berry) has no importer concept in its lockfile; it keys + * resolutions by descriptor (`name@range`). We disambiguate using the range + * the importer declared in its package.json. + */ +export interface LockfilePackageQuery { + /** The package whose version we want, e.g. `@playwright/test`. */ + packageName: string + /** Importer path relative to the workspace root, POSIX, `.` for the root. */ + importerRelPath: string + /** The version range the importer declared for the package, if any. */ + declaredRange?: string +} + +/** + * Strips a pnpm peer-dependency suffix from a resolved version. + * + * pnpm encodes the peer set a dependency was resolved against directly into + * the version string. Lockfile v6/v9 use parenthesized suffixes + * (`1.40.0(react@18.2.0)`), while v5 used underscore suffixes + * (`1.40.0_react@16.14.0`). A plain version never contains either character, + * so cutting at the first one yields the bare version. + */ +function stripPnpmPeerSuffix (version: string): string { + const cut = version.search(/[(_]/) + return cut === -1 ? version : version.slice(0, cut) +} + +/** + * Resolves a package version from an npm `package-lock.json`. + * + * Only lockfileVersion 2 and 3 are supported, which key the flat `packages` + * map by node_modules path relative to the workspace root (e.g. + * `node_modules/@playwright/test` when hoisted, or + * `packages/a/node_modules/@playwright/test` when a member pins a conflicting + * version). We mimic Node's resolution by checking the importer's own + * node_modules first, then walking up to the root. lockfileVersion 1 (npm 6) + * only has a nested `dependencies` tree and is unsupported — we return + * `undefined` so the caller falls back to reading node_modules. + */ +export function parseNpmLockfileVersion ( + content: string, + query: LockfilePackageQuery, +): string | undefined { + const data = JSON5.parse(content) + const packages = data?.packages + if (typeof packages !== 'object' || packages === null) { + return undefined + } + + const rel = query.importerRelPath === '.' ? '' : query.importerRelPath + const segments = rel === '' ? [] : rel.split('/') + + // From the importer directory up to the workspace root, try each ancestor's + // node_modules. The first match wins, matching Node's upward resolution. + for (let depth = segments.length; depth >= 0; depth--) { + const prefix = segments.slice(0, depth).join('/') + const key = `${prefix ? `${prefix}/` : ''}node_modules/${query.packageName}` + const entry = packages[key] + if (entry && typeof entry.version === 'string') { + return entry.version + } + } + + return undefined +} + +/** + * Resolves a package version from a `pnpm-lock.yaml`. + * + * pnpm records a per-importer resolved version under + * `importers..{dependencies,devDependencies,optionalDependencies}`. + * The value is a bare version string (lockfile v5) or a `{ specifier, version }` + * map (v6/v9). Catalog entries still carry a resolved `version`, so they need + * no special handling. The version may carry a pnpm peer suffix, which we + * strip. + */ +export function parsePnpmLockfileVersion ( + content: string, + query: LockfilePackageQuery, +): string | undefined { + const data = parseYaml(content) + const importers = data?.importers + if (typeof importers !== 'object' || importers === null) { + return undefined + } + + const importer = importers[query.importerRelPath] + if (typeof importer !== 'object' || importer === null) { + return undefined + } + + for (const group of ['dependencies', 'devDependencies', 'optionalDependencies']) { + const dep = importer[group]?.[query.packageName] + if (dep === undefined) { + continue + } + const version = typeof dep === 'string' ? dep : dep?.version + if (typeof version === 'string') { + return stripPnpmPeerSuffix(version) + } + } + + return undefined +} + +/** + * Resolves a package version from a bun text lockfile (`bun.lock`). + * + * bun.lock is JSONC. The `packages` map keys entries by package name, prefixed + * with the consuming workspace member's *name* when a member pins a version + * distinct from the hoisted one (e.g. `pkg-a/@playwright/test`). The member + * name is recorded in `workspaces..name` (the root uses the `""` key). + * Each entry's value is an array whose first element is `name@version`. + * + * The binary lockfile (`bun.lockb`) is not handled here; the caller skips it. + */ +export function parseBunLockfileVersion ( + content: string, + query: LockfilePackageQuery, +): string | undefined { + const data = JSON5.parse(content) + const packages = data?.packages + if (typeof packages !== 'object' || packages === null) { + return undefined + } + + // Resolve the importer's package name so we can probe a member-scoped entry + // before the hoisted one. + let importerName: string | undefined + const workspaces = data.workspaces + if (typeof workspaces === 'object' && workspaces !== null) { + const wsKey = query.importerRelPath === '.' ? '' : query.importerRelPath + importerName = workspaces[wsKey]?.name + } + + const candidates: string[] = [] + if (importerName) { + candidates.push(`${importerName}/${query.packageName}`) + } + candidates.push(query.packageName) + + const prefix = `${query.packageName}@` + for (const key of candidates) { + const entry = packages[key] + if (Array.isArray(entry) && typeof entry[0] === 'string' && entry[0].startsWith(prefix)) { + return entry[0].slice(prefix.length) + } + } + + return undefined +} + +interface YarnResolution { + /** The declared ranges (npm protocol stripped) that resolve to `version`. */ + ranges: string[] + version: string +} + +/** + * Parses a single yarn descriptor (`name@range`) into its name and range. + * + * Handles scoped names (the `@` prefix) by splitting on the last `@`. For yarn + * berry, the range carries a protocol (`npm:^1.40.0`); we strip the `npm:` + * protocol so it compares against the plain range from package.json. Non-npm + * protocols (`patch:`, `workspace:`, `portal:`, …) are left intact and simply + * won't match an npm range. + */ +function parseYarnDescriptor (descriptor: string): { name: string, range: string } | undefined { + const at = descriptor.lastIndexOf('@') + if (at <= 0) { + return undefined + } + const name = descriptor.slice(0, at) + let range = descriptor.slice(at + 1) + const colon = range.indexOf(':') + if (colon !== -1 && range.slice(0, colon) === 'npm') { + range = range.slice(colon + 1) + } + return { name, range } +} + +/** + * Picks the best version from the yarn resolutions matching our package. + * + * yarn keys resolutions by descriptor, and a workspace may resolve several + * versions of the same package (one per declared range). We prefer the entry + * whose declared range exactly matches the importer's — that's the precise + * answer, since yarn records the descriptor verbatim from package.json. If + * there's no exact match we fall back to the highest version that satisfies + * the range, and finally to the sole entry when the package resolves to just + * one version. + */ +function pickYarnVersion (resolutions: YarnResolution[], declaredRange?: string): string | undefined { + if (resolutions.length === 0) { + return undefined + } + + if (declaredRange !== undefined) { + const exact = resolutions.find(resolution => resolution.ranges.includes(declaredRange)) + if (exact) { + return exact.version + } + + const satisfying = resolutions.filter(resolution => { + try { + return semver.satisfies(resolution.version, declaredRange) + } catch { + return false + } + }) + if (satisfying.length > 0) { + return satisfying.reduce((best, candidate) => + semver.gt(candidate.version, best.version) ? candidate : best, + ).version + } + } + + if (resolutions.length === 1) { + return resolutions[0].version + } + + return undefined +} + +/** + * Resolves a package version from a yarn berry (v2+) `yarn.lock`, which is YAML. + * + * Entries are keyed by one or more comma-separated descriptors + * (`"@playwright/test@npm:^1.40.0"`) and carry a `version` field. We collect + * the declared ranges per resolved version and disambiguate by the importer's + * declared range. + */ +function parseYarnBerryLockfileVersion ( + content: string, + query: LockfilePackageQuery, +): string | undefined { + const data = parseYaml(content) + if (typeof data !== 'object' || data === null) { + return undefined + } + + const resolutions: YarnResolution[] = [] + for (const [key, value] of Object.entries(data as Record)) { + if (key === '__metadata') { + continue + } + const version = (value as { version?: unknown })?.version + if (typeof version !== 'string') { + continue + } + const ranges = key + .split(',') + .map(descriptor => parseYarnDescriptor(descriptor.trim())) + .filter((parsed): parsed is { name: string, range: string } => + parsed !== undefined && parsed.name === query.packageName) + .map(parsed => parsed.range) + if (ranges.length > 0) { + resolutions.push({ ranges, version }) + } + } + + return pickYarnVersion(resolutions, query.declaredRange) +} + +/** + * Resolves a package version from a yarn classic (v1) `yarn.lock`. + * + * The classic format is not YAML. Each resolution is a block whose header is an + * unindented, comma-separated list of (optionally quoted) descriptors ending in + * `:`, followed by indented fields including `version "x.y.z"`. + */ +function parseYarnClassicLockfileVersion ( + content: string, + query: LockfilePackageQuery, +): string | undefined { + const resolutions: YarnResolution[] = [] + const lines = content.split(/\r?\n/) + + let index = 0 + while (index < lines.length) { + const line = lines[index] + + // A header is an unindented, non-comment line ending with ':'. + const isHeader = line.length > 0 + && !line.startsWith(' ') + && !line.startsWith('#') + && line.trimEnd().endsWith(':') + + if (!isHeader) { + index++ + continue + } + + const header = line.trimEnd().slice(0, -1) + const ranges = header + .split(',') + .map(descriptor => parseYarnDescriptor(descriptor.trim().replace(/^"|"$/g, ''))) + .filter((parsed): parsed is { name: string, range: string } => + parsed !== undefined && parsed.name === query.packageName) + .map(parsed => parsed.range) + + // Scan the indented body for the version field, stopping at the next + // header (the next unindented, non-empty line). + let version: string | undefined + index++ + while (index < lines.length) { + const bodyLine = lines[index] + if (bodyLine.length > 0 && !bodyLine.startsWith(' ')) { + break + } + const match = bodyLine.match(/^\s+version:?\s+"?([^"]+?)"?\s*$/) + if (match) { + version = match[1] + } + index++ + } + + if (ranges.length > 0 && version !== undefined) { + resolutions.push({ ranges, version }) + } + } + + return pickYarnVersion(resolutions, query.declaredRange) +} + +/** + * Resolves a package version from a `yarn.lock`, dispatching to the classic or + * berry parser. Berry lockfiles carry a `__metadata:` block; classic ones do + * not. + */ +export function parseYarnLockfileVersion ( + content: string, + query: LockfilePackageQuery, +): string | undefined { + if (/^__metadata:/m.test(content)) { + return parseYarnBerryLockfileVersion(content, query) + } + return parseYarnClassicLockfileVersion(content, query) +} diff --git a/packages/cli/src/services/check-parser/package-files/package-manager.ts b/packages/cli/src/services/check-parser/package-files/package-manager.ts index ae2f04da6..b84228adb 100644 --- a/packages/cli/src/services/check-parser/package-files/package-manager.ts +++ b/packages/cli/src/services/check-parser/package-files/package-manager.ts @@ -9,6 +9,13 @@ import { PackageJsonFile } from './package-json-file.js' import { JsonSourceFile } from './json-source-file.js' import { OptionalWorkspaceFile, Package, Workspace, WorkspaceOptions } from './workspace.js' import { Err, Ok } from './result.js' +import { + LockfilePackageQuery, + parseBunLockfileVersion, + parseNpmLockfileVersion, + parsePnpmLockfileVersion, + parseYarnLockfileVersion, +} from './lockfile-package-version.js' export class Runnable { executable: string @@ -37,11 +44,46 @@ export interface PackageManager { addCommand (options: AddCommandOptions): Runnable execCommand (args: string[]): Runnable lookupWorkspace (dir: string): Promise + /** + * Resolves the version of a single package as recorded in the package + * manager's lockfile, scoped to a workspace importer. Returns `undefined` + * when the lockfile can't be read or parsed, the package isn't present, or + * the format isn't supported — the caller is expected to fall back to + * another source (e.g. reading the installed package). + */ + parsePackageVersionFromLockfile ( + lockfilePath: string, + query: LockfilePackageQuery, + ): Promise detector (): PackageManagerDetector } class NotDetectedError extends Error {} +/** + * Reads a lockfile and runs a format-specific parser over it, swallowing IO + * and parse errors so detection degrades to the caller's fallback rather than + * throwing on an unreadable or unexpected lockfile. + */ +async function parseLockfileWith ( + lockfilePath: string, + query: LockfilePackageQuery, + parse: (content: string, query: LockfilePackageQuery) => string | undefined, +): Promise { + let content: string + try { + content = await fs.readFile(lockfilePath, 'utf8') + } catch { + return undefined + } + + try { + return parse(content, query) + } catch { + return undefined + } +} + export type DetectionMethod = | 'userAgent' | 'runtime' @@ -82,6 +124,21 @@ export abstract class PackageManagerDetector { abstract addCommand (options: AddCommandOptions): Runnable abstract execCommand (args: string[]): Runnable abstract lookupWorkspace (dir: string): Promise + + /** + * Default: lockfile parsing is unsupported, so callers fall back. Package + * managers that can parse their lockfile override this. + */ + // eslint-disable-next-line require-await + async parsePackageVersionFromLockfile ( + lockfilePath: string, + query: LockfilePackageQuery, + ): Promise { + void lockfilePath + void query + return undefined + } + detector (): PackageManagerDetector { return this } @@ -144,6 +201,13 @@ export class NpmDetector extends PackageManagerDetector implements PackageManage async lookupWorkspace (dir: string): Promise { return await lookupNearestPackageJsonWorkspace(this, dir) } + + async parsePackageVersionFromLockfile ( + lockfilePath: string, + query: LockfilePackageQuery, + ): Promise { + return await parseLockfileWith(lockfilePath, query, parseNpmLockfileVersion) + } } export class CNpmDetector extends PackageManagerDetector implements PackageManager { @@ -203,6 +267,14 @@ export class CNpmDetector extends PackageManagerDetector implements PackageManag async lookupWorkspace (dir: string): Promise { return await lookupNearestPackageJsonWorkspace(this, dir) } + + async parsePackageVersionFromLockfile ( + lockfilePath: string, + query: LockfilePackageQuery, + ): Promise { + // cnpm shares npm's package-lock.json format. + return await parseLockfileWith(lockfilePath, query, parseNpmLockfileVersion) + } } export class PNpmDetector extends PackageManagerDetector implements PackageManager { @@ -326,6 +398,13 @@ export class PNpmDetector extends PackageManagerDetector implements PackageManag }) } } + + async parsePackageVersionFromLockfile ( + lockfilePath: string, + query: LockfilePackageQuery, + ): Promise { + return await parseLockfileWith(lockfilePath, query, parsePnpmLockfileVersion) + } } export class YarnDetector extends PackageManagerDetector implements PackageManager { @@ -381,6 +460,13 @@ export class YarnDetector extends PackageManagerDetector implements PackageManag async lookupWorkspace (dir: string): Promise { return await lookupNearestPackageJsonWorkspace(this, dir) } + + async parsePackageVersionFromLockfile ( + lockfilePath: string, + query: LockfilePackageQuery, + ): Promise { + return await parseLockfileWith(lockfilePath, query, parseYarnLockfileVersion) + } } export class DenoDetector extends PackageManagerDetector implements PackageManager { @@ -534,6 +620,18 @@ export class BunDetector extends PackageManagerDetector implements PackageManage async lookupWorkspace (dir: string): Promise { return await lookupNearestPackageJsonWorkspace(this, dir) } + + async parsePackageVersionFromLockfile ( + lockfilePath: string, + query: LockfilePackageQuery, + ): Promise { + // The legacy binary lockfile (bun.lockb) isn't parseable here; only the + // text format (bun.lock) is. Skip the binary form so the caller falls back. + if (lockfilePath.endsWith('.lockb')) { + return undefined + } + return await parseLockfileWith(lockfilePath, query, parseBunLockfileVersion) + } } async function accessR (filePath: string): Promise { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d48a76ca6..a5af198ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,9 @@ importers: uuid: specifier: ^14.0.0 version: 14.0.0 + yaml: + specifier: ^2.9.0 + version: 2.9.0 devDependencies: '@playwright/test': specifier: ^1.60.0 @@ -197,7 +200,7 @@ importers: version: 6.0.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.8.4) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.9.0) packages/create-cli: dependencies: @@ -270,7 +273,7 @@ importers: version: 14.0.0 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.8.4) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.9.0) packages: @@ -3157,8 +3160,8 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} - yaml@2.8.4: - resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true @@ -4272,13 +4275,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.3(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.8.4))': + '@vitest/mocker@3.2.4(vite@7.3.3(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.3(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.8.4) + vite: 7.3.3(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.9.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -5332,7 +5335,7 @@ snapshots: picomatch: 4.0.4 string-argv: 0.3.2 tinyexec: 1.1.2 - yaml: 2.8.4 + yaml: 2.9.0 listr2@9.0.5: dependencies: @@ -6067,13 +6070,13 @@ snapshots: validate-npm-package-name@5.0.1: {} - vite-node@3.2.4(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.8.4): + vite-node@3.2.4(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.9.0): dependencies: cac: 6.7.14 debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.3(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.8.4) + vite: 7.3.3(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -6088,7 +6091,7 @@ snapshots: - tsx - yaml - vite@7.3.3(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.8.4): + vite@7.3.3(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -6100,13 +6103,13 @@ snapshots: '@types/node': 22.19.18 fsevents: 2.3.3 jiti: 2.7.0 - yaml: 2.8.4 + yaml: 2.9.0 - vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.8.4): + vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.3(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.8.4)) + '@vitest/mocker': 3.2.4(vite@7.3.3(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -6124,8 +6127,8 @@ snapshots: tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.3(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.8.4) - vite-node: 3.2.4(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.8.4) + vite: 7.3.3(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@22.19.18)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.13 @@ -6221,7 +6224,7 @@ snapshots: yallist@5.0.0: {} - yaml@2.8.4: {} + yaml@2.9.0: {} yargs-parser@21.1.1: {} From 2b88f8d8994270f9c01d380fb6380d8f8f429b01 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Fri, 5 Jun 2026 17:28:40 +0900 Subject: [PATCH 2/5] fix(cli): detect @playwright/test version from lockfile, not node_modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Playwright version uploaded to the cloud was read from the locally installed @playwright/test, which can drift from the project's lockfile (e.g. after switching branches without reinstalling). Resolve it from the workspace lockfile instead — the version CI and other developers get — scoped to the workspace member that owns the Playwright config. resolvePlaywrightVersion prefers the lockfile and falls back to the old node_modules read when no lockfile answer is available (no/unsupported lockfile, package not pinned). Warns when a package.json range no longer satisfies the lockfile version (stale lockfile). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../playwright-project-bundler.spec.ts | 80 ++++++++++- .../services/playwright-project-bundler.ts | 125 +++++++++++++++++- 2 files changed, 202 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/services/__tests__/playwright-project-bundler.spec.ts b/packages/cli/src/services/__tests__/playwright-project-bundler.spec.ts index 8a8bda01d..7b989ad08 100644 --- a/packages/cli/src/services/__tests__/playwright-project-bundler.spec.ts +++ b/packages/cli/src/services/__tests__/playwright-project-bundler.spec.ts @@ -1,13 +1,20 @@ +import fs from 'node:fs/promises' +import os from 'node:os' import path from 'node:path' -import { describe, it, expect } from 'vitest' + +import { afterEach, describe, it, expect } from 'vitest' import { getAutoIncludes, getPlaywrightVersionFromPackage, PlaywrightProjectBundle, PlaywrightProjectBundler, + resolvePlaywrightVersion, } from '../playwright-project-bundler.js' -import { PackageManager } from '../check-parser/package-files/package-manager.js' +import { PackageManager, PNpmDetector } from '../check-parser/package-files/package-manager.js' +import { Package, Workspace } from '../check-parser/package-files/workspace.js' +import { Err, Ok } from '../check-parser/package-files/result.js' +import { Session } from '../../constructs/session.js' // A promise we can resolve from the outside, to hold a bundle "in flight" // while we issue concurrent calls — keeps the dedup test deterministic. @@ -166,3 +173,72 @@ describe('getPlaywrightVersionFromPackage()', () => { expect(version).toMatch(/^\d+\.\d+\.\d+/) }) }) + +describe('resolvePlaywrightVersion()', () => { + afterEach(() => { + Session.reset() + }) + + // Builds a single-package project on disk with a pnpm lockfile pinning one + // version and an *installed* node_modules pinning a different one, then wires + // up Session as project-parser would. This reproduces a local install that + // has drifted from the lockfile (e.g. switching branches without + // reinstalling), where the lockfile must win over the stale install. + async function setupProject (lockfileVersion: string, installedVersion: string): Promise { + const root = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-pw-version-')), + ) + + await fs.writeFile( + path.join(root, 'package.json'), + JSON.stringify({ + name: 'project', + version: '1.0.0', + devDependencies: { '@playwright/test': '^1.40.0' }, + }), + ) + + await fs.writeFile( + path.join(root, 'pnpm-lock.yaml'), + `lockfileVersion: '9.0'\n` + + `importers:\n` + + ` .:\n` + + ` devDependencies:\n` + + ` '@playwright/test':\n` + + ` specifier: ^1.40.0\n` + + ` version: ${lockfileVersion}\n`, + ) + + const installedDir = path.join(root, 'node_modules', '@playwright', 'test') + await fs.mkdir(installedDir, { recursive: true }) + await fs.writeFile( + path.join(installedDir, 'package.json'), + JSON.stringify({ name: '@playwright/test', version: installedVersion }), + ) + + const workspace = new Workspace({ + root: new Package({ name: 'project', path: root }), + packages: [], + lockfile: Ok(path.join(root, 'pnpm-lock.yaml')), + configFile: Err(new Error('none')), + }) + + Session.packageManager = new PNpmDetector() + Session.workspace = Ok(workspace) + + return root + } + + it('prefers the lockfile version over the installed node_modules version', async () => { + const root = await setupProject('1.41.0', '1.40.0') + const version = await resolvePlaywrightVersion(root) + expect(version).toBe('1.41.0') + }) + + it('falls back to the installed package when no workspace lockfile is available', async () => { + // Session left at its default (no workspace), so the lockfile path is + // skipped and we read the installed package in the cwd. + const version = await resolvePlaywrightVersion(process.cwd()) + expect(version).toMatch(/^\d+\.\d+\.\d+/) + }) +}) diff --git a/packages/cli/src/services/playwright-project-bundler.ts b/packages/cli/src/services/playwright-project-bundler.ts index 6e62d233b..9d9d06b70 100644 --- a/packages/cli/src/services/playwright-project-bundler.ts +++ b/packages/cli/src/services/playwright-project-bundler.ts @@ -5,6 +5,7 @@ import semver from 'semver' import { File } from './check-parser/parser.js' import { detectNearestPackageJson, PackageManager } from './check-parser/package-files/package-manager.js' +import { PackageJsonFile } from './check-parser/package-files/package-json-file.js' import { PlaywrightConfig } from './playwright-config.js' import { findFilesWithPattern, pathToPosix } from './util.js' import { Session } from '../constructs/session.js' @@ -49,7 +50,7 @@ export class PlaywrightProjectBundler { const pwConfigParsed = new PlaywrightConfig(filePath, pwtConfig) - const playwrightVersion = await getPlaywrightVersionFromPackage(dir) + const playwrightVersion = await resolvePlaywrightVersion(dir) const parser = Session.getPlaywrightParser() const { files, errors } = await parser.getFilesAndDependencies(pwConfigParsed) @@ -134,6 +135,128 @@ export function getAutoIncludes ( return autoIncludes } +const PLAYWRIGHT_TEST = '@playwright/test' + +/** + * Resolves the @playwright/test version that should run in the cloud. + * + * The project's lockfile is the source of truth: the version it pins is what + * CI and other developers resolve, and it stays correct even when the local + * node_modules has drifted (e.g. after switching branches without + * reinstalling). When no usable lockfile answer is available — no lockfile, an + * unsupported/unparseable format, or the package isn't pinned for the relevant + * workspace member — we fall back to reading the installed package. + */ +export async function resolvePlaywrightVersion (cwd: string): Promise { + const lockfileVersion = await getPlaywrightVersionFromLockfile(cwd) + if (lockfileVersion !== undefined) { + return lockfileVersion + } + + return await getPlaywrightVersionFromPackage(cwd) +} + +function playwrightRange (packageJson: PackageJsonFile): string | undefined { + return packageJson.dependencies?.[PLAYWRIGHT_TEST] + ?? packageJson.devDependencies?.[PLAYWRIGHT_TEST] +} + +/** + * Resolves the @playwright/test version from the workspace lockfile, scoped to + * the workspace member that owns the Playwright config. Returns `undefined` + * when no answer can be derived from the lockfile, signalling the caller to + * fall back. + */ +async function getPlaywrightVersionFromLockfile (cwd: string): Promise { + const workspaceResult = Session.workspace + if (!workspaceResult.isOk()) { + return undefined + } + + const workspace = workspaceResult.unwrap() + if (!workspace.lockfile.isOk()) { + return undefined + } + + const lockfilePath = workspace.lockfile.unwrap() + const packageManager = Session.packageManager + + // The Playwright config belongs to the nearest package.json at or above its + // directory — that's the workspace importer whose pinned version applies. + let consumingPackageJson: PackageJsonFile + try { + consumingPackageJson = await detectNearestPackageJson(cwd, { root: workspace.root.path }) + } catch { + return undefined + } + + const importerRelPath = toImporterRelPath(workspace.root.path, consumingPackageJson.basePath) + const declaredRange = playwrightRange(consumingPackageJson) + + // The root package.json range disambiguates yarn resolutions and covers the + // case where the member relies on a dependency hoisted from the root. + let rootRange: string | undefined + if (importerRelPath !== '.') { + try { + const rootPackageJson = await detectNearestPackageJson(workspace.root.path, { + root: workspace.root.path, + }) + rootRange = playwrightRange(rootPackageJson) + } catch { + // No root package.json — leave rootRange undefined. + } + } + + let raw = await packageManager.parsePackageVersionFromLockfile(lockfilePath, { + packageName: PLAYWRIGHT_TEST, + importerRelPath, + declaredRange: declaredRange ?? rootRange, + }) + + // If the member doesn't pin it directly, fall back to the root importer. + if (raw === undefined && importerRelPath !== '.') { + raw = await packageManager.parsePackageVersionFromLockfile(lockfilePath, { + packageName: PLAYWRIGHT_TEST, + importerRelPath: '.', + declaredRange: rootRange, + }) + } + + if (raw === undefined) { + return undefined + } + + const version = normalizeVersion(raw) + if (version === undefined) { + return undefined + } + + // Drift guard: a declared range the lockfile version doesn't satisfy means + // package.json was changed without re-resolving the lockfile. The cloud runs + // the lockfile version, so warn rather than silently using a stale pin. + const range = declaredRange ?? rootRange + if (range !== undefined) { + const validRange = semver.validRange(range) + if (validRange && !semver.satisfies(version, validRange)) { + process.stderr.write( + `Warning: lockfile @playwright/test version ${version} does not satisfy the range ` + + `"${range}" declared in package.json. The lockfile may be out of date; run your ` + + `package manager's install command to update it.\n`, + ) + } + } + + return version +} + +function toImporterRelPath (rootPath: string, packagePath: string): string { + const rel = path.relative(rootPath, packagePath) + if (rel === '' || rel === '.') { + return '.' + } + return pathToPosix(rel) +} + export async function getPlaywrightVersionFromPackage (cwd: string): Promise { try { const require = createRequire(path.join(cwd, 'noop.js')) From c1e50692f1693b7abb83bd3209264fb8aabdd284 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Fri, 5 Jun 2026 21:28:37 +0900 Subject: [PATCH 3/5] refactor(cli): reuse PLAYWRIGHT_TEST constant and playwrightRange helper getPlaywrightVersionFromPackage now uses the shared PLAYWRIGHT_TEST constant for the resolve specifier and the playwrightRange helper for the declared range, instead of repeating the '@playwright/test' literal. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/services/playwright-project-bundler.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/services/playwright-project-bundler.ts b/packages/cli/src/services/playwright-project-bundler.ts index 9d9d06b70..527297eed 100644 --- a/packages/cli/src/services/playwright-project-bundler.ts +++ b/packages/cli/src/services/playwright-project-bundler.ts @@ -260,7 +260,7 @@ function toImporterRelPath (rootPath: string, packagePath: string): string { export async function getPlaywrightVersionFromPackage (cwd: string): Promise { try { const require = createRequire(path.join(cwd, 'noop.js')) - const playwrightPath = require.resolve('@playwright/test/package.json') + const playwrightPath = require.resolve(`${PLAYWRIGHT_TEST}/package.json`) const playwrightPkg = require(playwrightPath) const version = normalizeVersion(playwrightPkg.version) @@ -269,9 +269,7 @@ export async function getPlaywrightVersionFromPackage (cwd: string): Promise Date: Fri, 5 Jun 2026 23:33:45 +0900 Subject: [PATCH 4/5] fix(cli): resolve Playwright version across the full importer ancestry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lockfile version detection only checked the nearest workspace importer and the workspace root. In a legal pnpm layout where a workspace package is physically nested inside another member's directory and declares no @playwright/test, Node resolves the version from the enclosing member, not the root — so the previous logic reported the wrong version (a regression versus the old require.resolve behavior). Resolution now walks the full importer ancestry (config dir → workspace root), mirroring Node's upward module resolution. The query carries an ordered importer list and each format resolves accordingly: npm's physical node_modules-path walk already covers ancestors; pnpm picks the nearest declaring importer; bun probes every member-scoped key before the hoisted fallback (two-phase); yarn tries each ancestor's declared range. The orchestrator realpaths the config dir and workspace root, and anchors the drift warning on the consuming package's range. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../playwright-project-bundler.spec.ts | 53 +++++ .../lockfile-package-version.spec.ts | 179 ++++++++++++--- .../package-files/lockfile-package-version.ts | 210 +++++++++++------- .../services/playwright-project-bundler.ts | 91 ++++---- 4 files changed, 382 insertions(+), 151 deletions(-) diff --git a/packages/cli/src/services/__tests__/playwright-project-bundler.spec.ts b/packages/cli/src/services/__tests__/playwright-project-bundler.spec.ts index 7b989ad08..212ba9b07 100644 --- a/packages/cli/src/services/__tests__/playwright-project-bundler.spec.ts +++ b/packages/cli/src/services/__tests__/playwright-project-bundler.spec.ts @@ -241,4 +241,57 @@ describe('resolvePlaywrightVersion()', () => { const version = await resolvePlaywrightVersion(process.cwd()) expect(version).toMatch(/^\d+\.\d+\.\d+/) }) + + it('resolves the enclosing member version for a nested non-declaring config package', async () => { + // Workspace where `other-package` (holding the Playwright config) is + // physically nested inside `some-package` and declares no @playwright/test. + // Node resolves the version from the enclosing `some-package` (1.41.0), not + // the root (1.40.0) — and so must we. + const root = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-pw-nested-')), + ) + const somePackage = path.join(root, 'packages', 'some-package') + const otherPackage = path.join(somePackage, 'more-packages', 'other-package') + await fs.mkdir(otherPackage, { recursive: true }) + + await fs.writeFile(path.join(root, 'package.json'), + JSON.stringify({ name: 'root', version: '1.0.0', devDependencies: { '@playwright/test': '1.40.0' } })) + await fs.writeFile(path.join(somePackage, 'package.json'), + JSON.stringify({ name: 'some-package', version: '1.0.0', dependencies: { '@playwright/test': '1.41.0' } })) + await fs.writeFile(path.join(otherPackage, 'package.json'), + JSON.stringify({ name: 'other-package', version: '1.0.0' })) + + await fs.writeFile( + path.join(root, 'pnpm-lock.yaml'), + `lockfileVersion: '9.0'\n` + + `importers:\n` + + ` .:\n` + + ` devDependencies:\n` + + ` '@playwright/test':\n` + + ` specifier: 1.40.0\n` + + ` version: 1.40.0\n` + + ` packages/some-package:\n` + + ` dependencies:\n` + + ` '@playwright/test':\n` + + ` specifier: 1.41.0\n` + + ` version: 1.41.0\n` + + ` packages/some-package/more-packages/other-package: {}\n`, + ) + + const workspace = new Workspace({ + root: new Package({ name: 'root', path: root }), + packages: [ + new Package({ name: 'some-package', path: somePackage }), + new Package({ name: 'other-package', path: otherPackage }), + ], + lockfile: Ok(path.join(root, 'pnpm-lock.yaml')), + configFile: Err(new Error('none')), + }) + + Session.packageManager = new PNpmDetector() + Session.workspace = Ok(workspace) + + const version = await resolvePlaywrightVersion(otherPackage) + expect(version).toBe('1.41.0') + }) }) diff --git a/packages/cli/src/services/check-parser/package-files/__tests__/lockfile-package-version.spec.ts b/packages/cli/src/services/check-parser/package-files/__tests__/lockfile-package-version.spec.ts index 713b058ba..47ba6207d 100644 --- a/packages/cli/src/services/check-parser/package-files/__tests__/lockfile-package-version.spec.ts +++ b/packages/cli/src/services/check-parser/package-files/__tests__/lockfile-package-version.spec.ts @@ -10,9 +10,9 @@ import { const PKG = '@playwright/test' // All fixtures below mirror the real output of each package manager (verified -// by generating lockfiles for a workspace with two members declaring -// conflicting @playwright/test versions: the root and pkg-b on 1.40.0, pkg-a -// on 1.41.0). +// by generating lockfiles for workspaces with members declaring conflicting +// versions, including a member physically nested inside another member's +// directory that declares nothing of its own). describe('parseNpmLockfileVersion (package-lock.json v2/v3)', () => { const lockfile = JSON.stringify({ @@ -28,22 +28,51 @@ describe('parseNpmLockfileVersion (package-lock.json v2/v3)', () => { }) it('resolves the hoisted version for the root importer', () => { - expect(parseNpmLockfileVersion(lockfile, { packageName: PKG, importerRelPath: '.' })) + expect(parseNpmLockfileVersion(lockfile, { packageName: PKG, importers: [{ relPath: '.' }] })) .toBe('1.40.0') }) it('resolves a member-specific nested version', () => { - expect(parseNpmLockfileVersion(lockfile, { packageName: PKG, importerRelPath: 'packages/a' })) - .toBe('1.41.0') + expect(parseNpmLockfileVersion(lockfile, { + packageName: PKG, + importers: [{ relPath: 'packages/a' }, { relPath: '.' }], + })).toBe('1.41.0') }) it('walks up to the hoisted version when a member has no nested entry', () => { - expect(parseNpmLockfileVersion(lockfile, { packageName: PKG, importerRelPath: 'packages/b' })) - .toBe('1.40.0') + expect(parseNpmLockfileVersion(lockfile, { + packageName: PKG, + importers: [{ relPath: 'packages/b' }, { relPath: '.' }], + })).toBe('1.40.0') + }) + + it('resolves the enclosing version for a deeply-nested non-declaring importer', () => { + // other-package is physically nested inside some-package, declares nothing, + // and npm placed some-package's conflicting copy under its node_modules. + // Node's upward walk finds it before the root — and so must the parser. + const nested = JSON.stringify({ + name: 'root', + lockfileVersion: 3, + packages: { + '': { name: 'root' }, + 'node_modules/@playwright/test': { version: '1.40.0' }, + 'packages/some-package/node_modules/@playwright/test': { version: '1.41.0' }, + 'packages/some-package': { name: 'some-package' }, + 'packages/some-package/more-packages/other-package': { name: 'other-package' }, + }, + }) + expect(parseNpmLockfileVersion(nested, { + packageName: PKG, + importers: [ + { relPath: 'packages/some-package/more-packages/other-package' }, + { relPath: 'packages/some-package' }, + { relPath: '.' }, + ], + })).toBe('1.41.0') }) it('returns undefined when the package is absent', () => { - expect(parseNpmLockfileVersion(lockfile, { packageName: 'missing-pkg', importerRelPath: '.' })) + expect(parseNpmLockfileVersion(lockfile, { packageName: 'missing-pkg', importers: [{ relPath: '.' }] })) .toBeUndefined() }) @@ -53,7 +82,7 @@ describe('parseNpmLockfileVersion (package-lock.json v2/v3)', () => { lockfileVersion: 1, dependencies: { '@playwright/test': { version: '1.40.0' } }, }) - expect(parseNpmLockfileVersion(v1, { packageName: PKG, importerRelPath: '.' })) + expect(parseNpmLockfileVersion(v1, { packageName: PKG, importers: [{ relPath: '.' }] })) .toBeUndefined() }) }) @@ -86,17 +115,47 @@ importers: ` it('resolves the version for the root importer', () => { - expect(parsePnpmLockfileVersion(lockfile, { packageName: PKG, importerRelPath: '.' })) + expect(parsePnpmLockfileVersion(lockfile, { packageName: PKG, importers: [{ relPath: '.' }] })) .toBe('1.40.0') }) it('resolves a member importer version', () => { - expect(parsePnpmLockfileVersion(lockfile, { packageName: PKG, importerRelPath: 'packages/a' })) - .toBe('1.41.0') + expect(parsePnpmLockfileVersion(lockfile, { + packageName: PKG, + importers: [{ relPath: 'packages/a' }, { relPath: '.' }], + })).toBe('1.41.0') + }) + + it('resolves the nearest declaring importer for a nested non-declaring importer', () => { + // other-package declares nothing; with pnpm's isolated linker Node resolves + // the package from the physically-enclosing some-package, not the root. + const nested = `lockfileVersion: '9.0' +importers: + .: + dependencies: + '@playwright/test': + specifier: 1.40.0 + version: 1.40.0 + packages/some-package: + dependencies: + '@playwright/test': + specifier: 1.41.0 + version: 1.41.0 + packages/some-package/more-packages/other-package: {} +` + expect(parsePnpmLockfileVersion(nested, { + packageName: PKG, + importers: [ + { relPath: 'packages/some-package/more-packages/other-package' }, + { relPath: 'packages/some-package/more-packages' }, + { relPath: 'packages/some-package' }, + { relPath: '.' }, + ], + })).toBe('1.41.0') }) it('returns undefined for an unknown importer', () => { - expect(parsePnpmLockfileVersion(lockfile, { packageName: PKG, importerRelPath: 'packages/x' })) + expect(parsePnpmLockfileVersion(lockfile, { packageName: PKG, importers: [{ relPath: 'packages/x' }] })) .toBeUndefined() }) @@ -109,7 +168,7 @@ importers: specifier: ^1.40.0 version: 1.40.0(@types/node@20.0.0)(typescript@5.4.0) ` - expect(parsePnpmLockfileVersion(withPeers, { packageName: PKG, importerRelPath: '.' })) + expect(parsePnpmLockfileVersion(withPeers, { packageName: PKG, importers: [{ relPath: '.' }] })) .toBe('1.40.0') }) @@ -122,14 +181,15 @@ importers: devDependencies: '@playwright/test': 1.40.0_react@16.14.0 ` - expect(parsePnpmLockfileVersion(v5, { packageName: PKG, importerRelPath: '.' })) + expect(parsePnpmLockfileVersion(v5, { packageName: PKG, importers: [{ relPath: '.' }] })) .toBe('1.40.0') }) }) describe('parseBunLockfileVersion (bun.lock)', () => { // bun.lock is JSONC: object keys are unquoted-where-possible and trailing - // commas are allowed. `packages` nested keys use the member's package name. + // commas are allowed. `packages` keys a member's override by the declaring + // member's name. const lockfile = `{ "lockfileVersion": 1, "workspaces": { @@ -150,17 +210,47 @@ describe('parseBunLockfileVersion (bun.lock)', () => { ` it('resolves the hoisted version for the root importer', () => { - expect(parseBunLockfileVersion(lockfile, { packageName: PKG, importerRelPath: '.' })) + expect(parseBunLockfileVersion(lockfile, { packageName: PKG, importers: [{ relPath: '.' }] })) .toBe('1.40.0') }) it('resolves a member-scoped version by package name', () => { - expect(parseBunLockfileVersion(lockfile, { packageName: PKG, importerRelPath: 'packages/a' })) - .toBe('1.41.0') + expect(parseBunLockfileVersion(lockfile, { + packageName: PKG, + importers: [{ relPath: 'packages/a' }, { relPath: '.' }], + })).toBe('1.41.0') + }) + + it('resolves the enclosing member override for a nested non-declaring importer', () => { + // other-package declares nothing; bun keys some-package's override by its + // name. The two-phase lookup must probe every member-scoped key before the + // hoisted one, or it would wrongly return the root-hoisted version. + const nested = `{ + "lockfileVersion": 1, + "workspaces": { + "": { "name": "root", "dependencies": { "@playwright/test": "1.40.0", }, }, + "packages/some-package": { "name": "some-package", "dependencies": { "@playwright/test": "1.41.0", }, }, + "packages/some-package/more-packages/other-package": { "name": "other-package", }, + }, + "packages": { + "@playwright/test": ["@playwright/test@1.40.0", "", {}, "sha512-abc=="], + "some-package/@playwright/test": ["@playwright/test@1.41.0", "", {}, "sha512-def=="], + }, +} +` + expect(parseBunLockfileVersion(nested, { + packageName: PKG, + importers: [ + { relPath: 'packages/some-package/more-packages/other-package' }, + { relPath: 'packages/some-package/more-packages' }, + { relPath: 'packages/some-package' }, + { relPath: '.' }, + ], + })).toBe('1.41.0') }) it('returns undefined when the package is absent', () => { - expect(parseBunLockfileVersion(lockfile, { packageName: 'missing-pkg', importerRelPath: '.' })) + expect(parseBunLockfileVersion(lockfile, { packageName: 'missing-pkg', importers: [{ relPath: '.' }] })) .toBeUndefined() }) }) @@ -180,13 +270,30 @@ describe('parseYarnLockfileVersion (classic v1)', () => { ` it('matches the exact declared range', () => { - expect(parseYarnLockfileVersion(lockfile, { packageName: PKG, importerRelPath: 'packages/a', declaredRange: '1.41.0' })) - .toBe('1.41.0') + expect(parseYarnLockfileVersion(lockfile, { + packageName: PKG, + importers: [{ relPath: 'packages/a', declaredRange: '1.41.0' }, { relPath: '.', declaredRange: '^1.40.0' }], + })).toBe('1.41.0') }) it('resolves a caret range to its locked version', () => { - expect(parseYarnLockfileVersion(lockfile, { packageName: PKG, importerRelPath: '.', declaredRange: '^1.40.0' })) - .toBe('1.60.0') + expect(parseYarnLockfileVersion(lockfile, { + packageName: PKG, + importers: [{ relPath: '.', declaredRange: '^1.40.0' }], + })).toBe('1.60.0') + }) + + it('uses an ancestor range when the consuming importer declares nothing', () => { + // other-package declares nothing; the resolution comes from an ancestor's + // declared range. + expect(parseYarnLockfileVersion(lockfile, { + packageName: PKG, + importers: [ + { relPath: 'packages/some-package/more-packages/other-package' }, + { relPath: 'packages/some-package', declaredRange: '1.41.0' }, + { relPath: '.', declaredRange: '^1.40.0' }, + ], + })).toBe('1.41.0') }) it('handles a merged multi-descriptor header', () => { @@ -196,8 +303,10 @@ describe('parseYarnLockfileVersion (classic v1)', () => { version "1.40.0" resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.40.0.tgz#x" ` - expect(parseYarnLockfileVersion(merged, { packageName: PKG, importerRelPath: '.', declaredRange: '^1.40.0' })) - .toBe('1.40.0') + expect(parseYarnLockfileVersion(merged, { + packageName: PKG, + importers: [{ relPath: '.', declaredRange: '^1.40.0' }], + })).toBe('1.40.0') }) it('uses the sole resolution when no range is declared', () => { @@ -207,7 +316,7 @@ describe('parseYarnLockfileVersion (classic v1)', () => { version "1.60.0" resolved "x" ` - expect(parseYarnLockfileVersion(single, { packageName: PKG, importerRelPath: '.' })) + expect(parseYarnLockfileVersion(single, { packageName: PKG, importers: [{ relPath: '.' }] })) .toBe('1.60.0') }) }) @@ -232,17 +341,21 @@ __metadata: ` it('matches the exact declared range (npm protocol stripped)', () => { - expect(parseYarnLockfileVersion(lockfile, { packageName: PKG, importerRelPath: 'packages/a', declaredRange: '1.41.0' })) - .toBe('1.41.0') + expect(parseYarnLockfileVersion(lockfile, { + packageName: PKG, + importers: [{ relPath: 'packages/a', declaredRange: '1.41.0' }, { relPath: '.', declaredRange: '^1.40.0' }], + })).toBe('1.41.0') }) it('resolves a caret range to its locked version', () => { - expect(parseYarnLockfileVersion(lockfile, { packageName: PKG, importerRelPath: '.', declaredRange: '^1.40.0' })) - .toBe('1.60.0') + expect(parseYarnLockfileVersion(lockfile, { + packageName: PKG, + importers: [{ relPath: '.', declaredRange: '^1.40.0' }], + })).toBe('1.60.0') }) it('returns undefined when ambiguous and no range is declared', () => { - expect(parseYarnLockfileVersion(lockfile, { packageName: PKG, importerRelPath: '.' })) + expect(parseYarnLockfileVersion(lockfile, { packageName: PKG, importers: [{ relPath: '.' }] })) .toBeUndefined() }) }) diff --git a/packages/cli/src/services/check-parser/package-files/lockfile-package-version.ts b/packages/cli/src/services/check-parser/package-files/lockfile-package-version.ts index b08775eea..c8dc0d9d7 100644 --- a/packages/cli/src/services/check-parser/package-files/lockfile-package-version.ts +++ b/packages/cli/src/services/check-parser/package-files/lockfile-package-version.ts @@ -3,24 +3,31 @@ import { parse as parseYaml } from 'yaml' import JSON5 from 'json5' /** - * Describes the package we want to resolve a version for, scoped to a single - * workspace member (importer). Different lockfile formats key their data - * differently, so a query carries everything the various parsers might need: + * One candidate importer to resolve a package version against, identified by + * its path relative to the workspace root (POSIX, `.` for the root) and the + * version range it declares for the package, if any. + */ +export interface ImporterCandidate { + relPath: string + declaredRange?: string +} + +/** + * Describes the package we want to resolve a version for. * - * - npm and pnpm key by the importer's path relative to the workspace root. - * - bun keys nested entries by the importer's package *name*, which it stores - * in the lockfile keyed by relative path, so the bun parser can look it up. - * - yarn (classic and berry) has no importer concept in its lockfile; it keys - * resolutions by descriptor (`name@range`). We disambiguate using the range - * the importer declared in its package.json. + * `importers` is the chain of candidate importers ordered from nearest (the + * workspace member that owns the Playwright config, or a deeper directory) up + * to the workspace root. This mirrors Node's upward module resolution: a + * package required from a deep directory resolves to the first `node_modules` + * found walking up the tree, which may belong to a physically-enclosing + * workspace member rather than the root. Each lockfile format uses this chain + * differently (see the individual parsers). */ export interface LockfilePackageQuery { /** The package whose version we want, e.g. `@playwright/test`. */ packageName: string - /** Importer path relative to the workspace root, POSIX, `.` for the root. */ - importerRelPath: string - /** The version range the importer declared for the package, if any. */ - declaredRange?: string + /** Candidate importers, ordered nearest → workspace root. */ + importers: ImporterCandidate[] } /** @@ -44,10 +51,14 @@ function stripPnpmPeerSuffix (version: string): string { * map by node_modules path relative to the workspace root (e.g. * `node_modules/@playwright/test` when hoisted, or * `packages/a/node_modules/@playwright/test` when a member pins a conflicting - * version). We mimic Node's resolution by checking the importer's own - * node_modules first, then walking up to the root. lockfileVersion 1 (npm 6) - * only has a nested `dependencies` tree and is unsupported — we return - * `undefined` so the caller falls back to reading node_modules. + * version). lockfileVersion 1 (npm 6) only has a nested `dependencies` tree and + * is unsupported — we return `undefined` so the caller falls back. + * + * The map keys are physical node_modules paths, so resolution is a single + * lexical walk up the path segments of the *nearest* importer — that walk + * already visits every ancestor up to the root and finds the enclosing copy. + * The walk, not the candidate list, is the resolution mechanism; iterating the + * other candidates would be redundant. */ export function parseNpmLockfileVersion ( content: string, @@ -59,7 +70,12 @@ export function parseNpmLockfileVersion ( return undefined } - const rel = query.importerRelPath === '.' ? '' : query.importerRelPath + const nearest = query.importers[0] + if (nearest === undefined) { + return undefined + } + + const rel = nearest.relPath === '.' ? '' : nearest.relPath const segments = rel === '' ? [] : rel.split('/') // From the importer directory up to the workspace root, try each ancestor's @@ -85,6 +101,13 @@ export function parseNpmLockfileVersion ( * map (v6/v9). Catalog entries still carry a resolved `version`, so they need * no special handling. The version may carry a pnpm peer suffix, which we * strip. + * + * We walk the importer chain nearest → root and return the first importer that + * declares the package. With pnpm's default isolated linker a package required + * from a non-declaring member resolves up the filesystem to the nearest + * enclosing member that does declare it — which is exactly the nearest + * declaring importer in this chain. (The `node-linker=hoisted` and PnP layouts + * can diverge; those degrade to the caller's node_modules fallback.) */ export function parsePnpmLockfileVersion ( content: string, @@ -96,19 +119,21 @@ export function parsePnpmLockfileVersion ( return undefined } - const importer = importers[query.importerRelPath] - if (typeof importer !== 'object' || importer === null) { - return undefined - } - - for (const group of ['dependencies', 'devDependencies', 'optionalDependencies']) { - const dep = importer[group]?.[query.packageName] - if (dep === undefined) { + for (const candidate of query.importers) { + const importer = importers[candidate.relPath] + if (typeof importer !== 'object' || importer === null) { continue } - const version = typeof dep === 'string' ? dep : dep?.version - if (typeof version === 'string') { - return stripPnpmPeerSuffix(version) + + for (const group of ['dependencies', 'devDependencies', 'optionalDependencies']) { + const dep = importer[group]?.[query.packageName] + if (dep === undefined) { + continue + } + const version = typeof dep === 'string' ? dep : dep?.version + if (typeof version === 'string') { + return stripPnpmPeerSuffix(version) + } } } @@ -118,11 +143,17 @@ export function parsePnpmLockfileVersion ( /** * Resolves a package version from a bun text lockfile (`bun.lock`). * - * bun.lock is JSONC. The `packages` map keys entries by package name, prefixed - * with the consuming workspace member's *name* when a member pins a version - * distinct from the hoisted one (e.g. `pkg-a/@playwright/test`). The member - * name is recorded in `workspaces..name` (the root uses the `""` key). - * Each entry's value is an array whose first element is `name@version`. + * bun.lock is JSONC. The `packages` map keys the hoisted copy by bare name + * (`@playwright/test`) and a member's pinned override by the declaring member's + * *name* (`some-package/@playwright/test`). Member names are recorded in + * `workspaces..name` (the root uses the `""` key). Each entry's value + * is an array whose first element is `name@version`. + * + * Resolution is two-phase: probe every candidate's member-scoped key nearest → + * root first, and only fall back to the hoisted key once all member-scoped + * probes miss. A naive per-candidate "first hit wins" would return the hoisted + * version as soon as the nearest (non-declaring) member is reached, masking a + * deeper member's pinned override that Node would actually resolve. * * The binary lockfile (`bun.lockb`) is not handled here; the caller skips it. */ @@ -136,30 +167,34 @@ export function parseBunLockfileVersion ( return undefined } - // Resolve the importer's package name so we can probe a member-scoped entry - // before the hoisted one. - let importerName: string | undefined - const workspaces = data.workspaces - if (typeof workspaces === 'object' && workspaces !== null) { - const wsKey = query.importerRelPath === '.' ? '' : query.importerRelPath - importerName = workspaces[wsKey]?.name - } - - const candidates: string[] = [] - if (importerName) { - candidates.push(`${importerName}/${query.packageName}`) - } - candidates.push(query.packageName) + const workspaces = typeof data.workspaces === 'object' && data.workspaces !== null + ? data.workspaces + : {} const prefix = `${query.packageName}@` - for (const key of candidates) { + const readVersion = (key: string): string | undefined => { const entry = packages[key] if (Array.isArray(entry) && typeof entry[0] === 'string' && entry[0].startsWith(prefix)) { return entry[0].slice(prefix.length) } + return undefined } - return undefined + // Phase 1: member-scoped overrides, nearest → root. + for (const candidate of query.importers) { + const wsKey = candidate.relPath === '.' ? '' : candidate.relPath + const name = workspaces[wsKey]?.name + if (typeof name !== 'string') { + continue + } + const version = readVersion(`${name}/${query.packageName}`) + if (version !== undefined) { + return version + } + } + + // Phase 2: the hoisted copy. + return readVersion(query.packageName) } interface YarnResolution { @@ -192,15 +227,15 @@ function parseYarnDescriptor (descriptor: string): { name: string, range: string } /** - * Picks the best version from the yarn resolutions matching our package. + * Picks the best version from the yarn resolutions matching our package, given + * a single declared range. * * yarn keys resolutions by descriptor, and a workspace may resolve several * versions of the same package (one per declared range). We prefer the entry - * whose declared range exactly matches the importer's — that's the precise - * answer, since yarn records the descriptor verbatim from package.json. If - * there's no exact match we fall back to the highest version that satisfies - * the range, and finally to the sole entry when the package resolves to just - * one version. + * whose declared range exactly matches — that's the precise answer, since yarn + * records the descriptor verbatim from package.json. If there's no exact match + * we fall back to the highest version that satisfies the range, and finally to + * the sole entry when the package resolves to just one version. */ function pickYarnVersion (resolutions: YarnResolution[], declaredRange?: string): string | undefined { if (resolutions.length === 0) { @@ -235,20 +270,37 @@ function pickYarnVersion (resolutions: YarnResolution[], declaredRange?: string) } /** - * Resolves a package version from a yarn berry (v2+) `yarn.lock`, which is YAML. + * Resolves a version from yarn resolutions across the importer chain. * - * Entries are keyed by one or more comma-separated descriptors - * (`"@playwright/test@npm:^1.40.0"`) and carry a `version` field. We collect - * the declared ranges per resolved version and disambiguate by the importer's - * declared range. + * A yarn lockfile records no importer/placement information — only descriptor → + * version resolutions — so we can only disambiguate by declared range. We try + * each importer's declared range nearest → root, and finally fall back to the + * sole resolution when only one version exists. This is best-effort; the + * caller's node_modules fallback covers cases yarn's lockfile can't express. */ -function parseYarnBerryLockfileVersion ( - content: string, - query: LockfilePackageQuery, -): string | undefined { +function resolveYarnVersion (resolutions: YarnResolution[], query: LockfilePackageQuery): string | undefined { + for (const candidate of query.importers) { + if (candidate.declaredRange === undefined) { + continue + } + const version = pickYarnVersion(resolutions, candidate.declaredRange) + if (version !== undefined) { + return version + } + } + + return pickYarnVersion(resolutions, undefined) +} + +/** + * Parses the resolutions for `packageName` from a yarn berry (v2+) `yarn.lock`, + * which is YAML. Entries are keyed by one or more comma-separated descriptors + * (`"@playwright/test@npm:^1.40.0"`) and carry a `version` field. + */ +function parseYarnBerryResolutions (content: string, packageName: string): YarnResolution[] { const data = parseYaml(content) if (typeof data !== 'object' || data === null) { - return undefined + return [] } const resolutions: YarnResolution[] = [] @@ -264,27 +316,24 @@ function parseYarnBerryLockfileVersion ( .split(',') .map(descriptor => parseYarnDescriptor(descriptor.trim())) .filter((parsed): parsed is { name: string, range: string } => - parsed !== undefined && parsed.name === query.packageName) + parsed !== undefined && parsed.name === packageName) .map(parsed => parsed.range) if (ranges.length > 0) { resolutions.push({ ranges, version }) } } - return pickYarnVersion(resolutions, query.declaredRange) + return resolutions } /** - * Resolves a package version from a yarn classic (v1) `yarn.lock`. + * Parses the resolutions for `packageName` from a yarn classic (v1) `yarn.lock`. * * The classic format is not YAML. Each resolution is a block whose header is an * unindented, comma-separated list of (optionally quoted) descriptors ending in * `:`, followed by indented fields including `version "x.y.z"`. */ -function parseYarnClassicLockfileVersion ( - content: string, - query: LockfilePackageQuery, -): string | undefined { +function parseYarnClassicResolutions (content: string, packageName: string): YarnResolution[] { const resolutions: YarnResolution[] = [] const lines = content.split(/\r?\n/) @@ -308,7 +357,7 @@ function parseYarnClassicLockfileVersion ( .split(',') .map(descriptor => parseYarnDescriptor(descriptor.trim().replace(/^"|"$/g, ''))) .filter((parsed): parsed is { name: string, range: string } => - parsed !== undefined && parsed.name === query.packageName) + parsed !== undefined && parsed.name === packageName) .map(parsed => parsed.range) // Scan the indented body for the version field, stopping at the next @@ -332,20 +381,21 @@ function parseYarnClassicLockfileVersion ( } } - return pickYarnVersion(resolutions, query.declaredRange) + return resolutions } /** * Resolves a package version from a `yarn.lock`, dispatching to the classic or * berry parser. Berry lockfiles carry a `__metadata:` block; classic ones do - * not. + * not. The lockfile is parsed once, then resolved across the importer chain. */ export function parseYarnLockfileVersion ( content: string, query: LockfilePackageQuery, ): string | undefined { - if (/^__metadata:/m.test(content)) { - return parseYarnBerryLockfileVersion(content, query) - } - return parseYarnClassicLockfileVersion(content, query) + const resolutions = /^__metadata:/m.test(content) + ? parseYarnBerryResolutions(content, query.packageName) + : parseYarnClassicResolutions(content, query.packageName) + + return resolveYarnVersion(resolutions, query) } diff --git a/packages/cli/src/services/playwright-project-bundler.ts b/packages/cli/src/services/playwright-project-bundler.ts index 527297eed..02981813e 100644 --- a/packages/cli/src/services/playwright-project-bundler.ts +++ b/packages/cli/src/services/playwright-project-bundler.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs/promises' import { createRequire } from 'node:module' import path from 'node:path' @@ -6,6 +7,8 @@ import semver from 'semver' import { File } from './check-parser/parser.js' import { detectNearestPackageJson, PackageManager } from './check-parser/package-files/package-manager.js' import { PackageJsonFile } from './check-parser/package-files/package-json-file.js' +import { ImporterCandidate } from './check-parser/package-files/lockfile-package-version.js' +import { lineage } from './check-parser/package-files/walk.js' import { PlaywrightConfig } from './playwright-config.js' import { findFilesWithPattern, pathToPosix } from './util.js' import { Session } from '../constructs/session.js' @@ -181,47 +184,59 @@ async function getPlaywrightVersionFromLockfile (cwd: string): Promise /private/tmp). + let configDir: string + let workspaceRoot: string try { - consumingPackageJson = await detectNearestPackageJson(cwd, { root: workspace.root.path }) + configDir = await fs.realpath(cwd) + workspaceRoot = await fs.realpath(workspace.root.path) } catch { return undefined } - const importerRelPath = toImporterRelPath(workspace.root.path, consumingPackageJson.basePath) - const declaredRange = playwrightRange(consumingPackageJson) + // Walk from the config directory up to the workspace root, building the chain + // of candidate importers Node would consult when resolving the package from + // the config directory. Resolution must mirror that walk: a package required + // from a member that doesn't declare it resolves to a physically-enclosing + // member's copy, not necessarily the root's. + const importers: ImporterCandidate[] = [] + let consumingRange: string | undefined + let reachedRoot = false + for (const dir of lineage(configDir, { root: workspaceRoot })) { + // A directory contributes a declared range only when it actually has a + // package.json declaring the dep. Intermediate directories still + // participate (by position) so npm's physical node_modules walk and pnpm's + // importer lookup see them. + const packageJson = await PackageJsonFile.loadFromFilePath(PackageJsonFile.filePath(dir)) + const declaredRange = packageJson ? playwrightRange(packageJson) : undefined + + // The consuming package is the nearest package.json at or above the config; + // its declared range anchors the drift check below. + if (consumingRange === undefined && declaredRange !== undefined) { + consumingRange = declaredRange + } - // The root package.json range disambiguates yarn resolutions and covers the - // case where the member relies on a dependency hoisted from the root. - let rootRange: string | undefined - if (importerRelPath !== '.') { - try { - const rootPackageJson = await detectNearestPackageJson(workspace.root.path, { - root: workspace.root.path, - }) - rootRange = playwrightRange(rootPackageJson) - } catch { - // No root package.json — leave rootRange undefined. + importers.push({ relPath: toImporterRelPath(workspaceRoot, dir), declaredRange }) + + if (dir === workspaceRoot) { + reachedRoot = true + break } } - let raw = await packageManager.parsePackageVersionFromLockfile(lockfilePath, { + // If the walk never reached the workspace root, the relative paths don't + // describe importers under this workspace — don't trust them. + if (!reachedRoot) { + return undefined + } + + const raw = await packageManager.parsePackageVersionFromLockfile(lockfilePath, { packageName: PLAYWRIGHT_TEST, - importerRelPath, - declaredRange: declaredRange ?? rootRange, + importers, }) - // If the member doesn't pin it directly, fall back to the root importer. - if (raw === undefined && importerRelPath !== '.') { - raw = await packageManager.parsePackageVersionFromLockfile(lockfilePath, { - packageName: PLAYWRIGHT_TEST, - importerRelPath: '.', - declaredRange: rootRange, - }) - } - if (raw === undefined) { return undefined } @@ -231,17 +246,17 @@ async function getPlaywrightVersionFromLockfile (cwd: string): Promise Date: Sat, 6 Jun 2026 00:56:29 +0900 Subject: [PATCH 5/5] refactor(cli): rename parsePackageVersionFromLockfile to resolvePackageVersionFromLockfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The method reads the lockfile and resolves a version across the importer chain (mirroring Node's upward resolution), not merely parses — "resolve" reflects that and reserves "parse" for the per-format helpers it delegates to. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../check-parser/package-files/package-manager.ts | 14 +++++++------- .../cli/src/services/playwright-project-bundler.ts | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/services/check-parser/package-files/package-manager.ts b/packages/cli/src/services/check-parser/package-files/package-manager.ts index b84228adb..22cdfe9ac 100644 --- a/packages/cli/src/services/check-parser/package-files/package-manager.ts +++ b/packages/cli/src/services/check-parser/package-files/package-manager.ts @@ -51,7 +51,7 @@ export interface PackageManager { * the format isn't supported — the caller is expected to fall back to * another source (e.g. reading the installed package). */ - parsePackageVersionFromLockfile ( + resolvePackageVersionFromLockfile ( lockfilePath: string, query: LockfilePackageQuery, ): Promise @@ -130,7 +130,7 @@ export abstract class PackageManagerDetector { * managers that can parse their lockfile override this. */ // eslint-disable-next-line require-await - async parsePackageVersionFromLockfile ( + async resolvePackageVersionFromLockfile ( lockfilePath: string, query: LockfilePackageQuery, ): Promise { @@ -202,7 +202,7 @@ export class NpmDetector extends PackageManagerDetector implements PackageManage return await lookupNearestPackageJsonWorkspace(this, dir) } - async parsePackageVersionFromLockfile ( + async resolvePackageVersionFromLockfile ( lockfilePath: string, query: LockfilePackageQuery, ): Promise { @@ -268,7 +268,7 @@ export class CNpmDetector extends PackageManagerDetector implements PackageManag return await lookupNearestPackageJsonWorkspace(this, dir) } - async parsePackageVersionFromLockfile ( + async resolvePackageVersionFromLockfile ( lockfilePath: string, query: LockfilePackageQuery, ): Promise { @@ -399,7 +399,7 @@ export class PNpmDetector extends PackageManagerDetector implements PackageManag } } - async parsePackageVersionFromLockfile ( + async resolvePackageVersionFromLockfile ( lockfilePath: string, query: LockfilePackageQuery, ): Promise { @@ -461,7 +461,7 @@ export class YarnDetector extends PackageManagerDetector implements PackageManag return await lookupNearestPackageJsonWorkspace(this, dir) } - async parsePackageVersionFromLockfile ( + async resolvePackageVersionFromLockfile ( lockfilePath: string, query: LockfilePackageQuery, ): Promise { @@ -621,7 +621,7 @@ export class BunDetector extends PackageManagerDetector implements PackageManage return await lookupNearestPackageJsonWorkspace(this, dir) } - async parsePackageVersionFromLockfile ( + async resolvePackageVersionFromLockfile ( lockfilePath: string, query: LockfilePackageQuery, ): Promise { diff --git a/packages/cli/src/services/playwright-project-bundler.ts b/packages/cli/src/services/playwright-project-bundler.ts index 02981813e..6b06d2fd4 100644 --- a/packages/cli/src/services/playwright-project-bundler.ts +++ b/packages/cli/src/services/playwright-project-bundler.ts @@ -232,7 +232,7 @@ async function getPlaywrightVersionFromLockfile (cwd: string): Promise