Skip to content

Commit 7166ed8

Browse files
committed
feat(cli): resolve per-workingDir Playwright version from the fixture's lockfile
RED-625 Phase 3: make the CLI the source of truth for each playwrightChecks entry's Playwright version. When an entry has a workingDir, resolve @playwright/test from that fixture's own lockfile (the nearest one walking up, bounded strictly below the monorepo/Session.workspace lockfile) instead of collapsing every fixture to the monorepo catalog version.
1 parent 7b9cf29 commit 7166ed8

2 files changed

Lines changed: 303 additions & 15 deletions

File tree

packages/cli/src/services/__tests__/playwright-project-bundler.spec.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,4 +317,145 @@ describe('resolvePlaywrightVersion()', () => {
317317
const version = await resolvePlaywrightVersion(otherPackage)
318318
expect(version).toBe('1.41.0')
319319
})
320+
321+
// Builds a monorepo root that owns a pnpm workspace lockfile pinning
322+
// `monorepoVersion`, and wires it up as Session.workspace exactly like
323+
// project-parser would for the checkly monorepo. Per-entry working dirs live
324+
// in subdirectories *below* this root; each test adds its own fixture under
325+
// `root` with its own self-contained lockfile. The fix must resolve each
326+
// fixture's own version, never collapsing to this monorepo version.
327+
async function setupMonorepo (monorepoVersion: string): Promise<string> {
328+
const root = await fs.realpath(
329+
await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-pw-monorepo-')),
330+
)
331+
332+
await fs.writeFile(
333+
path.join(root, 'package.json'),
334+
JSON.stringify({
335+
name: 'monorepo',
336+
version: '1.0.0',
337+
devDependencies: { '@playwright/test': `${monorepoVersion}` },
338+
}),
339+
)
340+
341+
await fs.writeFile(
342+
path.join(root, 'pnpm-lock.yaml'),
343+
`lockfileVersion: '9.0'\n`
344+
+ `importers:\n`
345+
+ ` .:\n`
346+
+ ` devDependencies:\n`
347+
+ ` '@playwright/test':\n`
348+
+ ` specifier: ${monorepoVersion}\n`
349+
+ ` version: ${monorepoVersion}\n`,
350+
)
351+
352+
const workspace = new Workspace({
353+
root: new Package({ name: 'monorepo', path: root }),
354+
packages: [],
355+
lockfile: Ok(path.join(root, 'pnpm-lock.yaml')),
356+
configFile: Err(new Error('none')),
357+
})
358+
359+
Session.packageManager = new PNpmDetector()
360+
Session.workspace = Ok(workspace)
361+
362+
return root
363+
}
364+
365+
it('resolves a self-contained fixture from its own (yarn) lockfile, not the monorepo lockfile', async () => {
366+
// The monorepo pins 1.40.0; a per-entry workingDir fixture has its OWN
367+
// yarn.lock + package.json pinning 1.49.0. The fixture's lockfile is the
368+
// source of truth for that entry — note the fixture even uses a *different*
369+
// package manager (yarn) than the monorepo (pnpm), so we must detect the
370+
// fixture's package manager rather than reuse Session.packageManager.
371+
const root = await setupMonorepo('1.40.0')
372+
373+
const fixtureDir = path.join(root, 'fixtures', 'yarn-app')
374+
await fs.mkdir(fixtureDir, { recursive: true })
375+
376+
await fs.writeFile(
377+
path.join(fixtureDir, 'package.json'),
378+
JSON.stringify({
379+
name: 'yarn-app',
380+
version: '1.0.0',
381+
devDependencies: { '@playwright/test': '^1.49.0' },
382+
}),
383+
)
384+
385+
// yarn classic lockfile pinning 1.49.0.
386+
await fs.writeFile(
387+
path.join(fixtureDir, 'yarn.lock'),
388+
`"@playwright/test@^1.49.0":\n`
389+
+ ` version "1.49.0"\n`
390+
+ ` resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.49.0.tgz"\n`,
391+
)
392+
393+
const version = await resolvePlaywrightVersion(fixtureDir)
394+
expect(version).toBe('1.49.0')
395+
})
396+
397+
it('resolves a pnpm workspace-member fixture from the lockfile above the workingDir, not the monorepo lockfile', async () => {
398+
// The trap: the workingDir points at a workspace MEMBER subdir whose own
399+
// lockfile lives at the fixture's workspace root ABOVE it (the member has
400+
// no lockfile of its own). The search must walk UP from the workingDir to
401+
// that fixture workspace lockfile (pinning 1.49.0) — but stop before the
402+
// monorepo lockfile (pinning 1.40.0).
403+
const root = await setupMonorepo('1.40.0')
404+
405+
const fixtureWorkspaceRoot = path.join(root, 'fixtures', 'pnpm-ws')
406+
const memberDir = path.join(fixtureWorkspaceRoot, 'packages', 'member')
407+
await fs.mkdir(memberDir, { recursive: true })
408+
409+
await fs.writeFile(
410+
path.join(fixtureWorkspaceRoot, 'package.json'),
411+
JSON.stringify({ name: 'pnpm-ws', version: '1.0.0' }),
412+
)
413+
await fs.writeFile(
414+
path.join(fixtureWorkspaceRoot, 'pnpm-workspace.yaml'),
415+
`packages:\n - 'packages/*'\n`,
416+
)
417+
await fs.writeFile(
418+
path.join(memberDir, 'package.json'),
419+
JSON.stringify({
420+
name: 'member',
421+
version: '1.0.0',
422+
devDependencies: { '@playwright/test': '^1.49.0' },
423+
}),
424+
)
425+
426+
// The fixture workspace lockfile records the member importer relative to
427+
// the fixture workspace root (packages/member), pinning 1.49.0.
428+
await fs.writeFile(
429+
path.join(fixtureWorkspaceRoot, 'pnpm-lock.yaml'),
430+
`lockfileVersion: '9.0'\n`
431+
+ `importers:\n`
432+
+ ` .: {}\n`
433+
+ ` packages/member:\n`
434+
+ ` devDependencies:\n`
435+
+ ` '@playwright/test':\n`
436+
+ ` specifier: ^1.49.0\n`
437+
+ ` version: 1.49.0\n`,
438+
)
439+
440+
const version = await resolvePlaywrightVersion(memberDir)
441+
expect(version).toBe('1.49.0')
442+
})
443+
444+
it('falls back to the monorepo workspace lockfile when the entry has no fixture-local lockfile', async () => {
445+
// No regression: a workingDir whose nearest lockfile walking up IS the
446+
// monorepo lockfile must resolve the monorepo version. The fixture-local
447+
// search must NOT cross into / use the monorepo lockfile, so it finds
448+
// nothing and the existing Session.workspace path resolves 1.40.0.
449+
const root = await setupMonorepo('1.40.0')
450+
451+
const entryDir = path.join(root, 'apps', 'no-lockfile-entry')
452+
await fs.mkdir(entryDir, { recursive: true })
453+
await fs.writeFile(
454+
path.join(entryDir, 'package.json'),
455+
JSON.stringify({ name: 'no-lockfile-entry', version: '1.0.0' }),
456+
)
457+
458+
const version = await resolvePlaywrightVersion(entryDir)
459+
expect(version).toBe('1.40.0')
460+
})
320461
})

