From da101f265e4ad1f167b67fee41cae8ea12f247c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20P=C3=A9rez?= Date: Wed, 17 Jun 2026 13:36:02 +0200 Subject: [PATCH 1/2] feat: support per-entry workingDir on playwrightChecks A single bundled `checkly test`/`deploy` session can contain several playwrightChecks, but they share one bundle root and one working directory, so each self-contained Playwright project (its own package manager and pinned @playwright/test) needs hand-written installCommand/testCommand shell surgery, and the CLI resolves a single @playwright/test version for all of them. Add an optional per-entry `workingDir` to PlaywrightCheck and playwrightChecks config entries. When set, the bundler resolves that entry's Playwright version from the working directory and stamps a per-entry working directory (plus a working-dir-relative playwright config path) into the check payload, so one session can carry projects on different Playwright versions without command surgery. Backward compatible: when `workingDir` is omitted, every computed value is identical to today (defaults to the project directory). --- .../cli/src/constructs/playwright-check.ts | 24 ++++++++++++- .../playwright-project-bundler.spec.ts | 29 ++++++++++++++-- .../cli/src/services/checkly-config-loader.ts | 2 +- .../services/playwright-project-bundler.ts | 34 +++++++++++++++---- 4 files changed, 77 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/constructs/playwright-check.ts b/packages/cli/src/constructs/playwright-check.ts index ef9ad9069..6f89c72a0 100644 --- a/packages/cli/src/constructs/playwright-check.ts +++ b/packages/cli/src/constructs/playwright-check.ts @@ -94,6 +94,22 @@ export interface PlaywrightCheckProps extends Omit () { // Subclass that stubs the real bundling so we can count how many times it runs // and control its timing, without needing a full Session/filesystem setup. class CountingBundler extends PlaywrightProjectBundler { - calls: Array<{ config: string, include: string[] }> = [] + calls: Array<{ config: string, include: string[], workingDir?: string }> = [] #gate?: Promise constructor (gate?: Promise) { @@ -37,8 +37,12 @@ class CountingBundler extends PlaywrightProjectBundler { this.#gate = gate } - protected async bundleProject (config: string, include: string[]): Promise { - this.calls.push({ config, include }) + protected async bundleProject ( + config: string, + include: string[], + workingDir?: string, + ): Promise { + this.calls.push({ config, include, workingDir }) if (this.#gate) { await this.#gate } @@ -96,6 +100,25 @@ describe('PlaywrightProjectBundler cache', () => { expect(bundler.calls).toHaveLength(2) }) + + it('bundles separately for different working directories', async () => { + const bundler = new CountingBundler() + + await bundler.bundle('pw.config.ts', ['a/**'], 'packages/foo') + await bundler.bundle('pw.config.ts', ['a/**'], 'packages/bar') + + expect(bundler.calls).toHaveLength(2) + }) + + it('threads the working directory through and reuses the cache for the same key', async () => { + const bundler = new CountingBundler() + + await bundler.bundle('pw.config.ts', ['a/**'], 'packages/foo') + await bundler.bundle('pw.config.ts', ['a/**'], 'packages/foo') + + expect(bundler.calls).toHaveLength(1) + expect(bundler.calls[0].workingDir).toBe('packages/foo') + }) }) describe('getAutoIncludes()', () => { diff --git a/packages/cli/src/services/checkly-config-loader.ts b/packages/cli/src/services/checkly-config-loader.ts index b06300587..48deee87f 100644 --- a/packages/cli/src/services/checkly-config-loader.ts +++ b/packages/cli/src/services/checkly-config-loader.ts @@ -35,7 +35,7 @@ export type PlaywrightSlimmedProp = Pick & { logicalId: string, playwrightConfigPath?: string } + | 'engine' | 'workingDir'> & { logicalId: string, playwrightConfigPath?: string } export type ChecklyConfig = { /** diff --git a/packages/cli/src/services/playwright-project-bundler.ts b/packages/cli/src/services/playwright-project-bundler.ts index 6b06d2fd4..cee89e90f 100644 --- a/packages/cli/src/services/playwright-project-bundler.ts +++ b/packages/cli/src/services/playwright-project-bundler.ts @@ -31,29 +31,45 @@ export class PlaywrightProjectBundler { // config path and the serialized include patterns from colliding. #cache = new Map>() - async bundle (playwrightConfig: string, include: string[]): Promise { - const cacheKey = `${playwrightConfig}\0${JSON.stringify(include)}` + async bundle (playwrightConfig: string, include: string[], workingDir?: string): Promise { + const cacheKey = `${playwrightConfig}\0${JSON.stringify(include)}\0${workingDir ?? ''}` const cached = this.#cache.get(cacheKey) if (cached !== undefined) { return await cached } - const promise = this.bundleProject(playwrightConfig, include) + const promise = this.bundleProject(playwrightConfig, include, workingDir) this.#cache.set(cacheKey, promise) return await promise } // The actual bundling, separated from the cache wrapper above so it can be // overridden in tests. Not part of the public surface. - protected async bundleProject (playwrightConfig: string, include: string[]): Promise { + protected async bundleProject ( + playwrightConfig: string, + include: string[], + workingDir?: string, + ): Promise { const dir = path.resolve(path.dirname(playwrightConfig)) const filePath = path.resolve(dir, playwrightConfig) + // Per-entry working directory: where this check's install/test commands run, + // and where its @playwright/test version is resolved. `workingDir` is authored + // relative to the project context dir. When omitted it defaults to the context + // dir, so everything below collapses to the legacy single-working-dir behaviour. + // Setting it lets one bundled session carry several self-contained fixtures on + // different Playwright versions without hand-written install/test shell surgery. + const effectiveWorkingDir = workingDir + ? path.resolve(Session.contextPath!, workingDir) + : Session.contextPath! + // No need of loading everything if there is no lockfile const pwtConfig = await Session.loadFile(filePath) const pwConfigParsed = new PlaywrightConfig(filePath, pwtConfig) - const playwrightVersion = await resolvePlaywrightVersion(dir) + // Resolve the version from the working dir when set (that's where the fixture + // declares/installs its own @playwright/test); otherwise from the config dir. + const playwrightVersion = await resolvePlaywrightVersion(workingDir ? effectiveWorkingDir : dir) const parser = Session.getPlaywrightParser() const { files, errors } = await parser.getFilesAndDependencies(pwConfigParsed) @@ -104,8 +120,12 @@ export class PlaywrightProjectBundler { return { browsers: pwConfigParsed.getBrowsers(), playwrightVersion, - relativePlaywrightConfigPath: Session.contextRelativePosixPath(filePath), - workingDir: Session.relativePosixPath(Session.contextPath!), + // Both relative to the effective working dir: the runner runs the test + // command from `workingDir`, so the config path must resolve from there. + // With no per-entry workingDir these collapse to the legacy + // contextPath-relative values. + relativePlaywrightConfigPath: pathToPosix(path.relative(effectiveWorkingDir, filePath)), + workingDir: Session.relativePosixPath(effectiveWorkingDir), files, } } From 7166ed81c85e426218286390cd481b731f07c48a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20P=C3=A9rez?= Date: Thu, 18 Jun 2026 18:33:08 +0200 Subject: [PATCH 2/2] 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. --- .../playwright-project-bundler.spec.ts | 141 ++++++++++++++ .../services/playwright-project-bundler.ts | 177 ++++++++++++++++-- 2 files changed, 303 insertions(+), 15 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 1330498e2..6ab0f9d8c 100644 --- a/packages/cli/src/services/__tests__/playwright-project-bundler.spec.ts +++ b/packages/cli/src/services/__tests__/playwright-project-bundler.spec.ts @@ -317,4 +317,145 @@ describe('resolvePlaywrightVersion()', () => { const version = await resolvePlaywrightVersion(otherPackage) expect(version).toBe('1.41.0') }) + + // Builds a monorepo root that owns a pnpm workspace lockfile pinning + // `monorepoVersion`, and wires it up as Session.workspace exactly like + // project-parser would for the checkly monorepo. Per-entry working dirs live + // in subdirectories *below* this root; each test adds its own fixture under + // `root` with its own self-contained lockfile. The fix must resolve each + // fixture's own version, never collapsing to this monorepo version. + async function setupMonorepo (monorepoVersion: string): Promise { + const root = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-pw-monorepo-')), + ) + + await fs.writeFile( + path.join(root, 'package.json'), + JSON.stringify({ + name: 'monorepo', + version: '1.0.0', + devDependencies: { '@playwright/test': `${monorepoVersion}` }, + }), + ) + + await fs.writeFile( + path.join(root, 'pnpm-lock.yaml'), + `lockfileVersion: '9.0'\n` + + `importers:\n` + + ` .:\n` + + ` devDependencies:\n` + + ` '@playwright/test':\n` + + ` specifier: ${monorepoVersion}\n` + + ` version: ${monorepoVersion}\n`, + ) + + const workspace = new Workspace({ + root: new Package({ name: 'monorepo', 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('resolves a self-contained fixture from its own (yarn) lockfile, not the monorepo lockfile', async () => { + // The monorepo pins 1.40.0; a per-entry workingDir fixture has its OWN + // yarn.lock + package.json pinning 1.49.0. The fixture's lockfile is the + // source of truth for that entry — note the fixture even uses a *different* + // package manager (yarn) than the monorepo (pnpm), so we must detect the + // fixture's package manager rather than reuse Session.packageManager. + const root = await setupMonorepo('1.40.0') + + const fixtureDir = path.join(root, 'fixtures', 'yarn-app') + await fs.mkdir(fixtureDir, { recursive: true }) + + await fs.writeFile( + path.join(fixtureDir, 'package.json'), + JSON.stringify({ + name: 'yarn-app', + version: '1.0.0', + devDependencies: { '@playwright/test': '^1.49.0' }, + }), + ) + + // yarn classic lockfile pinning 1.49.0. + await fs.writeFile( + path.join(fixtureDir, 'yarn.lock'), + `"@playwright/test@^1.49.0":\n` + + ` version "1.49.0"\n` + + ` resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.49.0.tgz"\n`, + ) + + const version = await resolvePlaywrightVersion(fixtureDir) + expect(version).toBe('1.49.0') + }) + + it('resolves a pnpm workspace-member fixture from the lockfile above the workingDir, not the monorepo lockfile', async () => { + // The trap: the workingDir points at a workspace MEMBER subdir whose own + // lockfile lives at the fixture's workspace root ABOVE it (the member has + // no lockfile of its own). The search must walk UP from the workingDir to + // that fixture workspace lockfile (pinning 1.49.0) — but stop before the + // monorepo lockfile (pinning 1.40.0). + const root = await setupMonorepo('1.40.0') + + const fixtureWorkspaceRoot = path.join(root, 'fixtures', 'pnpm-ws') + const memberDir = path.join(fixtureWorkspaceRoot, 'packages', 'member') + await fs.mkdir(memberDir, { recursive: true }) + + await fs.writeFile( + path.join(fixtureWorkspaceRoot, 'package.json'), + JSON.stringify({ name: 'pnpm-ws', version: '1.0.0' }), + ) + await fs.writeFile( + path.join(fixtureWorkspaceRoot, 'pnpm-workspace.yaml'), + `packages:\n - 'packages/*'\n`, + ) + await fs.writeFile( + path.join(memberDir, 'package.json'), + JSON.stringify({ + name: 'member', + version: '1.0.0', + devDependencies: { '@playwright/test': '^1.49.0' }, + }), + ) + + // The fixture workspace lockfile records the member importer relative to + // the fixture workspace root (packages/member), pinning 1.49.0. + await fs.writeFile( + path.join(fixtureWorkspaceRoot, 'pnpm-lock.yaml'), + `lockfileVersion: '9.0'\n` + + `importers:\n` + + ` .: {}\n` + + ` packages/member:\n` + + ` devDependencies:\n` + + ` '@playwright/test':\n` + + ` specifier: ^1.49.0\n` + + ` version: 1.49.0\n`, + ) + + const version = await resolvePlaywrightVersion(memberDir) + expect(version).toBe('1.49.0') + }) + + it('falls back to the monorepo workspace lockfile when the entry has no fixture-local lockfile', async () => { + // No regression: a workingDir whose nearest lockfile walking up IS the + // monorepo lockfile must resolve the monorepo version. The fixture-local + // search must NOT cross into / use the monorepo lockfile, so it finds + // nothing and the existing Session.workspace path resolves 1.40.0. + const root = await setupMonorepo('1.40.0') + + const entryDir = path.join(root, 'apps', 'no-lockfile-entry') + await fs.mkdir(entryDir, { recursive: true }) + await fs.writeFile( + path.join(entryDir, 'package.json'), + JSON.stringify({ name: 'no-lockfile-entry', version: '1.0.0' }), + ) + + const version = await resolvePlaywrightVersion(entryDir) + expect(version).toBe('1.40.0') + }) }) diff --git a/packages/cli/src/services/playwright-project-bundler.ts b/packages/cli/src/services/playwright-project-bundler.ts index cee89e90f..8a6cdebd6 100644 --- a/packages/cli/src/services/playwright-project-bundler.ts +++ b/packages/cli/src/services/playwright-project-bundler.ts @@ -5,7 +5,11 @@ import path from 'node:path' import semver from 'semver' import { File } from './check-parser/parser.js' -import { detectNearestPackageJson, PackageManager } from './check-parser/package-files/package-manager.js' +import { + detectNearestLockfiles, + 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' @@ -163,15 +167,39 @@ 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 + * A 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). + * + * `cwd` is the entry's effective working directory — the dir that owns the + * `@playwright/test` install for this entry. When several `playwrightChecks` + * entries point at different fixtures (each with its own lockfile, possibly on + * a different Playwright version), each must resolve from *its own* lockfile, + * not the monorepo/workspace lockfile that physically encloses them all. + * + * Resolution order: + * + * 1. A *fixture-local* lockfile: the nearest lockfile walking up from `cwd`, + * bounded so it stays strictly below the `Session.workspace` (monorepo) + * root and never reaches/uses the monorepo lockfile. This is the per-entry + * answer. + * 2. The `Session.workspace` lockfile, scoped to the workspace member that + * owns `cwd` (the legacy single-working-dir behaviour; for an entry without + * a per-entry workingDir the nearest lockfile above `cwd` simply *is* this + * one, so step 1 finds nothing and we land here unchanged). + * 3. The installed package read from disk. + * + * Each step returns `undefined` when it can't derive an answer (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. + * member), signalling a fall-through to the next. */ export async function resolvePlaywrightVersion (cwd: string): Promise { - const lockfileVersion = await getPlaywrightVersionFromLockfile(cwd) + const fixtureVersion = await getPlaywrightVersionFromFixtureLockfile(cwd) + if (fixtureVersion !== undefined) { + return fixtureVersion + } + + const lockfileVersion = await getPlaywrightVersionFromWorkspaceLockfile(cwd) if (lockfileVersion !== undefined) { return lockfileVersion } @@ -185,12 +213,13 @@ function playwrightRange (packageJson: PackageJsonFile): string | undefined { } /** - * 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. + * Resolves the @playwright/test version from the `Session.workspace` lockfile, + * scoped to the workspace member that owns `cwd`. This is the legacy path used + * for entries without a per-entry workingDir (where the nearest lockfile above + * `cwd` simply *is* the workspace lockfile). Returns `undefined` when no answer + * can be derived, signalling the caller to fall back. */ -async function getPlaywrightVersionFromLockfile (cwd: string): Promise { +async function getPlaywrightVersionFromWorkspaceLockfile (cwd: string): Promise { const workspaceResult = Session.workspace if (!workspaceResult.isOk()) { return undefined @@ -201,9 +230,127 @@ async function getPlaywrightVersionFromLockfile (cwd: string): Promise { + const workspaceResult = Session.workspace + if (!workspaceResult.isOk()) { + return undefined + } + + const workspace = workspaceResult.unwrap() + + // Normalize through realpath so the descendant check and the directory walk + // line up even when reached through a symlink (e.g. macOS /tmp -> + // /private/tmp). If either can't be resolved, decline. + let configDir: string + let workspaceRoot: string + try { + configDir = await fs.realpath(cwd) + workspaceRoot = await fs.realpath(workspace.root.path) + } catch { + return undefined + } + // Find the nearest lockfile walking up from the working dir. Unbounded on the + // way up; we apply the monorepo bound to the *result* below. When several + // package managers claim a lockfile in the same nearest directory, prefer the + // workspace's own package manager, else take the first. + let nearest + try { + const lockfiles = await detectNearestLockfiles(configDir) + nearest = lockfiles.find(({ packageManager }) => packageManager.name === Session.packageManager.name) + ?? lockfiles[0] + } catch { + return undefined + } + + if (nearest === undefined) { + return undefined + } + + // Bound: the lockfile must live strictly below the monorepo root. The nearest + // lockfile found at (or, defensively, at/above) the workspace root IS the + // monorepo lockfile — decline so the workspace path handles it. realpath the + // lockfile's directory so the comparison survives symlinks too. + let lockfileDir: string + try { + lockfileDir = await fs.realpath(path.dirname(nearest.lockfile)) + } catch { + return undefined + } + + if (!isStrictDescendant(lockfileDir, workspaceRoot)) { + return undefined + } + + return await getPlaywrightVersionFromLockfile(configDir, { + lockfilePath: nearest.lockfile, + packageManager: nearest.packageManager, + rootPath: lockfileDir, + }) +} + +/** + * Whether `child` is a strict descendant of `parent` (not equal, not an + * ancestor). Both must be absolute, already-normalized (realpath'd) paths. + */ +function isStrictDescendant (child: string, parent: string): boolean { + const rel = path.relative(parent, child) + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) +} + +interface LockfileResolution { + lockfilePath: string + packageManager: PackageManager + /** + * The directory the lockfile's importer paths are relative to — the workspace + * root for the monorepo lockfile, or the fixture (workspace) root for a + * fixture-local one. The importer walk runs from `cwd` up to here. + */ + rootPath: string +} + +/** + * Resolves the @playwright/test version from a specific lockfile, scoped to the + * importer (workspace member) that owns `cwd`. The lockfile, package manager, + * and root the importer paths are relative to are passed in, so this serves + * both the monorepo workspace lockfile and a per-entry fixture lockfile. + * Returns `undefined` when no answer can be derived from the lockfile. + */ +async function getPlaywrightVersionFromLockfile ( + cwd: string, + { lockfilePath, packageManager, rootPath }: LockfileResolution, +): Promise { // 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). @@ -211,7 +358,7 @@ async function getPlaywrightVersionFromLockfile (cwd: string): Promise