@@ -5,7 +5,11 @@ import path from 'node:path'
55import semver from 'semver'
66
77import { 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'
913import { PackageJsonFile } from './check-parser/package-files/package-json-file.js'
1014import { ImporterCandidate } from './check-parser/package-files/lockfile-package-version.js'
1115import { 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 */
173196export 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