packages/cli/src/services/playwright-project-bundler.ts

Lines changed: 162 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import path from 'node:path'
55
import semver from 'semver'
66

77
import { File } from './check-parser/parser.js'
8-
import { detectNearestPackageJson, PackageManager } from './check-parser/package-files/package-manager.js'
8+
import {
9+
detectNearestLockfiles,
10+
detectNearestPackageJson,
11+
PackageManager,
12+
} from './check-parser/package-files/package-manager.js'
913
import { PackageJsonFile } from './check-parser/package-files/package-json-file.js'
1014
import { ImporterCandidate } from './check-parser/package-files/lockfile-package-version.js'
1115
import { lineage } from './check-parser/package-files/walk.js'
@@ -163,15 +167,39 @@ const PLAYWRIGHT_TEST = '@playwright/test'
163167
/**
164168
* Resolves the @playwright/test version that should run in the cloud.
165169
*
166-
* The project's lockfile is the source of truth: the version it pins is what
167-
* CI and other developers resolve, and it stays correct even when the local
168-
* node_modules has drifted (e.g. after switching branches without
169-
* reinstalling). When no usable lockfile answer is available — no lockfile, an
170+
* A lockfile is the source of truth: the version it pins is what CI and other
171+
* developers resolve, and it stays correct even when the local node_modules has
172+
* drifted (e.g. after switching branches without reinstalling).
173+
*
174+
* `cwd` is the entry's effective working directory — the dir that owns the
175+
* `@playwright/test` install for this entry. When several `playwrightChecks`
176+
* entries point at different fixtures (each with its own lockfile, possibly on
177+
* a different Playwright version), each must resolve from *its own* lockfile,
178+
* not the monorepo/workspace lockfile that physically encloses them all.
179+
*
180+
* Resolution order:
181+
*
182+
* 1. A *fixture-local* lockfile: the nearest lockfile walking up from `cwd`,
183+
* bounded so it stays strictly below the `Session.workspace` (monorepo)
184+
* root and never reaches/uses the monorepo lockfile. This is the per-entry
185+
* answer.
186+
* 2. The `Session.workspace` lockfile, scoped to the workspace member that
187+
* owns `cwd` (the legacy single-working-dir behaviour; for an entry without
188+
* a per-entry workingDir the nearest lockfile above `cwd` simply *is* this
189+
* one, so step 1 finds nothing and we land here unchanged).
190+
* 3. The installed package read from disk.
191+
*
192+
* Each step returns `undefined` when it can't derive an answer (no lockfile, an
170193
* unsupported/unparseable format, or the package isn't pinned for the relevant
171-
* workspace member — we fall back to reading the installed package.
194+
* member), signalling a fall-through to the next.
172195
*/
173196
export async function resolvePlaywrightVersion (cwd: string): Promise<string> {
174-
const lockfileVersion = await getPlaywrightVersionFromLockfile(cwd)
197+
const fixtureVersion = await getPlaywrightVersionFromFixtureLockfile(cwd)
198+
if (fixtureVersion !== undefined) {
199+
return fixtureVersion
200+
}
201+
202+
const lockfileVersion = await getPlaywrightVersionFromWorkspaceLockfile(cwd)
175203
if (lockfileVersion !== undefined) {
176204
return lockfileVersion
177205
}
@@ -185,12 +213,13 @@ function playwrightRange (packageJson: PackageJsonFile): string | undefined {
185213
}
186214

187215
/**
188-
* Resolves the @playwright/test version from the workspace lockfile, scoped to
189-
* the workspace member that owns the Playwright config. Returns `undefined`
190-
* when no answer can be derived from the lockfile, signalling the caller to
191-
* fall back.
216+
* Resolves the @playwright/test version from the `Session.workspace` lockfile,
217+
* scoped to the workspace member that owns `cwd`. This is the legacy path used
218+
* for entries without a per-entry workingDir (where the nearest lockfile above
219+
* `cwd` simply *is* the workspace lockfile). Returns `undefined` when no answer
220+
* can be derived, signalling the caller to fall back.
192221
*/
193-
async function getPlaywrightVersionFromLockfile (cwd: string): Promise<string | undefined> {
222+
async function getPlaywrightVersionFromWorkspaceLockfile (cwd: string): Promise<string | undefined> {
194223
const workspaceResult = Session.workspace
195224
if (!workspaceResult.isOk()) {
196225
return undefined
@@ -201,17 +230,135 @@ async function getPlaywrightVersionFromLockfile (cwd: string): Promise<string |
201230
return undefined
202231
}
203232

204-
const lockfilePath = workspace.lockfile.unwrap()
205-
const packageManager = Session.packageManager
233+
return await getPlaywrightVersionFromLockfile(cwd, {
234+
lockfilePath: workspace.lockfile.unwrap(),
235+
packageManager: Session.packageManager,
236+
rootPath: workspace.root.path,
237+
})
238+
}
239+
240+
/**
241+
* Resolves the @playwright/test version from a *fixture-local* lockfile: the
242+
* nearest lockfile found walking up from `cwd`, but only when it lies strictly
243+
* below the `Session.workspace` (monorepo) root.
244+
*
245+
* This is what makes the CLI the source of truth per `playwrightChecks` entry.
246+
* An entry's working dir may be a self-contained fixture (its own lockfile in
247+
* the working dir) or a member of a *fixture-level* workspace whose lockfile
248+
* sits at a parent directory above the working dir. Either way the nearest
249+
* lockfile above `cwd` is that fixture's lockfile — until the walk reaches the
250+
* monorepo root, whose lockfile pins the catalog version and would collapse
251+
* every fixture to one version (the bug we're fixing).
252+
*
253+
* The bound is defined relative to the actual monorepo root, not to any
254+
* fixture's depth: the discovered lockfile's directory must be a *strict
255+
* descendant* of the workspace root. If the nearest lockfile is the monorepo's
256+
* own (found at the workspace root) — which is exactly the case for an entry
257+
* with no per-entry workingDir — there is no fixture-local answer and we return
258+
* `undefined` so the caller uses the workspace lockfile path instead.
259+
*
260+
* When `Session.workspace` is unavailable we can't establish that bound, so we
261+
* decline here and let the (likewise-declining) workspace path / installed
262+
* package fallback handle it — preserving the no-workspace behaviour exactly.
263+
*/
264+
async function getPlaywrightVersionFromFixtureLockfile (cwd: string): Promise<string | undefined> {
265+
const workspaceResult = Session.workspace
266+
if (!workspaceResult.isOk()) {
267+
return undefined
268+
}
269+
270+
const workspace = workspaceResult.unwrap()
271+
272+
// Normalize through realpath so the descendant check and the directory walk
273+
// line up even when reached through a symlink (e.g. macOS /tmp ->
274+
// /private/tmp). If either can't be resolved, decline.
275+
let configDir: string
276+
let workspaceRoot: string
277+
try {
278+
configDir = await fs.realpath(cwd)
279+
workspaceRoot = await fs.realpath(workspace.root.path)
280+
} catch {
281+
return undefined
282+
}
206283

284+
// Find the nearest lockfile walking up from the working dir. Unbounded on the
285+
// way up; we apply the monorepo bound to the *result* below. When several
286+
// package managers claim a lockfile in the same nearest directory, prefer the
287+
// workspace's own package manager, else take the first.
288+
let nearest
289+
try {
290+
const lockfiles = await detectNearestLockfiles(configDir)
291+
nearest = lockfiles.find(({ packageManager }) => packageManager.name === Session.packageManager.name)
292+
?? lockfiles[0]
293+
} catch {
294+
return undefined
295+
}
296+
297+
if (nearest === undefined) {
298+
return undefined
299+
}
300+
301+
// Bound: the lockfile must live strictly below the monorepo root. The nearest
302+
// lockfile found at (or, defensively, at/above) the workspace root IS the
303+
// monorepo lockfile — decline so the workspace path handles it. realpath the
304+
// lockfile's directory so the comparison survives symlinks too.
305+
let lockfileDir: string
306+
try {
307+
lockfileDir = await fs.realpath(path.dirname(nearest.lockfile))
308+
} catch {
309+
return undefined
310+
}
311+
312+
if (!isStrictDescendant(lockfileDir, workspaceRoot)) {
313+
return undefined
314+
}
315+
316+
return await getPlaywrightVersionFromLockfile(configDir, {
317+
lockfilePath: nearest.lockfile,
318+
packageManager: nearest.packageManager,
319+
rootPath: lockfileDir,
320+
})
321+
}
322+
323+
/**
324+
* Whether `child` is a strict descendant of `parent` (not equal, not an
325+
* ancestor). Both must be absolute, already-normalized (realpath'd) paths.
326+
*/
327+
function isStrictDescendant (child: string, parent: string): boolean {
328+
const rel = path.relative(parent, child)
329+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel)
330+
}
331+
332+
interface LockfileResolution {
333+
lockfilePath: string
334+
packageManager: PackageManager
335+
/**
336+
* The directory the lockfile's importer paths are relative to — the workspace
337+
* root for the monorepo lockfile, or the fixture (workspace) root for a
338+
* fixture-local one. The importer walk runs from `cwd` up to here.
339+
*/
340+
rootPath: string
341+
}
342+
343+
/**
344+
* Resolves the @playwright/test version from a specific lockfile, scoped to the
345+
* importer (workspace member) that owns `cwd`. The lockfile, package manager,
346+
* and root the importer paths are relative to are passed in, so this serves
347+
* both the monorepo workspace lockfile and a per-entry fixture lockfile.
348+
* Returns `undefined` when no answer can be derived from the lockfile.
349+
*/
350+
async function getPlaywrightVersionFromLockfile (
351+
cwd: string,
352+
{ lockfilePath, packageManager, rootPath }: LockfileResolution,
353+
): Promise<string | undefined> {
207354
// Normalize both paths through realpath so the directory walk and the
208355
// relative importer paths line up with the workspace root even when the
209356
// config is reached through a symlink (e.g. macOS /tmp -> /private/tmp).
210357
let configDir: string
211358
let workspaceRoot: string
212359
try {
213360
configDir = await fs.realpath(cwd)
214-
workspaceRoot = await fs.realpath(workspace.root.path)
361+
workspaceRoot = await fs.realpath(rootPath)
215362
} catch {
216363
return undefined
217364
}

0 commit comments

Comments
 (0)