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/__tests__/playwright-project-bundler.spec.ts b/packages/cli/src/services/__tests__/playwright-project-bundler.spec.ts index 8a8bda01d..212ba9b07 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,125 @@ 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+/) + }) + + 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 new file mode 100644 index 000000000..47ba6207d --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/__tests__/lockfile-package-version.spec.ts @@ -0,0 +1,361 @@ +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 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({ + 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, importers: [{ relPath: '.' }] })) + .toBe('1.40.0') + }) + + it('resolves a member-specific nested version', () => { + 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, + 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', importers: [{ relPath: '.' }] })) + .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, importers: [{ relPath: '.' }] })) + .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, importers: [{ relPath: '.' }] })) + .toBe('1.40.0') + }) + + it('resolves a member importer version', () => { + 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, importers: [{ relPath: '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, importers: [{ relPath: '.' }] })) + .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, 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` keys a member's override by the declaring + // member's 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, importers: [{ relPath: '.' }] })) + .toBe('1.40.0') + }) + + it('resolves a member-scoped version by package name', () => { + 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', importers: [{ relPath: '.' }] })) + .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, + 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, + 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', () => { + 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, + importers: [{ relPath: '.', 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, importers: [{ relPath: '.' }] })) + .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, + 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, + 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, 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 new file mode 100644 index 000000000..c8dc0d9d7 --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/lockfile-package-version.ts @@ -0,0 +1,401 @@ +import semver from 'semver' +import { parse as parseYaml } from 'yaml' +import JSON5 from 'json5' + +/** + * 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. + * + * `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 + /** Candidate importers, ordered nearest → workspace root. */ + importers: ImporterCandidate[] +} + +/** + * 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). 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, + query: LockfilePackageQuery, +): string | undefined { + const data = JSON5.parse(content) + const packages = data?.packages + if (typeof packages !== 'object' || packages === null) { + return undefined + } + + 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 + // 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. + * + * 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, + query: LockfilePackageQuery, +): string | undefined { + const data = parseYaml(content) + const importers = data?.importers + if (typeof importers !== 'object' || importers === null) { + return undefined + } + + for (const candidate of query.importers) { + const importer = importers[candidate.relPath] + if (typeof importer !== 'object' || importer === null) { + continue + } + + 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 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. + */ +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 + } + + const workspaces = typeof data.workspaces === 'object' && data.workspaces !== null + ? data.workspaces + : {} + + const prefix = `${query.packageName}@` + 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 + } + + // 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 { + /** 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, 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 — 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 version from yarn resolutions across the importer chain. + * + * 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 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 [] + } + + 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 === packageName) + .map(parsed => parsed.range) + if (ranges.length > 0) { + resolutions.push({ ranges, version }) + } + } + + return resolutions +} + +/** + * 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 parseYarnClassicResolutions (content: string, packageName: string): YarnResolution[] { + 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 === 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 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. The lockfile is parsed once, then resolved across the importer chain. + */ +export function parseYarnLockfileVersion ( + content: string, + query: LockfilePackageQuery, +): string | undefined { + 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/check-parser/package-files/package-manager.ts b/packages/cli/src/services/check-parser/package-files/package-manager.ts index ae2f04da6..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 @@ -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). + */ + resolvePackageVersionFromLockfile ( + 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 resolvePackageVersionFromLockfile ( + 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 resolvePackageVersionFromLockfile ( + 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 resolvePackageVersionFromLockfile ( + 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 resolvePackageVersionFromLockfile ( + 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 resolvePackageVersionFromLockfile ( + 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 resolvePackageVersionFromLockfile ( + 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/packages/cli/src/services/playwright-project-bundler.ts b/packages/cli/src/services/playwright-project-bundler.ts index 6e62d233b..6b06d2fd4 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' @@ -5,6 +6,9 @@ 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' @@ -49,7 +53,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,10 +138,144 @@ 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 + + // Normalize both paths through realpath so the directory walk and the + // relative importer paths line up with the workspace root even when the + // config is reached through a symlink (e.g. macOS /tmp -> /private/tmp). + let configDir: string + let workspaceRoot: string + try { + configDir = await fs.realpath(cwd) + workspaceRoot = await fs.realpath(workspace.root.path) + } catch { + return undefined + } + + // 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 + } + + importers.push({ relPath: toImporterRelPath(workspaceRoot, dir), declaredRange }) + + if (dir === workspaceRoot) { + reachedRoot = true + break + } + } + + // 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.resolvePackageVersionFromLockfile(lockfilePath, { + packageName: PLAYWRIGHT_TEST, + importers, + }) + + if (raw === undefined) { + return undefined + } + + const version = normalizeVersion(raw) + if (version === undefined) { + return undefined + } + + // Drift guard: if the consuming package declares a range the resolved version + // doesn't satisfy, its package.json was changed without re-resolving the + // lockfile (or the install is otherwise inconsistent). The cloud runs the + // resolved version, so warn rather than silently using a mismatched pin. + if (consumingRange !== undefined) { + const validRange = semver.validRange(consumingRange) + if (validRange && !semver.satisfies(version, validRange)) { + process.stderr.write( + `Warning: resolved @playwright/test version ${version} does not satisfy the range ` + + `"${consumingRange}" 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')) - 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) @@ -146,9 +284,7 @@ export async function getPlaywrightVersionFromPackage (cwd: string): Promise=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: {}