From 84c13076857520ee8d5d732de872d82e4f87ca3c Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Wed, 15 Jul 2026 05:48:07 +0900 Subject: [PATCH 1/7] feat(cli): resolve symlinks when collecting code bundle files [RED-713] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under pnpm every package in node_modules is a symlink into a content-addressed store, and the include globs report such a link as if it were a regular file while separately walking through it and reporting the files beneath. Archiving both yields a path that is at once a symlink and a directory, which tar refuses to extract. Add a resolver that turns matched paths into archive entries upholding one invariant: an entry is either a symlink, with nothing beneath it, or a regular file at a symlink-free archive path. Symlinks are kept as symlinks and their targets brought along, rather than dereferenced — under pnpm a package's dependencies are siblings of it inside the store, not children, so a flattened copy of a package cannot resolve anything it depends on. Not yet wired into the bundler. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/symlink-resolver.spec.ts | 684 +++++++++++++++ .../cli/src/services/check-parser/parser.ts | 16 +- packages/cli/src/services/symlink-resolver.ts | 809 ++++++++++++++++++ 3 files changed, 1508 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/services/__tests__/symlink-resolver.spec.ts create mode 100644 packages/cli/src/services/symlink-resolver.ts diff --git a/packages/cli/src/services/__tests__/symlink-resolver.spec.ts b/packages/cli/src/services/__tests__/symlink-resolver.spec.ts new file mode 100644 index 000000000..2dc7c12bf --- /dev/null +++ b/packages/cli/src/services/__tests__/symlink-resolver.spec.ts @@ -0,0 +1,684 @@ +import fs from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import { PhysicalFile } from '../check-parser/parser.js' +import { resolveBundleFiles } from '../symlink-resolver.js' +import { findFilesWithPattern } from '../util.js' + +const sandboxes: string[] = [] + +afterEach(async () => { + await Promise.all(sandboxes.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true }))) +}) + +/** A symlink in a tree spec. The target is written to the link verbatim. */ +function link (target: string) { + return { target } +} + +type TreeSpec = Record + +/** + * Files first, then links. Windows picks a symlink's type by looking at its + * target, and falls back to a file-type link when the target does not exist yet + * — which cannot then be opened as a directory. Creating every target first + * keeps the links directory-typed on all platforms. + */ +async function makeTree (root: string, spec: TreeSpec): Promise { + const links: Array<[string, string]> = [] + + for (const [relative, value] of Object.entries(spec)) { + const absolute = path.join(root, relative) + await fs.mkdir(path.dirname(absolute), { recursive: true }) + + if (typeof value === 'string') { + await fs.writeFile(absolute, value) + } else { + links.push([absolute, value.target]) + } + } + + for (const [absolute, target] of links) { + await fs.symlink(target, absolute) + } +} + +async function makeSandbox (spec: TreeSpec): Promise { + // Resolve the path: tmpdir() is itself reached through a symlink on macOS, and + // that is a separate case with its own test below. + const root = await fs.realpath(await fs.mkdtemp(path.join(tmpdir(), 'symlink-resolver-'))) + sandboxes.push(root) + await makeTree(root, spec) + return root +} + +interface BundleOptions { + ignore?: string[] + /** Where the include patterns and ignore patterns are relative to. */ + cwd?: string + /** The archive root. Defaults to the sandbox root. */ + bundleRoot?: string +} + +async function bundle (root: string, patterns: string[], options: BundleOptions = {}): Promise { + const { ignore = [], cwd = root, bundleRoot = root } = options + + const matchedPaths = await findFilesWithPattern(cwd, patterns, ignore) + + const files = await resolveBundleFiles({ + matchedPaths, + bundleRoot, + ignoreCwd: cwd, + ignorePatterns: ignore, + }) + + // The archive must never contain a symlink with entries beneath it, whatever + // the tree or the pattern. Asserting it on every result rather than in + // individual tests means a new case cannot forget to check it. + expectNoSymlinkHasChildren(files) + + return files +} + +/** Renders entries as `path` or `path -> target`, so tests read like a tar listing. */ +function entries (files: PhysicalFile[]): string[] { + return files + .map(file => file.symlinkTarget !== undefined + ? `${file.archivePath} -> ${file.symlinkTarget}` + : file.archivePath!) + .sort() +} + +/** + * The condition tar cannot survive: a symlink entry with entries beneath it. One + * path cannot be both a symlink and a directory. + */ +function expectNoSymlinkHasChildren (files: PhysicalFile[]): void { + for (const symlink of files.filter(file => file.symlinkTarget !== undefined)) { + const children = files + .filter(file => file.archivePath!.startsWith(`${symlink.archivePath}/`)) + .map(file => file.archivePath) + + expect(children, `entries beneath symlink ${symlink.archivePath}`).toEqual([]) + } +} + +describe('resolveBundleFiles', () => { + it('should archive a plain file tree unchanged', async () => { + const root = await makeSandbox({ + 'tests/example.spec.ts': 'test', + 'package.json': '{}', + }) + + const files = await bundle(root, ['**/*']) + + expect(entries(files)).toEqual([ + 'package.json', + 'tests/example.spec.ts', + ]) + }) + + describe('pnpm store links', () => { + // What pnpm actually builds: node_modules/ is a link into the store, + // and the package's own dependencies sit *next to* its directory in there. + const store: TreeSpec = { + 'node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/index.js': 'pkg', + 'node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/package.json': '{"name":"pkg"}', + 'node_modules/.pnpm/pkg@1.0.0/node_modules/dep': link('../../dep@2.0.0/node_modules/dep'), + 'node_modules/.pnpm/dep@2.0.0/node_modules/dep/index.js': 'dep', + 'node_modules/pkg': link('.pnpm/pkg@1.0.0/node_modules/pkg'), + 'package.json': '{}', + } + + it('should keep the link, bundle its target, and follow sibling dependencies', async () => { + const root = await makeSandbox(store) + + const files = await bundle(root, ['node_modules/pkg/**']) + + expect(entries(files)).toEqual([ + 'node_modules/.pnpm/dep@2.0.0/node_modules/dep/index.js', + 'node_modules/.pnpm/pkg@1.0.0/node_modules/dep -> ../../dep@2.0.0/node_modules/dep', + 'node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/index.js', + 'node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/package.json', + 'node_modules/pkg -> .pnpm/pkg@1.0.0/node_modules/pkg', + ]) + expectNoSymlinkHasChildren(files) + }) + + it.each([ + ['node_modules/pkg/**'], + ['node_modules/pkg/**/*'], + ['node_modules/**'], + ['**/node_modules/**'], + ])('should produce the same archive for pattern %s', async pattern => { + const root = await makeSandbox(store) + + const files = await bundle(root, [pattern]) + + // Every shape converges: the link, its target, and the target's own + // dependencies. `node_modules/pkg/**/*` matches only files *beneath* the + // link and never the link itself, so this is not free. + expect(entries(files)).toEqual(expect.arrayContaining([ + 'node_modules/.pnpm/dep@2.0.0/node_modules/dep/index.js', + 'node_modules/.pnpm/pkg@1.0.0/node_modules/dep -> ../../dep@2.0.0/node_modules/dep', + 'node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/index.js', + 'node_modules/pkg -> .pnpm/pkg@1.0.0/node_modules/pkg', + ])) + expectNoSymlinkHasChildren(files) + }) + + it('should follow scoped packages and their scoped dependencies', async () => { + // A scoped package sits two levels below the store's node_modules, so its + // dependencies are not where an unscoped package's would be. + const root = await makeSandbox({ + 'node_modules/.pnpm/@scope+pkg@1.0.0/node_modules/@scope/pkg/index.js': 'pkg', + 'node_modules/.pnpm/@scope+pkg@1.0.0/node_modules/@other/dep': link('../../../@other+dep@2.0.0/node_modules/@other/dep'), + 'node_modules/.pnpm/@other+dep@2.0.0/node_modules/@other/dep/index.js': 'dep', + 'node_modules/@scope/pkg': link('../.pnpm/@scope+pkg@1.0.0/node_modules/@scope/pkg'), + 'package.json': '{}', + }) + + const files = await bundle(root, ['node_modules/@scope/pkg/**']) + + expect(entries(files)).toEqual([ + 'node_modules/.pnpm/@other+dep@2.0.0/node_modules/@other/dep/index.js', + 'node_modules/.pnpm/@scope+pkg@1.0.0/node_modules/@other/dep -> ../../../@other+dep@2.0.0/node_modules/@other/dep', + 'node_modules/.pnpm/@scope+pkg@1.0.0/node_modules/@scope/pkg/index.js', + 'node_modules/@scope/pkg -> ../.pnpm/@scope+pkg@1.0.0/node_modules/@scope/pkg', + ]) + expectNoSymlinkHasChildren(files) + }) + + it('should bundle .bin executables, which dotfile rules would otherwise drop', async () => { + const root = await makeSandbox({ + 'node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/index.js': 'pkg', + 'node_modules/.pnpm/pkg@1.0.0/node_modules/.bin/tool': '#!/bin/sh', + 'node_modules/pkg': link('.pnpm/pkg@1.0.0/node_modules/pkg'), + 'package.json': '{}', + }) + + const files = await bundle(root, ['node_modules/pkg/**']) + + expect(entries(files)).toContain('node_modules/.pnpm/pkg@1.0.0/node_modules/.bin/tool') + }) + + it('should terminate when two store packages depend on each other', async () => { + // Two packages that depend on each other is an ordinary thing for a pnpm + // store to contain. Without a guard, collecting a's dependencies reaches b, + // collecting b's reaches a, and the resolver never returns. + const root = await makeSandbox({ + 'node_modules/.pnpm/a@1.0.0/node_modules/a/index.js': 'a', + 'node_modules/.pnpm/a@1.0.0/node_modules/b': link('../../b@1.0.0/node_modules/b'), + 'node_modules/.pnpm/b@1.0.0/node_modules/b/index.js': 'b', + 'node_modules/.pnpm/b@1.0.0/node_modules/a': link('../../a@1.0.0/node_modules/a'), + 'node_modules/a': link('.pnpm/a@1.0.0/node_modules/a'), + 'package.json': '{}', + }) + + const files = await bundle(root, ['node_modules/a/**']) + + expect(entries(files)).toEqual([ + 'node_modules/.pnpm/a@1.0.0/node_modules/a/index.js', + 'node_modules/.pnpm/a@1.0.0/node_modules/b -> ../../b@1.0.0/node_modules/b', + 'node_modules/.pnpm/b@1.0.0/node_modules/a -> ../../a@1.0.0/node_modules/a', + 'node_modules/.pnpm/b@1.0.0/node_modules/b/index.js', + 'node_modules/a -> .pnpm/a@1.0.0/node_modules/a', + ]) + expectNoSymlinkHasChildren(files) + }, 20_000) + + it('should not walk a dependency graph once per path through it', async () => { + // Each package depends on the next two, so the number of distinct paths + // through the graph is exponential in its size while the number of + // packages is not. Anything that traverses per-path rather than per-package + // takes minutes here. + const spec: TreeSpec = { 'package.json': '{}' } + const size = 24 + for (let i = 0; i < size; i++) { + spec[`node_modules/.pnpm/p${i}@1.0.0/node_modules/p${i}/index.js`] = `p${i}` + for (const dependency of [i + 1, i + 2].filter(next => next < size)) { + spec[`node_modules/.pnpm/p${i}@1.0.0/node_modules/p${dependency}`] = + link(`../../p${dependency}@1.0.0/node_modules/p${dependency}`) + } + } + spec['node_modules/p0'] = link('.pnpm/p0@1.0.0/node_modules/p0') + const root = await makeSandbox(spec) + + const files = await bundle(root, ['node_modules/p0/**']) + + // Every package's own file, reached once. + for (let i = 0; i < size; i++) { + expect(entries(files)).toContain(`node_modules/.pnpm/p${i}@1.0.0/node_modules/p${i}/index.js`) + } + }, 20_000) + + it('should skip a link whose target the ignore patterns exclude', async () => { + const root = await makeSandbox({ + 'node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/index.js': 'pkg', + 'node_modules/pkg': link('.pnpm/pkg@1.0.0/node_modules/pkg'), + 'package.json': '{}', + }) + + const files = await bundle(root, ['node_modules/**'], { ignore: ['**/.pnpm/**'] }) + + // Keeping the link would put a symlink to nothing in the archive: its + // target was excluded, so it cannot travel with it. + expect(entries(files)).toEqual([]) + }) + + it('should never bundle pnpm state files, which make pnpm purge node_modules', async () => { + const root = await makeSandbox({ + 'node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/index.js': 'pkg', + 'node_modules/.pnpm/pkg@1.0.0/node_modules/.modules.yaml': 'storeDir: /elsewhere', + 'node_modules/.modules.yaml': 'storeDir: /elsewhere', + 'node_modules/pkg': link('.pnpm/pkg@1.0.0/node_modules/pkg'), + 'package.json': '{}', + }) + + const files = await bundle(root, ['node_modules/**']) + + expect(entries(files).filter(entry => entry.includes('.modules.yaml'))).toEqual([]) + }) + }) + + it('should keep a workspace link and bundle the package it points at', async () => { + const root = await makeSandbox({ + 'packages/shared-lib/src/index.ts': 'export const x = 1', + 'packages/shared-lib/package.json': '{"name":"@scope/shared-lib"}', + 'packages/e2e/node_modules/@scope/shared-lib': link('../../../shared-lib'), + 'packages/e2e/tests/example.spec.ts': 'test', + 'package.json': '{}', + }) + + const files = await bundle(root, ['node_modules/@scope/shared-lib/**'], { + cwd: path.join(root, 'packages', 'e2e'), + }) + + expect(entries(files)).toEqual([ + 'packages/e2e/node_modules/@scope/shared-lib -> ../../../shared-lib', + 'packages/shared-lib/package.json', + 'packages/shared-lib/src/index.ts', + ]) + expectNoSymlinkHasChildren(files) + }) + + it('should resolve a chain of symlinks without nesting entries under a link', async () => { + const root = await makeSandbox({ + 'real/pkg/index.js': 'pkg', + 'alias': link('real'), + 'alias-to-alias': link('alias'), + 'package.json': '{}', + }) + + const files = await bundle(root, ['alias-to-alias/**']) + + // Only ever one symlink entry per matched path — the first link in the chain. + // A second entry would sit beneath the first, which is the broken shape. + expect(entries(files)).toEqual([ + 'alias-to-alias -> real', + 'real/pkg/index.js', + ]) + expectNoSymlinkHasChildren(files) + }) + + it('should not expand a plain directory symlink when only files beneath it matched', async () => { + const root = await makeSandbox({ + 'shared-media/logo.png': 'png', + 'shared-media/huge-video.mp4': 'mp4', + 'assets': link('shared-media'), + 'package.json': '{}', + }) + + const files = await bundle(root, ['assets/**/*.png']) + + // Only the images were asked for. Expanding the link's target here would + // bundle the video too. + expect(entries(files)).toEqual([ + 'assets -> shared-media', + 'shared-media/logo.png', + ]) + expectNoSymlinkHasChildren(files) + }) + + it('should keep a symlink to a file and bundle the file it points at', async () => { + const root = await makeSandbox({ + 'config/base.json': '{}', + 'playwright.config.json': link('config/base.json'), + 'package.json': '{}', + }) + + const files = await bundle(root, ['playwright.config.json', 'package.json']) + + expect(entries(files)).toEqual([ + 'config/base.json', + 'package.json', + 'playwright.config.json -> config/base.json', + ]) + }) + + describe('targets outside the archive root', () => { + it('should bundle the contents of an out-of-root link, without a symlink entry', async () => { + const outer = await makeSandbox({ + 'external/pkg/index.js': 'pkg', + 'project/node_modules/pkg': link('../../external/pkg'), + 'project/package.json': '{}', + }) + const root = path.join(outer, 'project') + + const files = await bundle(root, ['node_modules/pkg/**', 'package.json'], { bundleRoot: root }) + + // The target cannot be named inside the archive, so a symlink entry would + // dangle after extraction. Copy the bytes to where the link sits instead. + expect(entries(files)).toEqual([ + 'node_modules/pkg/index.js', + 'package.json', + ]) + expect(files.filter(file => file.symlinkTarget !== undefined)).toEqual([]) + }) + + it('should not leave a symlink with entries beneath it when two links share a target', async () => { + // Two links to one directory, and a pattern that matches files through + // both. glob traverses a symlinked directory for `**/*` (it does not for a + // bare `**`), so files arrive under both link paths — and if the second + // link is archived as a symlink to the first, those files sit beneath a + // symlink entry. That is exactly the archive tar cannot extract. + const outer = await makeSandbox({ + 'external/pkg/index.js': 'pkg', + 'external/other/o.js': 'other', + 'external/pkg/aliasA': link('../other'), + 'external/pkg/aliasB': link('../other'), + 'project/node_modules/pkg': link('../../external/pkg'), + 'project/package.json': '{}', + }) + const root = path.join(outer, 'project') + + const files = await bundle(root, ['node_modules/pkg/**/*'], { bundleRoot: root }) + + expectNoSymlinkHasChildren(files) + expect(entries(files)).toEqual(expect.arrayContaining([ + 'node_modules/pkg/aliasA/o.js', + 'node_modules/pkg/index.js', + ])) + }) + + it('should dereference symlinks nested inside an out-of-root tree', async () => { + const outer = await makeSandbox({ + 'external/pkg/index.js': 'pkg', + 'external/pkg/vendor': link('../vendored'), + 'external/vendored/lib.js': 'lib', + 'project/node_modules/pkg': link('../../external/pkg'), + 'project/package.json': '{}', + }) + const root = path.join(outer, 'project') + + const files = await bundle(root, ['node_modules/pkg/**'], { bundleRoot: root }) + + expect(entries(files)).toEqual([ + 'node_modules/pkg/index.js', + 'node_modules/pkg/vendor/lib.js', + ]) + expect(files.filter(file => file.symlinkTarget !== undefined)).toEqual([]) + }) + }) + + it('should keep a link pointing at its own parent directory', async () => { + const root = await makeSandbox({ + 'pkg/index.js': 'pkg', + 'pkg/self': link('.'), + 'package.json': '{}', + }) + + const files = await bundle(root, ['pkg/**']) + + // The naive relative path here is the empty string, which symlink(2) rejects. + expect(entries(files)).toEqual([ + 'pkg/index.js', + 'pkg/self -> .', + ]) + }) + + it('should terminate on symlink cycles', async () => { + const root = await makeSandbox({ + 'a/index.js': 'a', + 'b/index.js': 'b', + 'a/to-b': link('../b'), + 'b/to-a': link('../a'), + 'package.json': '{}', + }) + + const files = await bundle(root, ['a/**']) + + // a -> b -> a is cut by the second visit to a real path already expanded. + expect(entries(files)).toEqual([ + 'a/index.js', + 'a/to-b -> ../b', + 'b/index.js', + 'b/to-a -> ../a', + ]) + expectNoSymlinkHasChildren(files) + }) + + describe('broken symlinks', () => { + it.each([ + ['a relative target', './missing'], + ['an absolute target outside the project', '/nonexistent/elsewhere'], + ])('should skip one with %s', async (_name, target) => { + const root = await makeSandbox({ + 'broken': link(target), + 'package.json': '{}', + }) + + const files = await bundle(root, ['*']) + + // Its target does not exist and so cannot be bundled with it. Keeping the + // link would extract to a link to nothing — and an absolute one would + // escape the archive root, which hardened extractors reject outright. + expect(entries(files)).toEqual([ + 'package.json', + ]) + }) + }) + + it('should resolve correctly when the project is reached through a symlinked path', async () => { + // What macOS does to every path under /tmp. If the lexical root and the real + // root are not reconciled, every real path looks like it is outside the root + // and the whole tree gets dereferenced. + const outer = await makeSandbox({ + 'real/project/node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/index.js': 'pkg', + 'real/project/node_modules/pkg': link('.pnpm/pkg@1.0.0/node_modules/pkg'), + 'real/project/package.json': '{}', + 'alias': link('real'), + }) + const root = path.join(outer, 'alias', 'project') + + const files = await bundle(root, ['node_modules/pkg/**'], { bundleRoot: root }) + + expect(entries(files)).toEqual([ + 'node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/index.js', + 'node_modules/pkg -> .pnpm/pkg@1.0.0/node_modules/pkg', + ]) + expectNoSymlinkHasChildren(files) + }) + + it('should handle paths containing glob metacharacters', async () => { + const root = await makeSandbox({ + 'pkg (v2)[beta]/index.js': 'pkg', + 'node_modules/pkg': link('../pkg (v2)[beta]'), + 'package.json': '{}', + }) + + const files = await bundle(root, ['node_modules/**']) + + expect(entries(files)).toEqual([ + 'node_modules/pkg -> ../pkg (v2)[beta]', + 'pkg (v2)[beta]/index.js', + ]) + }) + + it('should not re-import a subtree the ignore patterns excluded', async () => { + const root = await makeSandbox({ + 'shared/src/index.js': 'src', + 'shared/fixtures/big.json': '{}', + 'lib': link('shared'), + 'package.json': '{}', + }) + + const files = await bundle(root, ['lib/**'], { ignore: ['**/fixtures/**'] }) + + expect(entries(files)).toEqual([ + 'lib -> shared', + 'shared/src/index.js', + ]) + }) + + it('should apply ignore patterns to content expanded outside the include cwd', async () => { + // The store sits at the workspace root while the Playwright config lives in a + // package below it, which is the ordinary monorepo shape. Relativized against + // the config directory, a store path starts with `..` — and minimatch's `**` + // will not match across one, so patterns matched in that namespace are inert. + const root = await makeSandbox({ + 'node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/index.js': 'pkg', + 'node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/fixtures/huge.json': '{}', + 'node_modules/pkg': link('.pnpm/pkg@1.0.0/node_modules/pkg'), + 'packages/e2e/playwright.config.ts': 'config', + 'package.json': '{}', + }) + + const files = await bundle(root, ['../../node_modules/pkg/**'], { + cwd: path.join(root, 'packages', 'e2e'), + ignore: ['**/fixtures/**'], + }) + + expect(entries(files)).toEqual([ + 'node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/index.js', + 'node_modules/pkg -> .pnpm/pkg@1.0.0/node_modules/pkg', + ]) + }) + + it('should keep a file the include globs matched, even when expansion reached it first', async () => { + // The ignore patterns exclude `fixtures`, but the glob kept this file anyway: + // relative to the config directory its path crosses `..`, which no pattern can + // match. The resolver must not overturn that decision just because it happened + // to walk into the same file while expanding the package link next door. + const root = await makeSandbox({ + 'shared/src/index.js': 'src', + 'shared/fixtures/data.json': '{}', + 'packages/e2e/node_modules/@scope/shared': link('../../../../shared'), + 'package.json': '{}', + }) + + const files = await bundle(root, ['node_modules/@scope/shared/**', '../../shared/fixtures/**'], { + cwd: path.join(root, 'packages', 'e2e'), + ignore: ['**/fixtures/**'], + }) + + expect(entries(files)).toEqual([ + 'packages/e2e/node_modules/@scope/shared -> ../../../../shared', + 'shared/fixtures/data.json', + 'shared/src/index.js', + ]) + }) + + it('should keep a link onto a directory whose only content is other links', async () => { + // tar creates the parent directories of a symlink entry just as it does for a + // file, so `holder` exists after extraction and `lib` resolves through it. + const root = await makeSandbox({ + 'real/tool.js': 'tool', + 'holder/tool': link('../real/tool.js'), + 'lib': link('holder'), + 'package.json': '{}', + }) + + const files = await bundle(root, ['lib/**', 'package.json']) + + expect(entries(files)).toEqual([ + 'holder/tool -> ../real/tool.js', + 'lib -> holder', + 'package.json', + 'real/tool.js', + ]) + }) + + it('should drop a link whose target contributes nothing to the archive', async () => { + const root = await makeSandbox({ + 'shared/fixtures/big.json': '{}', + 'lib': link('shared'), + 'package.json': '{}', + }) + + const files = await bundle(root, ['lib/**', 'package.json'], { ignore: ['**/fixtures/**'] }) + + // Everything under the target was excluded, so tar never creates the + // directory the link points at. + expect(entries(files)).toEqual([ + 'package.json', + ]) + }) + + it('should not expand a link that points at one of its own ancestors', async () => { + // pnpm builds this for a package that depends on itself (`file:.`), giving a + // link whose target is the project root. + const root = await makeSandbox({ + 'src/index.ts': 'src', + 'private-notes.txt': 'secret', + 'node_modules/app': link('..'), + 'package.json': '{}', + }) + + const files = await bundle(root, ['node_modules/app/src/**']) + + // The pattern asked for one file. Because the link is a package link, the + // resolver would otherwise expand its target — the whole project — and sweep + // up every other file, including ones no include pattern named. + expect(entries(files)).toEqual([ + 'node_modules/app -> ..', + 'src/index.ts', + ]) + }) + + it('should copy an out-of-root directory once, however many links reach it', async () => { + // Each level fans out to the next by two links, so the number of distinct + // routes through the tree is exponential in its depth while the number of + // directories is not. + const spec: TreeSpec = { 'project/package.json': '{}' } + const depth = 12 + for (let i = 0; i < depth; i++) { + spec[`external/l${i}/file.js`] = `l${i}` + if (i + 1 < depth) { + spec[`external/l${i}/a`] = link(`../l${i + 1}`) + spec[`external/l${i}/b`] = link(`../l${i + 1}`) + } + } + spec['project/node_modules/pkg'] = link('../../external/l0') + const outer = await makeSandbox(spec) + const root = path.join(outer, 'project') + + const files = await bundle(root, ['node_modules/pkg/**'], { bundleRoot: root }) + + // Each level's file is copied exactly once; the second route to a directory + // becomes a link to the first copy rather than another copy of it. + for (let i = 0; i < depth; i++) { + const copies = entries(files).filter(entry => entry.endsWith(`/file.js`) && entry.includes(`l${i}`) === false) + expect(copies.length).toBeLessThanOrEqual(depth) + } + expect(files.length).toBeLessThan(depth * 4) + }, 20_000) + + it('should refuse to bundle pnpm state files even when named outright', async () => { + const root = await makeSandbox({ + 'node_modules/.modules.yaml': 'storeDir: /elsewhere', + 'package.json': '{}', + }) + + // A literal dot segment matches even though a wildcard would not, so an + // explicit include is the one way this file can reach the archive. + const files = await bundle(root, ['node_modules/.modules.yaml', 'package.json']) + + expect(entries(files)).toEqual([ + 'package.json', + ]) + }) +}) diff --git a/packages/cli/src/services/check-parser/parser.ts b/packages/cli/src/services/check-parser/parser.ts index eb73454c9..b555d90b4 100644 --- a/packages/cli/src/services/check-parser/parser.ts +++ b/packages/cli/src/services/check-parser/parser.ts @@ -135,7 +135,21 @@ type ParserOptions = { } export type VirtualFile = { filePath: string, physical: false, content: string } -export type PhysicalFile = { filePath: string, physical: true } +export type PhysicalFile = { + filePath: string + physical: true + /** + * Where the file goes in the archive. Defaults to filePath relative to the + * bundle root. Only needed when the two differ, which happens when a file is + * bundled at the path of a symlink that points at it. + */ + archivePath?: string + /** + * When set, the entry is archived as a symlink pointing here rather than as a + * copy of the file's contents. Relative to the entry's own archive directory. + */ + symlinkTarget?: string +} export type File = | VirtualFile diff --git a/packages/cli/src/services/symlink-resolver.ts b/packages/cli/src/services/symlink-resolver.ts new file mode 100644 index 000000000..47445a643 --- /dev/null +++ b/packages/cli/src/services/symlink-resolver.ts @@ -0,0 +1,809 @@ +import type { Stats } from 'node:fs' +import fs from 'node:fs/promises' +import path from 'node:path' + +import Debug from 'debug' +import { glob } from 'glob' +import { minimatch } from 'minimatch' + +import { PhysicalFile } from './check-parser/parser.js' +import { pathToPosix } from './util.js' + +const debug = Debug('checkly:cli:services:symlink-resolver') + +const NODE_MODULES = 'node_modules' +const PNPM_STORE = '.pnpm' +const BIN_DIR = '.bin' + +/** + * pnpm records the state of a node_modules directory in these files. They must + * never end up in a code bundle: on the runner the store directory differs from + * the one they name, and pnpm reacts by purging node_modules entirely — without + * prompting when CI is set. + */ +const PACKAGE_MANAGER_STATE_FILES = new Set([ + '.modules.yaml', + '.pnpm-workspace-state.json', + '.pnpm-workspace-state-v1.json', +]) + +export interface ResolveBundleFilesOptions { + /** Absolute paths matched by the include globs. */ + matchedPaths: string[] + /** Archive root. Every archive path is relative to this. */ + bundleRoot: string + /** Directory the ignore patterns are relative to (the include glob's cwd). */ + ignoreCwd: string + ignorePatterns: string[] +} + +/** + * Turns the paths matched by the include globs into archive entries, resolving + * symlinks so that the resulting archive is both extractable and usable. + * + * Two problems make this necessary. + * + * Extractability: glob's `nodir` option filters on lstat, so a symlink pointing + * at a directory is reported as if it were a regular file, while glob separately + * walks *through* that same symlink and reports the files beneath it. Archiving + * both yields a path that is simultaneously a symlink and a directory, which tar + * refuses to extract. Package managers that link packages out of a shared store + * (pnpm) make that the ordinary shape of node_modules. + * + * Usability: simply dereferencing the symlink does not work either. Under pnpm a + * package's dependencies are siblings of it inside the store, not children, so a + * flattened copy of node_modules/ cannot resolve anything it depends on. + * + * The archive therefore keeps symlinks as symlinks and brings their targets + * along, which reproduces the layout the package manager built. The invariant + * that makes it extractable: an entry is either a symlink, which by construction + * never has children, or a regular file at a symlink-free archive path. + */ +export async function resolveBundleFiles (options: ResolveBundleFilesOptions): Promise { + const resolver = new SymlinkResolver(options) + return await resolver.resolve(options.matchedPaths) +} + +class SymlinkResolver { + /** + * Candidate archive roots. A path may be expressed either lexically (as the + * include globs produced it) or canonically (as realpath produced it), and on + * macOS those differ whenever the project sits under a symlinked prefix such + * as /tmp. Both spellings must map to the same archive path. + */ + #roots: string[] = [] + #ignoreCwd: string + #ignorePatterns: string[] + + /** Archive entries, keyed by archive path. */ + #entries = new Map() + /** Paths already classified. */ + #classified = new Set() + /** Paths the include globs matched outright, as opposed to ones we followed to. */ + #directPaths = new Set() + /** Real directories already expanded. */ + #expanded = new Set() + /** + * Store directories whose dependency links have been collected. Without this, + * a dependency cycle — which pnpm stores have whenever two packages depend on + * each other, as they routinely do — would recurse until the process dies, and + * even an acyclic graph would be walked once per distinct path through it. + */ + #closed = new Set() + /** Out-of-root directories already copied, and where each one landed. */ + #dereferenced = new Map() + #lstatCache = new Map() + #warned = new Set() + + constructor (options: ResolveBundleFilesOptions) { + this.#ignoreCwd = options.ignoreCwd + this.#ignorePatterns = options.ignorePatterns + this.#roots = [options.bundleRoot] + } + + async resolve (matchedPaths: string[]): Promise { + const [bundleRoot] = this.#roots + + // The canonical root is what real paths are measured against; the lexical + // root is what the include globs produced. Keep both, or a project reached + // through a symlink would treat every real path as being outside the root. + try { + const realRoot = await fs.realpath(bundleRoot) + if (realRoot !== bundleRoot) { + this.#roots.push(realRoot) + } + } catch { + // Root does not exist; nothing can be inside it anyway. + } + + // Which paths the include globs matched is a property of the path, not of + // when it happens to be reached: expansion can arrive at a directly-matched + // file first, and it must not then be judged by rules the glob already + // applied to it. + for (const matchedPath of matchedPaths) { + this.#directPaths.add(matchedPath) + } + + for (const matchedPath of matchedPaths) { + await this.#classify(matchedPath) + } + + this.#pruneSymlinks() + + return Array.from(this.#entries.values()) + } + + /** + * Enforces, over the finished set of entries, the two things a symlink entry + * must satisfy. Doing it here rather than at each emit is what makes it hold + * regardless of the order paths happened to be classified in. + * + * A symlink must have nothing beneath it: one path cannot be both a symlink + * and a directory, and tar refuses to extract an archive claiming otherwise — + * the whole reason this resolver exists. Where entries did land beneath a link, + * the link is what goes: the entries are real content and extract as ordinary + * files, whereas the link would take the archive down with it. + * + * A symlink must also point at something the archive contains, or it extracts + * into a link to nothing and the check fails at run time. Dropping one link can + * empty out the directory another points at, so this repeats until it settles. + */ + #pruneSymlinks (): void { + for (;;) { + // Every path the extracted archive will contain: each entry, and every + // directory tar has to create on the way to it. Symlink entries count — + // tar materializes their parent directories exactly as it does a file's — + // so a link onto a directory holding nothing but other links still + // resolves. + const occupied = new Set() + for (const archivePath of this.#entries.keys()) { + occupied.add(archivePath) + + for ( + let parent = path.posix.dirname(archivePath); + parent !== '.' && parent !== '/' && parent !== '' && !occupied.has(parent); + parent = path.posix.dirname(parent) + ) { + occupied.add(parent) + } + } + + let pruned = false + + for (const [archivePath, file] of Array.from(this.#entries)) { + if (file.symlinkTarget === undefined) { + continue + } + + const hasChildren = Array.from(this.#entries.keys()) + .some(other => other.startsWith(`${archivePath}/`)) + + const target = resolveArchivePath(archivePath, file.symlinkTarget) + // A link onto the archive root always resolves; the root is not an entry. + const resolves = target === '' || occupied.has(target) + + if (hasChildren || !resolves) { + debug(`Dropping symlink ${archivePath}: ${hasChildren ? 'has children' : 'target is not bundled'}`) + this.#entries.delete(archivePath) + pruned = true + } + } + + if (!pruned) { + return + } + } + } + + /** + * Decides how a single matched path is represented in the archive. Everything + * hinges on the *first* symlinked component of the path: it alone determines + * the mode, which is what guarantees that entries never end up beneath a + * symlink entry. + * + */ + async #classify (matchedPath: string): Promise { + if (this.#classified.has(matchedPath)) { + return + } + this.#classified.add(matchedPath) + + if (isPackageManagerStateFile(matchedPath)) { + // Reachable when an include pattern names the file outright, since a + // literal dot segment matches even though wildcards do not. + debug(`Refusing to bundle package manager state file ${matchedPath}`) + return + } + + // The include glob already applied the ignore patterns to what it matched, + // in its own cwd namespace. Re-deciding those here would reinterpret the + // user's patterns in a different namespace and could drop files the glob + // deliberately kept. Content this resolver reached by itself, on the other + // hand, the glob never saw — and it is the only content that needs checking. + if (!this.#directPaths.has(matchedPath) && this.#isIgnored(matchedPath)) { + return + } + + const archivePath = this.#archivePathOf(matchedPath) + if (archivePath === undefined) { + // Include patterns may be absolute, and a Playwright config may live + // outside the workspace root, so a matched path is not guaranteed to sit + // under it. Such a path has no archive path relative to the root, and + // therefore nothing a symlink could point at. Leave the archive path unset + // and let the bundler name it exactly as it did before. + this.#emit(matchedPath, { + filePath: matchedPath, + physical: true, + }) + return + } + + const symlink = await this.#firstSymlinkComponent(matchedPath) + if (symlink === undefined) { + this.#emitFile(matchedPath, archivePath) + return + } + + await this.#handleSymlink(symlink, matchedPath) + } + + /** + * Walks the path from the archive root downwards and returns the first + * component that is a symlink. Components above the root are never examined — + * a symlinked root is simply the root. + */ + async #firstSymlinkComponent (target: string): Promise { + const root = this.#rootOf(target) + if (root === undefined) { + return undefined + } + + const relative = path.relative(root, target) + let current = root + + for (const segment of relative.split(path.sep)) { + current = path.join(current, segment) + const stats = await this.#lstat(current) + if (stats?.isSymbolicLink()) { + return current + } + } + + return undefined + } + + async #handleSymlink (symlink: string, matchedPath: string): Promise { + const target = await this.#realpath(symlink) + if (target === undefined) { + this.#skipDanglingSymlink(symlink) + return + } + + const targetArchivePath = this.#archivePathOf(target) + if (targetArchivePath === undefined) { + // The target lives outside the archive root (a globally linked package, a + // dependency outside the repository, and — in our own test sandbox — a + // node_modules directory linked to a shared template). It has no + // expressible path in the archive, so a symlink entry would dangle after + // extraction. Copy the bytes across instead. + await this.#dereference(symlink, matchedPath) + return + } + + if (this.#isIgnored(target)) { + // The ignore patterns exclude what this link points at, so its target will + // not be in the archive. Keeping the link would extract to a link pointing + // at nothing, which fails at run time rather than here. + this.#warnOnce( + symlink, + `${symlink} points at ${target}, which is excluded from the bundle. Skipping the symlink.`, + ) + return + } + + this.#emitSymlink(symlink, target) + + const stats = await this.#statThroughLink(symlink) + if (stats === undefined) { + return + } + + if (!stats.isDirectory()) { + // A symlink to a file cannot have children, so it is safe to keep as a + // link. Its target still has to be in the archive for it to resolve. + this.#emitFile(target, targetArchivePath) + return + } + + const isPackageLink = isInsideNodeModules(symlink) + + // Expanding the target subtree is what puts the package's own files in the + // archive. Do it when the pattern matched the link itself, and for package + // links whatever the pattern's shape — `node_modules/pkg/**/*` matches only + // files *beneath* the link, and those files alone are not enough to run. + // + // Do NOT expand unconditionally: for a plain directory symlink that would + // drag in the whole target whenever a pattern merely matched something + // inside it, so `assets/**/*.png` with a symlinked `assets` would bundle the + // entire directory rather than the images. + // + // A link that points at one of its own ancestors is never expanded. pnpm + // creates one for a package that depends on itself (`file:.`), giving + // node_modules/ -> .., and expanding that would walk the whole project + // and bundle every file the include patterns deliberately left out. + const pointsAtAncestor = isInside(target, symlink) + + if ((matchedPath === symlink || isPackageLink) && !pointsAtAncestor) { + await this.#expand(target) + } + + if (isPackageLink && !pointsAtAncestor) { + await this.#addDependencyClosure(target) + } + + if (matchedPath !== symlink) { + // Re-express the matched path in the real namespace and classify it there. + // Its remaining components may contain symlinks of their own; each ends up + // at its own real path, never nested under this link. + await this.#classify(path.join(target, path.relative(symlink, matchedPath))) + } + + // Whether this link survives — whether its target contributed anything, and + // whether anything landed beneath the link itself — is only knowable once + // every path has been classified. #pruneSymlinks decides that at the end. + } + + /** + * Bundles a real directory that a symlink points at, and everything reachable + * from it. Deduplicated by real path, which is what makes cyclic and diamond + * link graphs terminate. + */ + async #expand (directory: string): Promise { + if (this.#expanded.has(directory)) { + return + } + this.#expanded.add(directory) + + debug(`Expanding symlink target ${directory}`) + + for (const entry of await this.#enumerate(directory)) { + await this.#classify(entry) + } + } + + /** + * Adds the dependency links that live alongside a package inside a pnpm store, + * which is the only way a bundled package can resolve what it depends on. + * + * pnpm links node_modules/debug to .pnpm/debug@4.3.4/node_modules/debug, and + * puts debug's own dependency ms next to that directory, at + * .pnpm/debug@4.3.4/node_modules/ms — a *sibling* of the link's target rather + * than something inside it. Expanding the target alone therefore produces a + * package whose dependencies are all missing. + * + * Restricted to the pnpm store on purpose. The same rule applied to a package + * inside an ordinary node_modules directory would enumerate that entire + * directory, so a single linked package could pull in everything installed. + * + * Known gap: pnpm also hoists packages into node_modules/.pnpm/node_modules, + * which Node reaches from inside the store. A package that requires something + * it does not declare resolves through there locally, and is not collected + * here, so it would still fail on the runner. Collecting that directory means + * collecting every installed package, which is far too much to pay for a case + * pnpm's own strictness makes rare. + */ + async #addDependencyClosure (packageDirectory: string): Promise { + const nodeModules = enclosingNodeModules(packageDirectory) + if (nodeModules === undefined) { + return + } + + // A pnpm store package's node_modules directory looks like + // <...>/.pnpm/@/node_modules. Anything else — including a + // node_modules directory bundled *inside* a package — must not trigger this. + if (path.basename(path.dirname(path.dirname(nodeModules))) !== PNPM_STORE) { + return + } + + if (this.#closed.has(nodeModules)) { + return + } + this.#closed.add(nodeModules) + + debug(`Adding dependency closure from ${nodeModules}`) + + for (const entry of await this.#readdir(nodeModules)) { + if (PACKAGE_MANAGER_STATE_FILES.has(entry.name)) { + continue + } + + const entryPath = path.join(nodeModules, entry.name) + + if (entry.isSymbolicLink()) { + await this.#classify(entryPath) + continue + } + + if (!entry.isDirectory()) { + continue + } + + if (entry.name === BIN_DIR) { + // Executables the package's own scripts rely on. They are ordinary + // files that locate themselves at run time, so copying them works. + for (const bin of await this.#readdir(entryPath)) { + await this.#classify(path.join(entryPath, bin.name)) + } + continue + } + + if (entry.name.startsWith('@')) { + // A scoped dependency is a symlink one level inside a real scope + // directory, so the scope directory itself has to be opened. + for (const scoped of await this.#readdir(entryPath)) { + if (scoped.isSymbolicLink()) { + await this.#classify(path.join(entryPath, scoped.name)) + } + } + continue + } + + // A real directory here is the package itself, which #expand covers. + } + } + + /** + * Copies the contents of an out-of-root symlink target into the archive at the + * path the link occupies, emitting no symlink entry. Nested symlinks are + * dereferenced too, since anything they point at is out of root as well. + */ + async #dereference (symlink: string, matchedPath: string): Promise { + if (isInsideNodeModules(symlink) && (await this.#isPnpmStorePackage(symlink))) { + // A package linked in from outside the project — a globally linked package, + // or a virtual store relocated out of the workspace. Its dependencies are + // siblings of it inside that store, and they have no archive path either, + // so they cannot travel with it: the package arrives without anything it + // needs. Say so, rather than reporting a success the runner will not see. + this.#warnOnce( + symlink, + `${symlink} is linked from outside the project. Its contents will be bundled, but its ` + + `dependencies cannot be, so it may fail to resolve them when the check runs.`, + ) + } else { + this.#warnOnce( + symlink, + `${symlink} points outside the project. Bundling its contents instead of the symlink.`, + ) + } + + // Whatever was matched belongs in the archive at the path it was matched at. + // Its bytes are readable straight through the link. + await this.#dereferenceEntry(matchedPath) + } + + /** + * Bundles one path from inside an out-of-root tree. The path may itself be a + * symlink — a package directory reached through a linked node_modules can + * contain more links — and those get dereferenced too, since anything they + * point at is out of root as well. + */ + async #dereferenceEntry (target: string): Promise { + const archivePath = this.#archivePathOf(target) + if (archivePath === undefined) { + return + } + + const stats = await this.#lstat(target) + if (stats?.isSymbolicLink()) { + const linkStats = await this.#statThroughLink(target) + if (linkStats === undefined) { + // Dangling. A link to nothing is worth nothing on the runner. + return + } + + if (linkStats.isDirectory()) { + await this.#dereferenceTree(target, archivePath, new Set()) + return + } + } + + this.#emitFile(target, archivePath) + } + + async #dereferenceTree (directory: string, archiveDirectory: string, ancestors: Set): Promise { + const real = await this.#realpath(directory) + if (real === undefined || ancestors.has(real)) { + return + } + + // A directory reachable by more than one route is copied once, and every + // later route becomes a link to that copy. Copying it again per route would + // duplicate the bytes and, on a graph where links fan out, take time + // exponential in its depth. + // + // Should files also arrive beneath the later route — glob does walk into a + // symlinked directory for a `**/*` pattern — that link would have children, + // and #pruneSymlinks drops it in favour of them. + const copied = this.#dereferenced.get(real) + if (copied !== undefined) { + if (copied !== archiveDirectory) { + this.#emitSymlinkEntry(directory, archiveDirectory, copied) + } + return + } + this.#dereferenced.set(real, archiveDirectory) + + const visited = new Set(ancestors).add(real) + + for (const entry of await this.#enumerate(real)) { + const archivePath = path.posix.join(archiveDirectory, pathToPosix(path.relative(real, entry))) + + if (this.#isIgnoredArchivePath(archivePath)) { + continue + } + + const stats = await this.#lstat(entry) + if (!stats?.isSymbolicLink()) { + this.#emitFile(entry, archivePath) + continue + } + + const linkStats = await this.#statThroughLink(entry) + if (linkStats === undefined) { + // Dangling. A link to nothing is worth nothing on the runner. + continue + } + + if (linkStats.isDirectory()) { + await this.#dereferenceTree(entry, archivePath, visited) + continue + } + + this.#emitFile(entry, archivePath) + } + } + + /** + * A broken symlink is not bundled. Whatever it points at does not exist here + * and so cannot travel with it, leaving a link to nothing on the runner. (Were + * it kept, #pruneSymlinks would drop it anyway, its target having no entry.) + */ + #skipDanglingSymlink (symlink: string): void { + this.#warnOnce( + symlink, + `${symlink} is a broken symlink. Skipping it.`, + ) + } + + #emitSymlink (symlink: string, target: string): void { + const archivePath = this.#archivePathOf(symlink) + const targetArchivePath = this.#archivePathOf(target) + if (archivePath === undefined || targetArchivePath === undefined) { + return + } + + this.#emitSymlinkEntry(symlink, archivePath, targetArchivePath) + } + + #emitSymlinkEntry (symlink: string, archivePath: string, targetArchivePath: string): void { + // The link target is computed between archive paths, not filesystem paths, + // so it stays valid wherever the archive is extracted. Both are anchored to + // '/' first: path.posix.relative() resolves bare relative paths against the + // process's working directory, which has nothing to do with the archive. + // + // A link to its own parent directory relativizes to the empty string, which + // symlink(2) rejects, so name the directory instead. + const relativeTarget = path.posix.relative( + path.posix.dirname(`/${archivePath}`), + `/${targetArchivePath}`, + ) + const symlinkTarget = relativeTarget === '' ? '.' : relativeTarget + + this.#emit(archivePath, { + filePath: symlink, + physical: true, + archivePath, + symlinkTarget, + }) + } + + #emitFile (filePath: string, archivePath: string): void { + this.#emit(archivePath, { + filePath, + physical: true, + archivePath, + }) + } + + #emit (key: string, file: PhysicalFile): void { + if (this.#entries.has(key)) { + return + } + this.#entries.set(key, file) + } + + /** + * Lists the files in a real directory. Rooted at a real path on purpose: glob + * yields nothing at all when its cwd is a symlink. + * + * Dotfiles stay out, matching the include globs. That is not cosmetic — it is + * what keeps the pnpm store, its state files and stray .env files from being + * swept into the archive when a node_modules directory is enumerated. + */ + async #enumerate (directory: string): Promise { + return await glob('**/*', { + cwd: directory, + nodir: true, + absolute: true, + dot: false, + }) + } + + /** + * Ignore patterns are matched against the path a file would occupy in the + * archive, not against its path relative to the include glob's cwd. + * + * The cwd is the Playwright config directory, which in a monorepo sits below + * the workspace root while the pnpm store sits at it — so a store path + * relativized against the cwd starts with `..`, and minimatch's `**` cannot + * swallow a `..` segment (with or without `dot`). Matching in that namespace + * would silently ignore nothing at all for exactly the content this resolver + * pulls in. + * + * The trade-off is that a pattern anchored to the cwd rather than the root — + * `fixtures/...` rather than a globstar-prefixed one — does not apply to + * expanded content. A pattern reaching outside the cwd has to be root-relative + * to mean anything, and the CLI's own examples are all globstar-prefixed. + */ + #isIgnored (file: string): boolean { + const archivePath = this.#archivePathOf(file) + if (archivePath === undefined) { + // Outside the archive root, so there is no root-relative name to match. + // Callers that know where the file will land in the archive should use + // #isIgnoredArchivePath instead. + const relative = pathToPosix(path.relative(this.#ignoreCwd, file)) + return this.#ignorePatterns.some(pattern => minimatch(relative, pattern, { dot: true })) + } + + return this.#isIgnoredArchivePath(archivePath) + } + + #isIgnoredArchivePath (archivePath: string): boolean { + return this.#ignorePatterns.some(pattern => minimatch(archivePath, pattern, { dot: true })) + } + + #rootOf (target: string): string | undefined { + return this.#roots.find(root => isInside(root, target)) + } + + #archivePathOf (target: string): string | undefined { + const root = this.#rootOf(target) + if (root === undefined) { + return undefined + } + + const relative = pathToPosix(path.relative(root, target)) + + // The root itself normalizes to '.', which is not a path anything is + // archived at. Spell it as the empty string so it reads as "the root". + return relative === '.' ? '' : relative + } + + async #lstat (target: string): Promise { + const cached = this.#lstatCache.get(target) + if (cached !== undefined || this.#lstatCache.has(target)) { + return cached + } + + let stats: Stats | undefined + try { + stats = await fs.lstat(target) + } catch { + stats = undefined + } + + this.#lstatCache.set(target, stats) + + return stats + } + + /** Stats the target of a link. Undefined when the link is broken. */ + async #statThroughLink (target: string): Promise { + try { + return await fs.stat(target) + } catch { + return undefined + } + } + + /** Whether a link resolves into a pnpm store, where a package's deps are siblings. */ + async #isPnpmStorePackage (symlink: string): Promise { + const target = await this.#realpath(symlink) + if (target === undefined) { + return false + } + + const nodeModules = enclosingNodeModules(target) + if (nodeModules === undefined) { + return false + } + + return path.basename(path.dirname(path.dirname(nodeModules))) === PNPM_STORE + } + + /** Undefined when the path is a broken or cyclic symlink. */ + async #realpath (target: string): Promise { + try { + return await fs.realpath(target) + } catch { + return undefined + } + } + + async #readdir (directory: string) { + try { + return await fs.readdir(directory, { withFileTypes: true }) + } catch { + return [] + } + } + + #warnOnce (key: string, message: string): void { + if (this.#warned.has(key)) { + return + } + this.#warned.add(key) + + debug(message) + process.stderr.write(`Warning: ${message}\n`) + } +} + +function isInside (root: string, target: string): boolean { + return target === root || target.startsWith(root + path.sep) +} + +/** Where a symlink entry's target lands, as an archive path. */ +function resolveArchivePath (archivePath: string, symlinkTarget: string): string { + const resolved = path.posix.normalize( + path.posix.join(path.posix.dirname(`/${archivePath}`), symlinkTarget), + ) + + // Anchored at '/' so the join cannot escape into the process's working + // directory; strip the anchor back off to get an archive path again. + return resolved.replace(/^\/+/, '') +} + +/** + * pnpm's record of how a node_modules directory was built. Bundling one is worse + * than useless: the runner's store directory is not the one it names, and pnpm + * responds by purging node_modules — without asking, when CI is set. + */ +function isPackageManagerStateFile (target: string): boolean { + return PACKAGE_MANAGER_STATE_FILES.has(path.basename(target)) + && path.basename(path.dirname(target)) === NODE_MODULES +} + +/** Whether a path is a package inside a node_modules directory, scope included. */ +function isInsideNodeModules (target: string): boolean { + return enclosingNodeModules(target) !== undefined +} + +/** + * The node_modules directory a package directory belongs to, looking through a + * scope directory when there is one: node_modules/@types/node lives two levels + * below its node_modules, not one. + */ +function enclosingNodeModules (packageDirectory: string): string | undefined { + const parent = path.dirname(packageDirectory) + if (path.basename(parent) === NODE_MODULES) { + return parent + } + + const grandParent = path.dirname(parent) + if (path.basename(parent).startsWith('@') && path.basename(grandParent) === NODE_MODULES) { + return grandParent + } + + return undefined +} From 715098ed7907f046797cb94a27ed356d1f936188 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Wed, 15 Jul 2026 06:14:06 +0900 Subject: [PATCH 2/7] fix(cli): stop code bundles claiming a path is both a symlink and a directory [RED-713] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundling a project whose include patterns reach a symlinked directory produced an archive GNU tar refuses to extract: tar: node_modules/pkg/package.json: Cannot open: Not a directory The include globs report a symlinked directory as if it were a regular file, while separately walking through it and reporting the files beneath. Archiver then lstats each path, turns the link into a tar symlink entry, and writes the files under that same path. Under pnpm every package in node_modules is such a link, so any project bundling its dependencies hit this. Route the matched paths through the symlink resolver, and emit symlink entries deliberately: archiver is now handed a followed stat for every regular file, so it can no longer infer a symlink entry from a path that happens to be one. Enforce the invariant where the archive is assembled rather than only where the resolver produces entries, since the archive also contains files registered by the check parser, which does not resolve symlinks — a spec importing through a symlinked directory is registered at its path through the link. Any symlink that ends up with files beneath it is dropped in favour of them: the files are the content, and the link is what makes the archive unextractable. Key the file registry by archive path rather than source path: a package reached through two links is archived at both, and keying by source silently dropped one. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test-bundling-symlinks/checkly.config.ts | 20 +++ .../test-bundling-symlinks/package.json | 7 + .../packages/e2e/playwright.config.ts | 6 + .../packages/e2e/tests/example.spec.ts | 8 + .../packages/shared-helpers/login.ts | 3 + .../packages/shared-lib/package.json | 5 + .../packages/shared-lib/src/index.js | 1 + .../test-bundling-symlinks/pnpm-lock.yaml | 52 +++++++ .../__tests__/playwright-check.spec.ts | 141 +++++++++++++++++- .../cli/src/services/check-parser/bundler.ts | 126 ++++++++++++++-- .../services/playwright-project-bundler.ts | 37 ++++- 11 files changed, 386 insertions(+), 20 deletions(-) create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/checkly.config.ts create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/package.json create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/packages/e2e/playwright.config.ts create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/packages/e2e/tests/example.spec.ts create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/packages/shared-helpers/login.ts create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/packages/shared-lib/package.json create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/packages/shared-lib/src/index.js create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/pnpm-lock.yaml diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/checkly.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/checkly.config.ts new file mode 100644 index 000000000..1b47d42cc --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/checkly.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'checkly' + +const config = defineConfig({ + projectName: 'Check Fixture', + logicalId: 'check-fixture', + checks: { + checkMatch: '**/*.check.ts', + ignoreDirectoriesMatch: [], + include: ['node_modules/**', 'helpers/**'], + playwrightConfigPath: './packages/e2e/playwright.config.ts', + playwrightChecks: [ + { + logicalId: 'playwright-check-suite', + name: 'Playwright Check Suite', + } + ], + }, +}) + +export default config diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/package.json b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/package.json new file mode 100644 index 000000000..00ced8ddd --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/package.json @@ -0,0 +1,7 @@ +{ + "name": "playwright-symlink-bundle-test", + "version": "1.0.0", + "dependencies": { + "@playwright/test": "^1.55.1" + } +} diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/packages/e2e/playwright.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/packages/e2e/playwright.config.ts new file mode 100644 index 000000000..eed093e29 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/packages/e2e/playwright.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './tests', + timeout: 30000, +}) diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/packages/e2e/tests/example.spec.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/packages/e2e/tests/example.spec.ts new file mode 100644 index 000000000..ba507c086 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/packages/e2e/tests/example.spec.ts @@ -0,0 +1,8 @@ +import { test, expect } from '@playwright/test' + +import { login } from '../helpers/login.js' + +test('basic test', async ({ page }) => { + expect(login()).toBe('logged-in') + await page.goto('https://playwright.dev/') +}) diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/packages/shared-helpers/login.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/packages/shared-helpers/login.ts new file mode 100644 index 000000000..de2b7b140 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/packages/shared-helpers/login.ts @@ -0,0 +1,3 @@ +export function login (): string { + return 'logged-in' +} diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/packages/shared-lib/package.json b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/packages/shared-lib/package.json new file mode 100644 index 000000000..3bc9574e8 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/packages/shared-lib/package.json @@ -0,0 +1,5 @@ +{ + "name": "@scope/shared-lib", + "version": "1.0.0", + "main": "src/index.js" +} diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/packages/shared-lib/src/index.js b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/packages/shared-lib/src/index.js new file mode 100644 index 000000000..9cb95289d --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/packages/shared-lib/src/index.js @@ -0,0 +1 @@ +module.exports.greeting = 'hello' diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/pnpm-lock.yaml b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/pnpm-lock.yaml new file mode 100644 index 000000000..9c3c4c244 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-symlinks/pnpm-lock.yaml @@ -0,0 +1,52 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@playwright/test': + specifier: ^1.55.1 + version: 1.59.1 + +packages: + + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + +snapshots: + + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + + fsevents@2.3.2: + optional: true + + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 diff --git a/packages/cli/src/constructs/__tests__/playwright-check.spec.ts b/packages/cli/src/constructs/__tests__/playwright-check.spec.ts index 14175921a..7b5f267ce 100644 --- a/packages/cli/src/constructs/__tests__/playwright-check.spec.ts +++ b/packages/cli/src/constructs/__tests__/playwright-check.spec.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs/promises' import path from 'node:path' import { describe, it, expect, beforeAll, afterAll } from 'vitest' @@ -37,6 +38,41 @@ async function listTarFiles (filePath: string): Promise { return filenames } +interface TarEntry { + path: string + type: string + linkpath?: string +} + +async function listTarEntries (filePath: string): Promise { + const entries: TarEntry[] = [] + await list({ + file: filePath, + onReadEntry: entry => entries.push({ + path: entry.path, + type: entry.type, + linkpath: entry.linkpath ?? undefined, + }), + }) + return entries +} + +/** + * Asserts the one thing an archive must never contain: a symlink with entries + * beneath it. A path cannot be both a symlink and a directory, and tar refuses + * to extract an archive that claims otherwise — which is what the CLI produced + * for any pnpm package reached through node_modules. + */ +function expectNoSymlinkHasChildren (entries: TarEntry[]): void { + for (const symlink of entries.filter(entry => entry.type === 'SymbolicLink')) { + const children = entries + .filter(entry => entry.path.startsWith(`${symlink.path}/`)) + .map(entry => entry.path) + + expect(children, `entries beneath symlink ${symlink.path}`).toEqual([]) + } +} + const DEFAULT_TEST_TIMEOUT = 180_000 describe('PlaywrightCheck', () => { @@ -960,7 +996,8 @@ describe('PlaywrightCheck', () => { codeBundlePath, } = output.payload.resources[0].payload as any - const files = await listTarFiles(codeBundlePath) + const entries = await listTarEntries(codeBundlePath) + const files = entries.map(entry => entry.path) expect(files.sort()).toEqual(expect.arrayContaining([ 'node_modules/checkly/package.json', @@ -969,6 +1006,12 @@ describe('PlaywrightCheck', () => { 'pnpm-lock.yaml', 'tests/example.spec.ts', ])) + + // The package list stays permissive because the checkly package's own + // contents change, but the archive still has to be extractable — and + // `node_modules/checkly` is a pnpm symlink, so this is exactly where a + // symlink entry used to appear with files nested beneath it. + expectNoSymlinkHasChildren(entries) }, DEFAULT_TEST_TIMEOUT) it('should still respect custom ignoreDirectoriesMatch for explicit patterns', async () => { @@ -1050,6 +1093,102 @@ describe('PlaywrightCheck', () => { }, DEFAULT_TEST_TIMEOUT) }) + describe('bundling a pnpm-style node_modules', () => { + let fixt: FixtureSandbox + + beforeAll(async () => { + fixt = await FixtureSandbox.create({ + source: path.join(__dirname, 'fixtures', 'playwright-check', 'test-cases', 'test-bundling-symlinks'), + }) + + // Built here rather than committed: nothing under a node_modules path can + // be checked in, and the sandbox's own top-level node_modules is a symlink + // to a template shared by every test — writing through it would corrupt + // the other tests. This tree is a nested, real node_modules instead. + const nodeModules = path.join(fixt.root, 'packages', 'e2e', 'node_modules') + + const writeFile = async (relativePath: string, content: string) => { + const filePath = path.join(nodeModules, relativePath) + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, content) + } + + const symlink = async (relativePath: string, target: string) => { + const linkPath = path.join(nodeModules, relativePath) + await fs.mkdir(path.dirname(linkPath), { recursive: true }) + await fs.symlink(target, linkPath) + } + + // What pnpm builds: packages live in a store, and node_modules holds links + // into it. A package's own dependencies sit next to it inside the store, + // not underneath it. + await writeFile('.pnpm/pkg@1.0.0/node_modules/pkg/index.js', 'module.exports = require(\'dep\')\n') + await writeFile('.pnpm/pkg@1.0.0/node_modules/pkg/package.json', '{"name":"pkg","version":"1.0.0"}') + await symlink('.pnpm/pkg@1.0.0/node_modules/dep', '../../dep@2.0.0/node_modules/dep') + await writeFile('.pnpm/dep@2.0.0/node_modules/dep/index.js', 'module.exports = \'dep\'\n') + await writeFile('.pnpm/dep@2.0.0/node_modules/dep/package.json', '{"name":"dep","version":"2.0.0"}') + await symlink('pkg', '.pnpm/pkg@1.0.0/node_modules/pkg') + + // A linked workspace package, which is how a monorepo shares code. + await symlink('@scope/shared-lib', '../../../shared-lib') + + // A symlinked source directory that a spec imports through. The check + // parser registers what the spec imports at its path *through* the link, + // without resolving it — so its files arrive beneath a link the symlink + // resolver kept, from a code path the resolver never sees. + await fs.symlink('../shared-helpers', path.join(fixt.root, 'packages', 'e2e', 'helpers')) + }, DEFAULT_TEST_TIMEOUT) + + afterAll(async () => { + await fixt?.destroy() + }) + + it('should keep symlinks as symlinks and bundle what they point at', async () => { + const output = await parseProject(fixt) + + const { + codeBundlePath, + } = output.payload.resources[0].payload as any + + const entries = await listTarEntries(codeBundlePath) + + // The archive has to be extractable. Previously the package symlink and + // the files reached through it were both archived under the same path, + // and tar cannot create that. + expectNoSymlinkHasChildren(entries) + + const symlinks = entries + .filter(entry => entry.type === 'SymbolicLink') + .map(entry => `${entry.path} -> ${entry.linkpath}`) + .sort() + + expect(symlinks).toEqual([ + 'packages/e2e/node_modules/.pnpm/pkg@1.0.0/node_modules/dep -> ../../dep@2.0.0/node_modules/dep', + 'packages/e2e/node_modules/@scope/shared-lib -> ../../../shared-lib', + 'packages/e2e/node_modules/pkg -> .pnpm/pkg@1.0.0/node_modules/pkg', + ]) + + const files = entries.map(entry => entry.path) + + // The link targets travel with the links, or nothing resolves on the + // runner. `dep` is only here because the store directory holding `pkg` + // was collected too — it is a sibling of pkg, not a child of it. + expect(files).toEqual(expect.arrayContaining([ + 'packages/e2e/node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/index.js', + 'packages/e2e/node_modules/.pnpm/dep@2.0.0/node_modules/dep/index.js', + 'packages/shared-lib/src/index.js', + 'packages/shared-lib/package.json', + ])) + + // The spec imports through the symlinked helpers directory, and the parser + // registers that import at its path through the link. The file has to be + // in the archive — and, since it sits beneath the link's path, the link + // must not also be there, or the archive would not extract. + expect(files).toContain('packages/e2e/helpers/login.ts') + expect(symlinks).not.toContain(expect.stringContaining('packages/e2e/helpers ->')) + }, DEFAULT_TEST_TIMEOUT) + }) + describe('bundling with subdirectory playwright config', () => { let fixt: FixtureSandbox diff --git a/packages/cli/src/services/check-parser/bundler.ts b/packages/cli/src/services/check-parser/bundler.ts index d8c2ccb86..a8c420b6c 100644 --- a/packages/cli/src/services/check-parser/bundler.ts +++ b/packages/cli/src/services/check-parser/bundler.ts @@ -12,9 +12,71 @@ import { checklyStorage } from '../../rest/api.js' import { computeWorkspaceCacheHash } from './cache-hash.js' import { File } from './parser.js' import { Workspace } from './package-files/workspace.js' +import { pathToPosix } from '../util.js' const debug = Debug('checkly:cli:services:check-parser:bundler') +/** + * Where a file goes in the archive. A file usually lands at its own path + * relative to the bundle root, but a file bundled at the path of a symlink that + * points at it carries the archive path explicitly. + */ +function archivePath (file: File, stripPrefix?: string): string { + if (file.physical && file.archivePath !== undefined) { + return file.archivePath + } + + return stripPrefix + ? path.relative(stripPrefix, file.filePath) + : file.filePath +} + +/** + * Drops any symlink that has entries beneath it. One path cannot be both a + * symlink and a directory, and tar refuses to extract an archive claiming + * otherwise — the failure this whole mechanism exists to avoid. + * + * This has to happen here, over the complete set of entries, and not only where + * the entries are produced: the archive is the union of what the symlink + * resolver contributed and what the check parser registered, and the parser does + * not resolve symlinks. A spec that imports through a symlinked directory is + * registered at its path *through* that link, which puts it under a link the + * resolver quite reasonably kept. + * + * The link is what goes, rather than the files: the files are content, and they + * extract perfectly well as ordinary files, whereas the link takes the whole + * archive down with it. + */ +function dropSymlinksWithChildren (entries: Array<[string, File]>): File[] { + const directories = new Set() + + for (const [name] of entries) { + for ( + let parent = path.posix.dirname(pathToPosix(name)); + parent !== '.' && parent !== '/' && parent !== '' && !directories.has(parent); + parent = path.posix.dirname(parent) + ) { + directories.add(parent) + } + } + + return entries + .filter(([name, file]) => { + if (!file.physical || file.symlinkTarget === undefined) { + return true + } + + if (!directories.has(pathToPosix(name))) { + return true + } + + debug(`Dropping symlink ${name}: other files are archived beneath it`) + + return false + }) + .map(([, file]) => file) +} + export interface CreateBundleArchiveOptions { tempDir?: string stripPrefix?: string @@ -88,23 +150,55 @@ export class BundleArchive { }) } - // eslint-disable-next-line require-await async add (...files: File[]): Promise { - for (const file of files) { - const name = this.#stripPrefix - ? path.relative(this.#stripPrefix, file.filePath) - : file.filePath + // Stat every physical file up front, following symlinks, and hand the result + // to archiver. Left to itself archiver lstats each path and turns anything + // that happens to be a symlink into a symlink entry — which is how a symlink + // and the files beneath it end up in the archive at the same path, an + // archive tar cannot extract. Symlink entries are emitted deliberately, + // below, and nowhere else. + // + // A bundle that includes node_modules runs to tens of thousands of files, so + // these go out together rather than one await at a time. + const stats = await Promise.all(files.map(async file => { + if (!file.physical || file.symlinkTarget !== undefined) { + return undefined + } + + try { + return await fs.stat(file.filePath) + } catch (err) { + // Following the link means a broken one fails here, where archiver would + // previously have made it a dangling entry. + process.stderr.write(`Warning: skipping ${file.filePath}: ${err instanceof Error ? err.message : err}\n`) + return undefined + } + })) + + for (const [index, file] of files.entries()) { + const name = archivePath(file, this.#stripPrefix) const entry = { mode: 0o755, // Default mode for files in the archive name, } - if (file.physical) { - this.#archive.file(file.filePath, entry) - } else { + if (!file.physical) { this.#archive.append(file.content, entry) + continue } + + if (file.symlinkTarget !== undefined) { + this.#archive.symlink(name, file.symlinkTarget, entry.mode) + continue + } + + const fileStats = stats[index] + if (fileStats === undefined) { + continue + } + + this.#archive.file(file.filePath, { ...entry, stats: fileStats }) } } @@ -294,7 +388,12 @@ export class Bundler { registerFiles (...files: File[]): void { for (const newFile of files) { - const existingFile = this.#files.get(newFile.filePath) + // Keyed by archive path, not source path: one source file can be archived + // at more than one path (a package reached through two symlinks), and + // keying by source would silently drop all but one of them. + const key = archivePath(newFile, this.#stripPrefix) + + const existingFile = this.#files.get(key) if (existingFile) { // Prefer physical files. if (existingFile.physical && !newFile.physical) { @@ -302,7 +401,7 @@ export class Bundler { } } - this.#files.set(newFile.filePath, newFile) + this.#files.set(key, newFile) } } @@ -312,10 +411,9 @@ export class Bundler { stripPrefix: this.#stripPrefix, }) - const files = Array.from(this.#files.values()) - files.sort((a, b) => { - return a.filePath.localeCompare(b.filePath) - }) + const files = dropSymlinksWithChildren( + Array.from(this.#files.entries()).sort(([a], [b]) => a.localeCompare(b)), + ) await archive.add(...files) diff --git a/packages/cli/src/services/playwright-project-bundler.ts b/packages/cli/src/services/playwright-project-bundler.ts index 6b06d2fd4..7d74edb8f 100644 --- a/packages/cli/src/services/playwright-project-bundler.ts +++ b/packages/cli/src/services/playwright-project-bundler.ts @@ -10,9 +10,23 @@ import { PackageJsonFile } from './check-parser/package-files/package-json-file. 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 { resolveBundleFiles } from './symlink-resolver.js' import { findFilesWithPattern, pathToPosix } from './util.js' import { Session } from '../constructs/session.js' +/** + * The directory archive paths are relative to. Must match the bundler's strip + * prefix (see Bundler.createForWorkspace), or archive paths won't line up. + */ +function bundleRootPath (): string | undefined { + const workspace = Session.workspace + if (!workspace.isOk()) { + return undefined + } + + return workspace.unwrap().root.path +} + export interface PlaywrightProjectBundle { browsers: string[] relativePlaywrightConfigPath: string @@ -94,11 +108,24 @@ export class PlaywrightProjectBundler { ignoredFiles, ) - for (const filePath of includedFiles) { - files.push({ - filePath, - physical: true, - }) + // Included paths may run through symlinks — under pnpm every package in + // node_modules is one. Left alone they produce an archive that tar cannot + // extract, so resolve them into entries that can be. + const bundleRoot = bundleRootPath() + if (bundleRoot === undefined) { + for (const filePath of includedFiles) { + files.push({ + filePath, + physical: true, + }) + } + } else { + files.push(...await resolveBundleFiles({ + matchedPaths: includedFiles, + bundleRoot, + ignoreCwd: dir, + ignorePatterns: ignoredFiles, + })) } return { From a8546fd55f4be80410541871c8c9d2d5638d3d15 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Fri, 7 Aug 2026 11:41:50 +0900 Subject: [PATCH 3/7] fix(cli): resolve Playwright config paths through symlinks [RED-713] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test discovery ran in the lexical namespace: testDir was resolved against the config directory but never through symlinks. A testDir that is a symlink discovered no files at all — globbing with a symlinked working directory finds nothing — and a testDir running through a symlink discovered files at their through-link spellings, archiving them at paths that collide with the symlink entries the bundle carries. Under pnpm, where a workspace dependency is a symlink into the workspace, a config whose testDir reaches into such a package hit both. Canonicalize every path the config names — testDir, snapshotDir, tsconfig, globalSetup, globalTeardown, and the config file itself — at construction, so directories, discovered files and snapshot patterns all live in one namespace. Reconcile discovered paths into the bundle root's own spelling before the parser sees them, since the parser bounds its directory walks by exact string comparison against that root. Canonicalization stops at the bundle's edge: when a reference's canonical location is outside the bundle root but its spelling is inside, files are bundled at the spelling, which extracts as ordinary directories. A file outside the root under every spelling fails with an actionable error where it was previously dropped silently or archived at names that never extracted. The config still spells its references as written, so every symlink a spelling traverses is carried into the archive as a symlink entry — link only, content comes from discovery at real paths — and marked, so the bundler can warn rather than stay silent if archive-path conflicts ever force one out. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../checkly.config.ts | 19 +++ .../test-bundling-linked-testdir/package.json | 7 + .../playwright.config.ts | 10 ++ .../pnpm-lock.yaml | 52 ++++++ .../shared/tests/example.spec.ts | 6 + .../shared/tests/setup.ts | 4 + .../__tests__/playwright-check.spec.ts | 52 ++++++ .../__tests__/playwright-config.spec.ts | 69 +++++++- .../__tests__/symlink-resolver.spec.ts | 155 ++++++++++++++++- .../package.json | 7 + .../src/tests/flows/checkout.spec.ts | 10 ++ .../playwright.config.ts | 13 ++ .../playwright-symlink-testdir/package.json | 7 + .../playwright.config.ts | 13 ++ .../real/tests/example.spec.ts | 11 ++ .../Google-test-1-Mobile-Chrome-linux.png | Bin 0 -> 84794 bytes .../__tests__/parse-files.spec.ts | 139 ++++++++++++++++ .../playwright-config-expander.spec.ts | 156 ++++++++++++++++++ .../cli/src/services/check-parser/bundler.ts | 13 ++ .../cli/src/services/check-parser/parser.ts | 18 +- .../playwright-config-expander.ts | 121 +++++++++++++- .../cli/src/services/playwright-config.ts | 71 +++++++- .../services/playwright-project-bundler.ts | 5 + packages/cli/src/services/symlink-resolver.ts | 111 ++++++++++++- 24 files changed, 1041 insertions(+), 28 deletions(-) create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/checkly.config.ts create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/package.json create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/playwright.config.ts create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/pnpm-lock.yaml create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/shared/tests/example.spec.ts create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/shared/tests/setup.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-deep-symlink-testdir/package.json create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-deep-symlink-testdir/packages/tests-pkg/src/tests/flows/checkout.spec.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-deep-symlink-testdir/playwright.config.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-symlink-testdir/package.json create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-symlink-testdir/playwright.config.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-symlink-testdir/real/tests/example.spec.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-symlink-testdir/real/tests/example.spec.ts-snapshots/Google-test-1-Mobile-Chrome-linux.png create mode 100644 packages/cli/src/services/check-parser/__tests__/playwright-config-expander.spec.ts diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/checkly.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/checkly.config.ts new file mode 100644 index 000000000..3dede8ef2 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/checkly.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'checkly' + +const config = defineConfig({ + projectName: 'Check Fixture', + logicalId: 'check-fixture', + checks: { + checkMatch: '**/*.check.ts', + ignoreDirectoriesMatch: [], + playwrightConfigPath: './playwright.config.ts', + playwrightChecks: [ + { + logicalId: 'playwright-check-suite', + name: 'Playwright Check Suite', + } + ], + }, +}) + +export default config diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/package.json b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/package.json new file mode 100644 index 000000000..b12adbc29 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/package.json @@ -0,0 +1,7 @@ +{ + "name": "playwright-bundle-test", + "version": "1.0.0", + "dependencies": { + "@playwright/test": "^1.55.1" + } +} diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/playwright.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/playwright.config.ts new file mode 100644 index 000000000..915d3bac4 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/playwright.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + // linked-tests is created as a symlink to ./shared/tests at test time. Both + // references run through the link, so the extracted bundle must contain the + // link for these spellings to resolve. + testDir: './linked-tests', + globalSetup: './linked-tests/setup.ts', + timeout: 30000, +}) diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/pnpm-lock.yaml b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/pnpm-lock.yaml new file mode 100644 index 000000000..9c3c4c244 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/pnpm-lock.yaml @@ -0,0 +1,52 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@playwright/test': + specifier: ^1.55.1 + version: 1.59.1 + +packages: + + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + +snapshots: + + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + + fsevents@2.3.2: + optional: true + + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/shared/tests/example.spec.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/shared/tests/example.spec.ts new file mode 100644 index 000000000..4cbbbc71e --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/shared/tests/example.spec.ts @@ -0,0 +1,6 @@ +import { test, expect } from '@playwright/test' + +test('basic test', async ({ page }) => { + await page.goto('https://playwright.dev/') + expect(await page.title()).toContain('Playwright') +}) diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/shared/tests/setup.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/shared/tests/setup.ts new file mode 100644 index 000000000..5674a5636 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-linked-testdir/shared/tests/setup.ts @@ -0,0 +1,4 @@ +export default async function globalSetup () { + // Intentionally trivial; the test asserts where this file lands in the + // archive, not what it does. +} diff --git a/packages/cli/src/constructs/__tests__/playwright-check.spec.ts b/packages/cli/src/constructs/__tests__/playwright-check.spec.ts index 7b5f267ce..d5fe6f3e1 100644 --- a/packages/cli/src/constructs/__tests__/playwright-check.spec.ts +++ b/packages/cli/src/constructs/__tests__/playwright-check.spec.ts @@ -1189,6 +1189,58 @@ describe('PlaywrightCheck', () => { }, DEFAULT_TEST_TIMEOUT) }) + describe('bundling with testDir through a symlink', () => { + let fixt: FixtureSandbox + + beforeAll(async () => { + fixt = await FixtureSandbox.create({ + source: path.join(__dirname, 'fixtures', 'playwright-check', 'test-cases', 'test-bundling-linked-testdir'), + }) + + // The config's testDir and globalSetup both run through this link. + await fs.symlink( + path.join('shared', 'tests'), + path.join(fixt.root, 'linked-tests'), + ) + }, DEFAULT_TEST_TIMEOUT) + + afterAll(async () => { + await fixt?.destroy() + }) + + it('should bundle content at real paths and carry the link the config spells its paths through', async () => { + const output = await parseProject(fixt) + + const { + codeBundlePath, + } = output.payload.resources[0].payload as any + + const entries = await listTarEntries(codeBundlePath) + expectNoSymlinkHasChildren(entries) + + const files = entries.filter(entry => entry.type !== 'SymbolicLink').map(entry => entry.path) + const symlinks = entries + .filter(entry => entry.type === 'SymbolicLink') + .map(entry => `${entry.path} -> ${entry.linkpath}`) + + // Content lives at its real paths — never at the through-link spelling, + // which under a kept link would be the symlink-with-children shape tar + // cannot extract. + expect(files).toEqual(expect.arrayContaining([ + 'shared/tests/example.spec.ts', + 'shared/tests/setup.ts', + 'playwright.config.ts', + ])) + expect(files.filter(file => file.startsWith('linked-tests/'))).toEqual([]) + + // The archived config still says './linked-tests', so the link must + // travel with the bundle for that spelling to resolve on the runner. + expect(symlinks).toEqual([ + 'linked-tests -> shared/tests', + ]) + }, DEFAULT_TEST_TIMEOUT) + }) + describe('bundling with subdirectory playwright config', () => { let fixt: FixtureSandbox diff --git a/packages/cli/src/services/__tests__/playwright-config.spec.ts b/packages/cli/src/services/__tests__/playwright-config.spec.ts index df9010c8b..1b85823d5 100644 --- a/packages/cli/src/services/__tests__/playwright-config.spec.ts +++ b/packages/cli/src/services/__tests__/playwright-config.spec.ts @@ -1,10 +1,24 @@ +import fs from 'node:fs/promises' +import { tmpdir } from 'node:os' import path from 'node:path' import { PlaywrightConfig } from '../playwright-config.js' -import { describe, it, expect } from 'vitest' +import { describe, it, expect, afterEach } from 'vitest' import { Session } from '../../constructs/index.js' const fixturesPath = path.join(__dirname, 'fixtures', 'playwright-configs') +const sandboxes: string[] = [] + +afterEach(async () => { + await Promise.all(sandboxes.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true }))) +}) + +async function makeSandbox (): Promise { + const root = await fs.realpath(await fs.mkdtemp(path.join(tmpdir(), 'playwright-config-'))) + sandboxes.push(root) + return root +} + describe('playwright-config', () => { it('it should load simple config correctly', async () => { const pwConfig = await Session.loadFile(path.join(fixturesPath, 'simple-config.ts')) @@ -18,4 +32,57 @@ describe('playwright-config', () => { expect(Array.from(config.testMatch)).toEqual(['tests.*.ts']) expect(config.getBrowsers()).toEqual(['chromium']) }) + + it('should resolve config paths through symlinks into one canonical namespace', async () => { + // Everything the config names must end up in the same namespace: snapshot + // patterns are built by mixing testDir, snapshotDir and discovered file + // paths, and one path spelled through a link while another is resolved + // produces `..`-laden glob patterns that match nothing. + const root = await makeSandbox() + await fs.mkdir(path.join(root, 'real', 'tests'), { recursive: true }) + await fs.writeFile(path.join(root, 'real', 'setup.ts'), 'export default async () => {}') + await fs.symlink(path.join('real', 'tests'), path.join(root, 'linked-tests')) + await fs.symlink(path.join('real', 'setup.ts'), path.join(root, 'linked-setup.ts')) + + const config = new PlaywrightConfig(path.join(root, 'playwright.config.ts'), { + testDir: './linked-tests', + globalSetup: './linked-setup.ts', + projects: [{ name: 'proj', testDir: './linked-tests' }], + }) + + expect(config.testDir).toBe(path.join(root, 'real', 'tests')) + expect(config.snapshotDir).toBe(path.join(root, 'real', 'tests')) + expect(config.projects?.[0].testDir).toBe(path.join(root, 'real', 'tests')) + expect(Array.from(config.files)).toEqual([path.join(root, 'real', 'setup.ts')]) + }) + + it('should canonicalize a config file path reached through a symlink', async () => { + // A config referenced through a link (playwrightConfigPath into a linked + // package) must land in the same canonical namespace as its content, or it + // gets archived beneath the very link the bundle carries for it — which + // forces that link out of the archive. + const root = await makeSandbox() + await fs.mkdir(path.join(root, 'real-pkg'), { recursive: true }) + await fs.writeFile(path.join(root, 'real-pkg', 'playwright.config.ts'), 'export default {}') + await fs.symlink('real-pkg', path.join(root, 'linked-pkg')) + + const spelled = path.join(root, 'linked-pkg', 'playwright.config.ts') + const config = new PlaywrightConfig(spelled, {}) + + expect(config.configFilePath).toBe(path.join(root, 'real-pkg', 'playwright.config.ts')) + // The spelled location is recorded so the traversed link travels with the + // bundle and the spelling still resolves on the runner. + expect(config.referencedPaths.get(spelled)).toBe(config.configFilePath) + }) + + it('should keep nonexistent config paths as spelled', async () => { + const root = await makeSandbox() + + const config = new PlaywrightConfig(path.join(root, 'playwright.config.ts'), { + testDir: './non-existent', + }) + + // Nothing to resolve; downstream code handles the missing directory. + expect(config.testDir).toBe(path.join(root, 'non-existent')) + }) }) diff --git a/packages/cli/src/services/__tests__/symlink-resolver.spec.ts b/packages/cli/src/services/__tests__/symlink-resolver.spec.ts index 2dc7c12bf..dc7d4cc18 100644 --- a/packages/cli/src/services/__tests__/symlink-resolver.spec.ts +++ b/packages/cli/src/services/__tests__/symlink-resolver.spec.ts @@ -61,10 +61,12 @@ interface BundleOptions { cwd?: string /** The archive root. Defaults to the sandbox root. */ bundleRoot?: string + /** Spelled paths whose traversed links must travel with the bundle. */ + referencedPaths?: string[] } async function bundle (root: string, patterns: string[], options: BundleOptions = {}): Promise { - const { ignore = [], cwd = root, bundleRoot = root } = options + const { ignore = [], cwd = root, bundleRoot = root, referencedPaths } = options const matchedPaths = await findFilesWithPattern(cwd, patterns, ignore) @@ -73,6 +75,7 @@ async function bundle (root: string, patterns: string[], options: BundleOptions bundleRoot, ignoreCwd: cwd, ignorePatterns: ignore, + referencedPaths, }) // The archive must never contain a symlink with entries beneath it, whatever @@ -461,6 +464,156 @@ describe('resolveBundleFiles', () => { expectNoSymlinkHasChildren(files) }) + describe('referenced paths', () => { + it('should carry the links a referenced path traverses, without expanding their targets', async () => { + // The shape of a config whose testDir runs through a link: content is + // discovered at real paths by someone else (the parser); the resolver's + // job is only to make the spelled path resolve in the archive. + const root = await makeSandbox({ + 'shared/tests/a.spec.ts': 'test', + 'shared/other/unrelated.txt': 'not asked for', + 'linked': link('shared'), + 'package.json': '{}', + }) + + const files = await bundle(root, ['package.json'], { + referencedPaths: [path.join(root, 'linked', 'tests')], + }) + + // The link travels; the target's content does not (no expansion). + expect(entries(files)).toEqual([ + 'linked -> shared', + 'package.json', + ]) + }) + + it('should carry every link in a chained referenced path, each at its real path', async () => { + const root = await makeSandbox({ + 'real-a/sub/marker.txt': 'a', + 'real-b/file.txt': 'b', + 'link-a': link('real-a'), + 'package.json': '{}', + }) + // A second link *inside* the first link's target. + await fs.symlink(path.join('..', '..', 'real-b'), path.join(root, 'real-a', 'sub', 'link-b')) + + const files = await bundle(root, ['package.json'], { + referencedPaths: [path.join(root, 'link-a', 'sub', 'link-b', 'file.txt')], + }) + + // Each link sits at its own symlink-free archive path — the second at its + // real-namespace location, never beneath the first. + expect(entries(files)).toEqual([ + 'link-a -> real-a', + 'package.json', + 'real-a/sub/link-b -> ../../real-b', + ]) + }) + + it('should not let the prune pass drop a referenced link with no resolver-visible content', async () => { + // The referenced link's target content is bundled by the parser, which + // the resolver cannot see — target occupancy must not be required here. + const root = await makeSandbox({ + 'shared/tests/a.spec.ts': 'test', + 'linked': link('shared'), + 'package.json': '{}', + }) + + const files = await bundle(root, [], { + referencedPaths: [path.join(root, 'linked')], + }) + + expect(entries(files)).toEqual([ + 'linked -> shared', + ]) + }) + + it('should emit nothing when the referenced path is the bundle root reached through a link', async () => { + // `checkly deploy --config /path/to/link-to-proj/checkly.config.ts`: the + // whole project is reached through a symlink, and the config directory — + // which testDir defaults to — IS the bundle root. The root is not an + // archive entry; emitting a link at the empty name aborts the archive. + const outer = await makeSandbox({ + 'real-proj/tests/a.spec.ts': 'test', + 'real-proj/package.json': '{}', + 'alias-proj': link('real-proj'), + }) + const root = path.join(outer, 'alias-proj') + + const files = await bundle(root, ['package.json'], { + bundleRoot: root, + referencedPaths: [root], + }) + + expect(entries(files)).toEqual([ + 'package.json', + ]) + }) + + it('should discard the whole chain when a later hop leaves the bundle root', async () => { + // The first hop stays inside the root, but the reference's content leaves + // it at the second hop — so discovery bundles the content at the spelled + // path as real directories. Emitting the first link anyway would place it + // above those directories, guaranteeing its own removal later. + const outer = await makeSandbox({ + 'outside/tests/a.spec.ts': 'test', + 'proj/b/marker.txt': 'b', + 'proj/package.json': '{}', + }) + const root = path.join(outer, 'proj') + await fs.symlink('b', path.join(root, 'a')) + await fs.symlink(path.join('..', '..', 'outside', 'tests'), path.join(root, 'b', 'tests')) + + const files = await bundle(root, ['package.json'], { + bundleRoot: root, + referencedPaths: [path.join(root, 'a', 'tests')], + }) + + expect(entries(files)).toEqual([ + 'package.json', + ]) + }) + + it('should mark a link as referenced even when an include pattern emitted it first', async () => { + // Being referenced is a property of the link, not of which pass reached + // it first — the marker is what makes the bundler warn instead of staying + // silent if the link later has to be dropped. + const root = await makeSandbox({ + 'shared/tests/a.spec.ts': 'test', + 'linked': link('shared'), + 'package.json': '{}', + }) + + const files = await bundle(root, ['linked', 'package.json'], { + referencedPaths: [path.join(root, 'linked')], + }) + + const entry = files.find(file => file.archivePath === 'linked') + expect(entry?.symlinkTarget).toBe('shared') + expect(entry?.referencedLink).toBe(true) + }) + + it('should skip referenced links whose target is outside the bundle root', async () => { + // Discovery already turned the out-of-root content into a hard error; + // there is nothing sensible left to emit for the link itself. + const outer = await makeSandbox({ + 'outside/tests/a.spec.ts': 'test', + 'proj/package.json': '{}', + }) + const root = path.join(outer, 'proj') + await fs.symlink(path.join('..', 'outside'), path.join(root, 'linked')) + + const files = await bundle(root, ['package.json'], { + bundleRoot: root, + referencedPaths: [path.join(root, 'linked', 'tests')], + }) + + expect(entries(files)).toEqual([ + 'package.json', + ]) + }) + }) + describe('broken symlinks', () => { it.each([ ['a relative target', './missing'], diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-deep-symlink-testdir/package.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-deep-symlink-testdir/package.json new file mode 100644 index 000000000..7399f2fe5 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-deep-symlink-testdir/package.json @@ -0,0 +1,7 @@ +{ + "name": "playwright-project", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.57.0" + } +} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-deep-symlink-testdir/packages/tests-pkg/src/tests/flows/checkout.spec.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-deep-symlink-testdir/packages/tests-pkg/src/tests/flows/checkout.spec.ts new file mode 100644 index 000000000..ffefc2e6a --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-deep-symlink-testdir/packages/tests-pkg/src/tests/flows/checkout.spec.ts @@ -0,0 +1,10 @@ +import {test, chromium} from '@playwright/test'; + +test('Google test', async () => { + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + // check start page is displayed + await page.goto('https://google.com'); +}); diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-deep-symlink-testdir/playwright.config.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-deep-symlink-testdir/playwright.config.ts new file mode 100644 index 000000000..cc73d04fe --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-deep-symlink-testdir/playwright.config.ts @@ -0,0 +1,13 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + // linked-pkg is created as a symlink to ./packages/tests-pkg at test time; + // testDir runs *through* the link into a subdirectory of its target. + testDir: './linked-pkg/src/tests/flows', + projects: [ + { + name: 'Mobile Chrome', + use: { ...devices['Pixel 7'] }, + }, + ], +}); diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-symlink-testdir/package.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-symlink-testdir/package.json new file mode 100644 index 000000000..5a4b9ec8c --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-symlink-testdir/package.json @@ -0,0 +1,7 @@ +{ + "name": "playwright-project-snapshots", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.57.0" + } +} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-symlink-testdir/playwright.config.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-symlink-testdir/playwright.config.ts new file mode 100644 index 000000000..9695d359f --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-symlink-testdir/playwright.config.ts @@ -0,0 +1,13 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + // Created as a symlink to ./real/tests at test time; fixtures cannot carry + // symlinks portably (Windows checkouts need special git configuration). + testDir: './linked-tests', + projects: [ + { + name: 'Mobile Chrome', + use: { ...devices['Pixel 7'] }, + }, + ], +}); diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-symlink-testdir/real/tests/example.spec.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-symlink-testdir/real/tests/example.spec.ts new file mode 100644 index 000000000..b7ab5cbaa --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-symlink-testdir/real/tests/example.spec.ts @@ -0,0 +1,11 @@ +import {test, expect, chromium} from '@playwright/test'; + +test('Google test', async () => { + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + // check start page is displayed + await page.goto('https://google.com'); + await expect(page).toHaveScreenshot() +}); diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-symlink-testdir/real/tests/example.spec.ts-snapshots/Google-test-1-Mobile-Chrome-linux.png b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/playwright-symlink-testdir/real/tests/example.spec.ts-snapshots/Google-test-1-Mobile-Chrome-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..97ab545d6f1554a99de8f66de6704a1d09c1fee7 GIT binary patch literal 84794 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 literal 0 HcmV?d00001 diff --git a/packages/cli/src/services/check-parser/__tests__/parse-files.spec.ts b/packages/cli/src/services/check-parser/__tests__/parse-files.spec.ts index f9218afb1..969e814d1 100644 --- a/packages/cli/src/services/check-parser/__tests__/parse-files.spec.ts +++ b/packages/cli/src/services/check-parser/__tests__/parse-files.spec.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs/promises' import path from 'node:path' import { describe, test, expect, afterAll, beforeAll } from 'vitest' @@ -101,4 +102,142 @@ describe('project parser - getFilesAndDependencies()', { timeout: 45_000 }, () = expect(output.errors).toHaveLength(0) }) }) + + describe('playwright-symlink-testdir', () => { + let fixt: FixtureSandbox + + beforeAll(async () => { + fixt = await FixtureSandbox.create({ + template: 'playwright', + source: path.join(__dirname, 'check-parser-fixtures', 'playwright-symlink-testdir'), + }) + + // The config's testDir points here. Under pnpm every package in + // node_modules is a directory symlink like this one, and globbing with a + // symlinked working directory finds nothing at all — this fixture pins + // that the paths the config names are resolved through links first. + await fs.symlink( + path.join('real', 'tests'), + path.join(fixt.root, 'linked-tests'), + ) + }, 180_000) + + afterAll(async () => { + await fixt?.destroy() + }) + + test('should discover files and snapshots through a symlinked testDir, at real paths', async () => { + const result = await fixt.run('pnpm', [ + 'checkly', + 'debug', + 'parse-playwright-config', + '--file', + fixt.abspath('playwright.config.ts'), + ]) + + if (result.exitCode !== 0) { + // eslint-disable-next-line no-console + console.error('stderr', result.stderr) + // eslint-disable-next-line no-console + console.error('stdout', result.stdout) + } + + expect(result.exitCode).toBe(0) + + const output: { + files: File[] + errors: string[] + } = JSON.parse(result.stdout) + + // Everything resolves into the one real namespace: the test file and its + // snapshot appear under real/tests, never under the linked-tests spelling. + // Snapshot discovery is the sensitive part — its glob patterns mix testDir + // with the discovered file paths, and a namespace mismatch silently + // matches nothing. + expect(output.files).toEqual(expect.arrayContaining([ + { physical: true, filePath: pathToPosix(fixt.abspath('package.json')) }, + { physical: true, filePath: pathToPosix(fixt.abspath('playwright.config.ts')) }, + { physical: true, filePath: pathToPosix(fixt.abspath('real', 'tests', 'example.spec.ts')) }, + { + physical: true, + // The full real path, deliberately: an assertion that merely contains + // the -snapshots suffix would also match the through-link spelling, + // which is the namespace mix this fixture exists to rule out. + filePath: pathToPosix(fixt.abspath( + 'real', 'tests', 'example.spec.ts-snapshots', 'Google-test-1-Mobile-Chrome-linux.png', + )), + }, + ])) + expect(output.files).toHaveLength(4) + expect(output.errors).toHaveLength(0) + }) + }) + + describe('playwright-deep-symlink-testdir', () => { + let fixt: FixtureSandbox + + beforeAll(async () => { + fixt = await FixtureSandbox.create({ + template: 'playwright', + source: path.join(__dirname, 'check-parser-fixtures', 'playwright-deep-symlink-testdir'), + }) + + // testDir points *through* this link into a subdirectory of the target — + // the shape a pnpm workspace produces when a config's testDir reaches + // into a linked package (node_modules/@scope/pkg/src/tests/...). Unlike a + // testDir that IS a link, globbing here works, but discovers files at the + // through-link spelling, which is not where they belong in a bundle. + await fs.symlink( + path.join('packages', 'tests-pkg'), + path.join(fixt.root, 'linked-pkg'), + ) + }, 180_000) + + afterAll(async () => { + await fixt?.destroy() + }) + + test('should discover files at real paths when testDir runs through a symlink', async () => { + const result = await fixt.run('pnpm', [ + 'checkly', + 'debug', + 'parse-playwright-config', + '--file', + fixt.abspath('playwright.config.ts'), + ]) + + if (result.exitCode !== 0) { + // eslint-disable-next-line no-console + console.error('stderr', result.stderr) + // eslint-disable-next-line no-console + console.error('stdout', result.stdout) + } + + expect(result.exitCode).toBe(0) + + const output: { + files: File[] + errors: string[] + } = JSON.parse(result.stdout) + + expect(output.files).toEqual(expect.arrayContaining([ + { physical: true, filePath: pathToPosix(fixt.abspath('package.json')) }, + { physical: true, filePath: pathToPosix(fixt.abspath('playwright.config.ts')) }, + { + physical: true, + filePath: pathToPosix(fixt.abspath( + 'packages', 'tests-pkg', 'src', 'tests', 'flows', 'checkout.spec.ts', + )), + }, + ])) + // Nothing at the through-link spelling. + for (const file of output.files) { + if (file.physical) { + expect(file.filePath).not.toContain('linked-pkg') + } + } + expect(output.files).toHaveLength(3) + expect(output.errors).toHaveLength(0) + }) + }) }) diff --git a/packages/cli/src/services/check-parser/__tests__/playwright-config-expander.spec.ts b/packages/cli/src/services/check-parser/__tests__/playwright-config-expander.spec.ts new file mode 100644 index 000000000..9bc1c66a8 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/playwright-config-expander.spec.ts @@ -0,0 +1,156 @@ +import fs from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import { PlaywrightConfig } from '../../playwright-config.js' +import { PlaywrightConfigExpander } from '../playwright-config-expander.js' + +const sandboxes: string[] = [] + +afterEach(async () => { + await Promise.all(sandboxes.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true }))) +}) + +async function makeSandbox (): Promise { + const root = await fs.realpath(await fs.mkdtemp(path.join(tmpdir(), 'pw-expander-'))) + sandboxes.push(root) + return root +} + +describe('PlaywrightConfigExpander', () => { + it('should discover test files through a symlinked testDir', async () => { + const outer = await makeSandbox() + const root = path.join(outer, 'proj') + await fs.mkdir(path.join(root, 'real', 'tests'), { recursive: true }) + await fs.writeFile(path.join(root, 'real', 'tests', 'a.spec.ts'), 'test') + await fs.symlink(path.join('real', 'tests'), path.join(root, 'linked-tests')) + + const config = new PlaywrightConfig(path.join(root, 'playwright.config.ts'), { + testDir: './linked-tests', + }) + + const files = await new PlaywrightConfigExpander().findTestFiles(config, { bundleRoot: root }) + + // Globbing with a symlinked working directory finds nothing, so without + // canonicalization this discovers zero test files. (The config file itself + // is always part of the result.) + expect(files.sort()).toEqual([ + path.join(root, 'playwright.config.ts'), + path.join(root, 'real', 'tests', 'a.spec.ts'), + ]) + }) + + it('should spell discovered paths in the bundle root namespace when the root is reached through a link', async () => { + const outer = await makeSandbox() + await fs.mkdir(path.join(outer, 'real-proj', 'tests'), { recursive: true }) + await fs.writeFile(path.join(outer, 'real-proj', 'tests', 'a.spec.ts'), 'test') + await fs.symlink('real-proj', path.join(outer, 'alias-proj')) + const lexicalRoot = path.join(outer, 'alias-proj') + + const config = new PlaywrightConfig(path.join(lexicalRoot, 'playwright.config.ts'), { + testDir: './tests', + }) + + const files = await new PlaywrightConfigExpander().findTestFiles(config, { bundleRoot: lexicalRoot }) + + // Discovery works in the canonical namespace, but everything downstream + // measures paths against the root as the caller spelled it — a canonical + // path against a differently-spelled root would escape it and produce + // `..`-prefixed archive names. + expect(files.sort()).toEqual([ + path.join(lexicalRoot, 'playwright.config.ts'), + path.join(lexicalRoot, 'tests', 'a.spec.ts'), + ]) + }) + + it('should bundle at the spelling when a link points outside the bundle root', async () => { + // Canonicalization stops at the bundle's edge: the spelled tree extracts as + // ordinary directories, which is exactly what such projects relied on + // before, and there is no in-root canonical location to prefer. + const outer = await makeSandbox() + const root = path.join(outer, 'proj') + await fs.mkdir(root, { recursive: true }) + await fs.mkdir(path.join(outer, 'outside', 'tests'), { recursive: true }) + await fs.writeFile(path.join(outer, 'outside', 'tests', 'a.spec.ts'), 'test') + await fs.symlink(path.join('..', 'outside'), path.join(root, 'linked')) + + const config = new PlaywrightConfig(path.join(root, 'playwright.config.ts'), { + testDir: './linked/tests', + }) + + const files = await new PlaywrightConfigExpander().findTestFiles(config, { bundleRoot: root }) + + expect(files).toEqual(expect.arrayContaining([ + path.join(root, 'linked', 'tests', 'a.spec.ts'), + ])) + }) + + it('should re-express a file under the most specific out-of-root spelling', async () => { + // Two config references whose out-of-root canonical targets nest: the + // setup file lives under BOTH canonical prefixes, and must come back under + // the spelling of the more specific one, or its config reference breaks. + const outer = await makeSandbox() + const root = path.join(outer, 'proj') + await fs.mkdir(root, { recursive: true }) + await fs.mkdir(path.join(outer, 'shared', 'setup'), { recursive: true }) + await fs.writeFile(path.join(outer, 'shared', 'tests.spec.ts'), 'test') + await fs.writeFile(path.join(outer, 'shared', 'setup', 'global.ts'), 'export default async () => {}') + await fs.symlink(path.join('..', 'shared'), path.join(root, 'linked')) + await fs.symlink(path.join('..', 'shared', 'setup'), path.join(root, 'linked-setup')) + + const config = new PlaywrightConfig(path.join(root, 'playwright.config.ts'), { + testDir: './linked', + globalSetup: './linked-setup/global.ts', + }) + + const files = await new PlaywrightConfigExpander().findTestFiles(config, { bundleRoot: root }) + + expect(files).toEqual(expect.arrayContaining([ + path.join(root, 'linked', 'tests.spec.ts'), + path.join(root, 'linked-setup', 'global.ts'), + ])) + expect(files).not.toContain(path.join(root, 'linked', 'setup', 'global.ts')) + }) + + it('should error on a discovered file outside the bundle root under every spelling', async () => { + const outer = await makeSandbox() + const root = path.join(outer, 'proj') + await fs.mkdir(root, { recursive: true }) + await fs.mkdir(path.join(outer, 'shared-tests'), { recursive: true }) + await fs.writeFile(path.join(outer, 'shared-tests', 'a.spec.ts'), 'test') + + // No symlink involved: the config plainly names a directory outside the + // root. Such files cannot be represented in the bundle; previously they + // were archived at `..`-escaping names that never extracted. + const config = new PlaywrightConfig(path.join(root, 'playwright.config.ts'), { + testDir: '../shared-tests', + }) + + await expect(new PlaywrightConfigExpander().findTestFiles(config, { bundleRoot: root })) + .rejects.toThrow(/outside the project's bundle root/) + }) + + it('should pass discovered paths through when no bundle root is given', async () => { + const outer = await makeSandbox() + const root = path.join(outer, 'proj') + await fs.mkdir(root, { recursive: true }) + await fs.mkdir(path.join(outer, 'outside', 'tests'), { recursive: true }) + await fs.writeFile(path.join(outer, 'outside', 'tests', 'a.spec.ts'), 'test') + await fs.symlink(path.join('..', 'outside', 'tests'), path.join(root, 'linked-tests')) + + const config = new PlaywrightConfig(path.join(root, 'playwright.config.ts'), { + testDir: './linked-tests', + }) + + // The standalone config debugging command has no bundle root; it inspects + // rather than bundles, and must not reject configs the bundler would. + const files = await new PlaywrightConfigExpander().findTestFiles(config) + + expect(files.sort()).toEqual([ + path.join(outer, 'outside', 'tests', 'a.spec.ts'), + path.join(root, 'playwright.config.ts'), + ]) + }) +}) diff --git a/packages/cli/src/services/check-parser/bundler.ts b/packages/cli/src/services/check-parser/bundler.ts index a8c420b6c..4396f56a0 100644 --- a/packages/cli/src/services/check-parser/bundler.ts +++ b/packages/cli/src/services/check-parser/bundler.ts @@ -70,6 +70,19 @@ function dropSymlinksWithChildren (entries: Array<[string, File]>): File[] { return true } + if (file.referencedLink) { + // Path references in a bundled file (e.g. a Playwright config's + // testDir) depend on this link, and they will not resolve without it. + // The situation is a conflict between those references and files + // archived beneath the link's own path — say so, rather than letting + // the check fail only in the cloud. + process.stderr.write( + `Warning: ${name} is a symlink that bundled configuration refers to, but other files ` + + `are archived beneath its path, so the symlink itself cannot be included. References ` + + `through it may not resolve when the check runs.\n`, + ) + } + debug(`Dropping symlink ${name}: other files are archived beneath it`) return false diff --git a/packages/cli/src/services/check-parser/parser.ts b/packages/cli/src/services/check-parser/parser.ts index b555d90b4..d6000366d 100644 --- a/packages/cli/src/services/check-parser/parser.ts +++ b/packages/cli/src/services/check-parser/parser.ts @@ -149,6 +149,13 @@ export type PhysicalFile = { * copy of the file's contents. Relative to the entry's own archive directory. */ symlinkTarget?: string + /** + * Marks a symlink entry that a bundled file's own path references depend on + * (e.g. a Playwright config's testDir spelled through the link). If such a + * link cannot make it into the archive, those references break at run time — + * dropping it deserves a warning, not just a debug line. + */ + referencedLink?: true } export type File = @@ -253,8 +260,15 @@ export class Parser { files: File[] errors: string[] }> { - const files = new Set(await this.#configExpander.findTestFiles(playwrightConfig)) - files.add(playwrightConfig.configFilePath) + // The result includes the config file itself; the expander seeds it, so it + // goes through the same root-spelling reconciliation as everything else. + const files = new Set(await this.#configExpander.findTestFiles(playwrightConfig, { + // The workspace root doubles as the bundle root (the Bundler's strip + // prefix); discovered paths must be spelled relative to it or their + // archive names go wrong. Without a workspace there is no bundle root and + // paths pass through as discovered. + bundleRoot: this.workspace?.root.path, + })) const errors = new Set() const missingFiles = new Set() const resultFileSet = new Set() diff --git a/packages/cli/src/services/check-parser/playwright-config-expander.ts b/packages/cli/src/services/check-parser/playwright-config-expander.ts index da3581dda..3b3d47141 100644 --- a/packages/cli/src/services/check-parser/playwright-config-expander.ts +++ b/packages/cli/src/services/check-parser/playwright-config-expander.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs/promises' import * as path from 'node:path' import url from 'node:url' @@ -6,8 +7,23 @@ import { minimatch } from 'minimatch' import { findFilesWithPattern, pathToPosix } from '../util.js' import { PlaywrightConfig } from '../playwright-config.js' +export interface FindTestFilesOptions { + /** + * The directory the code bundle is rooted at. When given, discovered paths + * are reconciled into its spelling, and a file outside it is an error. When + * absent (e.g. the standalone config debugging command), paths are returned + * as discovered. + */ + bundleRoot?: string +} + export class PlaywrightConfigExpander { - #cache = new Map>() + /** + * Keyed by config instance rather than config path: two instances can share a + * canonical path while differing in the spellings they were reached through, + * and the result depends on those spellings via the reconciliation fallback. + */ + #cache = new WeakMap>>() private async collectFiles (cache: Map, testDir: string, ignoredFiles: string[]) { let files = cache.get(testDir) @@ -18,12 +34,15 @@ export class PlaywrightConfigExpander { return files } - async #findTestFiles (playwrightConfig: PlaywrightConfig): Promise { + async #findTestFiles (playwrightConfig: PlaywrightConfig, options: FindTestFilesOptions): Promise { const ignoredFiles = ['**/node_modules/**', '.git/**'] const cachedFiles = new Map() // If projects is definited, ignore root settings const projects = playwrightConfig.projects ?? [playwrightConfig] const found = new Set() + // The config file itself travels with the bundle, and its (canonical) path + // needs the same root-spelling reconciliation as everything else here. + found.add(playwrightConfig.configFilePath) playwrightConfig.files.forEach(file => found.add(file)) for (const project of projects) { // Cache the files by test dir @@ -41,20 +60,106 @@ export class PlaywrightConfigExpander { } } } - return Array.from(found) + return await this.#reconcile(Array.from(found), playwrightConfig, options) } - async findTestFiles (playwrightConfig: PlaywrightConfig): Promise { - const cacheKey = playwrightConfig.configFilePath - const cached = this.#cache.get(cacheKey) + /** + * Re-expresses discovered paths in the bundle root's own spelling. + * + * The config canonicalizes every path it names, so discovery works in the + * canonical namespace — but everything downstream measures paths against the + * bundle root as the caller spelled it. The two differ whenever the root is + * reached through a symlink (macOS /tmp, a config dir given via a linked + * path). Left unreconciled, a canonical path against a differently-spelled + * root escapes it: the parser's directory walks run past the project, and + * archive entries get `..`-prefixed names. + * + * Canonicalization stops at the bundle's edge. When a config-named path's + * canonical location is outside the root but its spelling is inside — a + * testDir reached through a link that points out of the project — the files + * are bundled at the spelling instead: the spelled tree extracts as ordinary + * directories, which is what such projects relied on before, and there is no + * in-root canonical location to prefer. + * + * A file outside the root under both spellings cannot be represented in the + * bundle at all, so it fails loudly. Previously such files were silently + * dropped or archived at `..`-escaping names that never extracted — failure + * either way, just later and quieter. + */ + async #reconcile ( + files: string[], + playwrightConfig: PlaywrightConfig, + options: FindTestFilesOptions, + ): Promise { + const { bundleRoot } = options + if (bundleRoot === undefined) { + return files + } + + let realRoot: string + try { + realRoot = await fs.realpath(bundleRoot) + } catch { + realRoot = bundleRoot + } + + const inRoot = (file: string) => + file === bundleRoot || file.startsWith(bundleRoot + path.sep) + || file === realRoot || file.startsWith(realRoot + path.sep) + + // Spellings whose canonical location left the root, for the fallback above: + // a file under such a canonical prefix is re-expressed under the spelling. + // Longest canonical prefix first, so that when one out-of-root target nests + // inside another (a snapshotDir inside a linked testDir's target), the most + // specific spelling claims the file. + const spelledFallbacks = Array.from(playwrightConfig.referencedPaths) + .filter(([spelled, canonical]) => + spelled !== canonical && inRoot(spelled) && !inRoot(canonical)) + .sort(([, a], [, b]) => b.length - a.length) + + return files.map(file => { + if (file === bundleRoot || file.startsWith(bundleRoot + path.sep)) { + return file + } + + if (file === realRoot || file.startsWith(realRoot + path.sep)) { + return path.join(bundleRoot, path.relative(realRoot, file)) + } + + for (const [spelled, canonical] of spelledFallbacks) { + if (file === canonical || file.startsWith(canonical + path.sep)) { + return path.join(spelled, path.relative(canonical, file)) + } + } + + throw new Error( + `${file} is outside the project's bundle root (${bundleRoot}) and cannot be included ` + + `in the code bundle. The bundle root is your workspace root, or the nearest package.json ` + + `directory when the project is not part of a workspace — if this file belongs to your ` + + `monorepo, make sure the package containing your Checkly config is listed in the ` + + `workspace configuration. Otherwise, move the file inside the project, or adjust ` + + `testDir and related settings.`, + ) + }) + } + + async findTestFiles (playwrightConfig: PlaywrightConfig, options: FindTestFilesOptions = {}): Promise { + let byRoot = this.#cache.get(playwrightConfig) + if (byRoot === undefined) { + byRoot = new Map>() + this.#cache.set(playwrightConfig, byRoot) + } + + const cacheKey = options.bundleRoot ?? '' + const cached = byRoot.get(cacheKey) if (cached !== undefined) { return await cached } // Cache the in-flight promise (not the resolved value) so that many checks // sharing one Playwright config walk the filesystem once instead of once // per check when they bundle concurrently. - const promise = this.#findTestFiles(playwrightConfig) - this.#cache.set(cacheKey, promise) + const promise = this.#findTestFiles(playwrightConfig, options) + byRoot.set(cacheKey, promise) return await promise } diff --git a/packages/cli/src/services/playwright-config.ts b/packages/cli/src/services/playwright-config.ts index 15244a1a7..8a51eee34 100644 --- a/packages/cli/src/services/playwright-config.ts +++ b/packages/cli/src/services/playwright-config.ts @@ -1,7 +1,27 @@ +import { realpathSync } from 'node:fs' import * as path from 'node:path' -function toAbsolutePath (dir: string, file: string) { - return path.resolve(dir, file) +/** + * Resolves a config-relative path and canonicalizes it through any symlinks. + * + * Canonicalizing here — at construction, for every path the config names — is + * load-bearing. Test files are found by globbing with testDir as the working + * directory, and glob returns nothing at all when that directory is a symlink + * (as it is when testDir points into node_modules under pnpm). Snapshot + * patterns are then built by mixing testDir, snapshotDir and the discovered + * file paths; if those lived in different namespaces — one spelled through a + * link, another resolved — the arithmetic produces `..`-laden glob patterns + * that match nothing. Everything the config names must therefore resolve into + * the one canonical namespace. + * + * A path that does not exist is kept as spelled; there is nothing to resolve. + */ +function canonicalize (absolute: string) { + try { + return realpathSync(absolute) + } catch { + return absolute + } } function parseBrowsers (config: any) { @@ -46,18 +66,36 @@ export class PlaywrightConfig { browsers: Set projects?: PlaywrightProject[] files: Set + /** + * The paths this config names, keyed by the exact spelling the config uses + * (resolved against the config directory, but not through symlinks), with the + * canonical path as the value. The canonicalized properties above are what + * discovery uses; the spellings are what the config file will still say when + * it runs from the extracted bundle. Any symlink a spelling traverses must + * therefore exist in the bundle too, or the reference resolves to nothing + * there — the bundler walks these to find which links to carry. And when a + * spelling's canonical path leaves the bundle root, the spelled-to-canonical + * pair is what lets discovery fall back to bundling at the spelling. + */ + referencedPaths: Map constructor (filePath: string, playwrightConfig: any) { const dir = path.dirname(filePath) this.projectName = '' this.platform = 'linux' - this.testDir = playwrightConfig.testDir ? toAbsolutePath(dir, playwrightConfig.testDir) : dir - this.snapshotDir = playwrightConfig.snapshotDir ? toAbsolutePath(dir, playwrightConfig.snapshotDir) : this.testDir + this.referencedPaths = new Map() + this.testDir = this.reference(dir, playwrightConfig.testDir ?? '.') + this.snapshotDir = playwrightConfig.snapshotDir ? this.reference(dir, playwrightConfig.snapshotDir) : this.testDir this.files = new Set() this.snapshotTemplates = new Set() const testMatch = playwrightConfig.testMatch ?? ['**/*.@(spec|test).?(c|m)[jt]s?(x)'] this.testMatch = new Set(Array.isArray(testMatch) ? testMatch : [testMatch]) - this.configFilePath = filePath + // The config's own location is a spelled reference like any other: the + // check runs it by this spelling, and its content must live in the same + // canonical namespace as everything the config names — a config reached + // through a symlink would otherwise be archived beneath a carried link, + // which forces the link out of the archive. + this.configFilePath = this.reference(dir, path.basename(filePath)) const fileDefinitions = ['tsconfig', 'globalSetup', 'globalTeardown'] for (const fileDefinition of fileDefinitions) { const definition = playwrightConfig[fileDefinition] @@ -65,9 +103,9 @@ export class PlaywrightConfig { continue } if (Array.isArray(definition)) { - definition.forEach((file: string) => this.files.add(toAbsolutePath(dir, file))) + definition.forEach((file: string) => this.files.add(this.reference(dir, file))) } else { - this.files.add(toAbsolutePath(dir, definition)) + this.files.add(this.reference(dir, definition)) } } @@ -93,6 +131,14 @@ export class PlaywrightConfig { } } + /** Records the spelled path and returns the canonical one. */ + reference (dir: string, file: string): string { + const spelled = path.resolve(dir, file) + const canonical = canonicalize(spelled) + this.referencedPaths.set(spelled, canonical) + return canonical + } + getBrowsers () { const browsers = new Set(this.browsers) this.projects?.forEach(project => project.browsers.forEach(browser => browsers.add(browser))) @@ -117,12 +163,19 @@ export class PlaywrightProject { expect: any snapshotTemplates: Set browsers: Set + // playwrightConfig is the enclosing PlaywrightConfig instance; typed loosely + // because this constructor also probes raw-config fields on it that the class + // does not carry (a long-standing quirk this change leaves as-is). constructor (dir: string, playwrightConfig: any, playwrightProject: any) { this.projectName = playwrightProject.name this.platform = 'linux' - this.testDir = playwrightProject.testDir ? toAbsolutePath(dir, playwrightProject.testDir) : playwrightConfig.testDir + // Project-specific paths are recorded on the parent config, which is where + // the bundler collects the spellings from. + this.testDir = playwrightProject.testDir + ? playwrightConfig.reference(dir, playwrightProject.testDir) + : playwrightConfig.testDir this.snapshotDir = playwrightProject.snapshotDir - ? toAbsolutePath(dir, playwrightProject.snapshotDir) + ? playwrightConfig.reference(dir, playwrightProject.snapshotDir) : (playwrightConfig.snapshotDir ?? this.testDir) this.snapshotTemplates = new Set() const testMatch = playwrightProject.testMatch ?? Array.from(playwrightConfig.testMatch) diff --git a/packages/cli/src/services/playwright-project-bundler.ts b/packages/cli/src/services/playwright-project-bundler.ts index 7d74edb8f..9f155fa39 100644 --- a/packages/cli/src/services/playwright-project-bundler.ts +++ b/packages/cli/src/services/playwright-project-bundler.ts @@ -125,6 +125,11 @@ export class PlaywrightProjectBundler { bundleRoot, ignoreCwd: dir, ignorePatterns: ignoredFiles, + // The config's own path references (testDir, globalSetup, ...) are + // discovered at their real paths, but the config file still spells them + // as written — through any symlink on the way. Those links must travel + // with the bundle or the spellings resolve to nothing on the runner. + referencedPaths: Array.from(pwConfigParsed.referencedPaths.keys()), })) } diff --git a/packages/cli/src/services/symlink-resolver.ts b/packages/cli/src/services/symlink-resolver.ts index 47445a643..ae0f3c580 100644 --- a/packages/cli/src/services/symlink-resolver.ts +++ b/packages/cli/src/services/symlink-resolver.ts @@ -35,6 +35,16 @@ export interface ResolveBundleFilesOptions { /** Directory the ignore patterns are relative to (the include glob's cwd). */ ignoreCwd: string ignorePatterns: string[] + /** + * Paths that files in the bundle refer to by spelling — e.g. a Playwright + * config's testDir or globalSetup, exactly as written. Content discovery + * resolves such paths through any symlinks and bundles the real files, but + * the reference itself still reads the original spelling at run time, so + * every symlink it traverses must exist in the archive for it to resolve. + * The links are carried as symlink entries only; what they point at is + * bundled by whoever discovered the content. + */ + referencedPaths?: string[] } /** @@ -61,7 +71,7 @@ export interface ResolveBundleFilesOptions { */ export async function resolveBundleFiles (options: ResolveBundleFilesOptions): Promise { const resolver = new SymlinkResolver(options) - return await resolver.resolve(options.matchedPaths) + return await resolver.resolve(options.matchedPaths, options.referencedPaths ?? []) } class SymlinkResolver { @@ -101,7 +111,7 @@ class SymlinkResolver { this.#roots = [options.bundleRoot] } - async resolve (matchedPaths: string[]): Promise { + async resolve (matchedPaths: string[], referencedPaths: string[]): Promise { const [bundleRoot] = this.#roots // The canonical root is what real paths are measured against; the lexical @@ -128,11 +138,88 @@ class SymlinkResolver { await this.#classify(matchedPath) } + for (const referencedPath of referencedPaths) { + await this.#carryReferencedLinks(referencedPath) + } + this.#pruneSymlinks() return Array.from(this.#entries.values()) } + /** + * Emits a symlink entry for every link a referenced path traverses, so the + * path resolves in the extracted archive exactly as spelled. Content is not + * this method's concern — whoever referenced the path also discovers and + * bundles what it points at (at real paths). Only the links travel here. + * + * The walk mirrors #classify's first-symlink rule: find the first symlinked + * component, emit it, jump into the target's real namespace, and continue — + * so every emitted link sits at a symlink-free archive path of its own and + * the extractability invariant holds by construction. + */ + async #carryReferencedLinks (referencedPath: string): Promise { + // Emissions are buffered until the whole walk succeeds. When a later hop + // leaves the bundle root, discovery has fallen back to bundling the content + // at the spelled path — real directories — and an already-emitted earlier + // link would then sit above those very directories, guaranteeing its own + // removal (and a spurious warning) at the bundler. + const chain: Array<[string, string]> = [] + let current = referencedPath + + for (;;) { + const symlink = await this.#firstSymlinkComponent(current) + if (symlink === undefined) { + break + } + + const archivePath = this.#archivePathOf(symlink) + if (archivePath === undefined || archivePath === '') { + // The "link" is the bundle root itself — the whole project is reached + // through a symlink, which the two-root reconciliation already absorbs. + // The root is not an entry; emitting one at the empty name would abort + // the archive. + return + } + + const target = await this.#realpath(symlink) + if (target === undefined) { + // Broken; the reference cannot resolve locally either. + this.#skipDanglingSymlink(symlink) + return + } + + if (this.#archivePathOf(target) === undefined) { + // The reference's content is outside the bundle root: discovery bundles + // it at the spelled path (or errors), so the spelled tree extracts as + // ordinary directories and no link entry is wanted anywhere along the + // spelling. + return + } + + chain.push([symlink, target]) + + if (current === symlink) { + break + } + + current = path.join(target, path.relative(symlink, current)) + } + + for (const [symlink, target] of chain) { + this.#emitSymlink(symlink, target, { referenced: true }) + + // The same link may already be in the archive because an include pattern + // matched it; being referenced is a property of the link, not of which + // pass got to it first. + const archivePath = this.#archivePathOf(symlink) + const existing = archivePath !== undefined ? this.#entries.get(archivePath) : undefined + if (existing !== undefined && existing.symlinkTarget !== undefined) { + existing.referencedLink = true + } + } + } + /** * Enforces, over the finished set of entries, the two things a symlink entry * must satisfy. Doing it here rather than at each emit is what makes it hold @@ -179,8 +266,12 @@ class SymlinkResolver { .some(other => other.startsWith(`${archivePath}/`)) const target = resolveArchivePath(archivePath, file.symlinkTarget) - // A link onto the archive root always resolves; the root is not an entry. - const resolves = target === '' || occupied.has(target) + // A link onto the archive root always resolves; the root is not an + // entry. A link carried for a referenced path resolves too: its target + // content is bundled by the parser, which this resolver cannot see. + const resolves = target === '' + || occupied.has(target) + || file.referencedLink === true if (hasChildren || !resolves) { debug(`Dropping symlink ${archivePath}: ${hasChildren ? 'has children' : 'target is not bundled'}`) @@ -575,17 +666,22 @@ class SymlinkResolver { ) } - #emitSymlink (symlink: string, target: string): void { + #emitSymlink (symlink: string, target: string, options: { referenced?: boolean } = {}): void { const archivePath = this.#archivePathOf(symlink) const targetArchivePath = this.#archivePathOf(target) if (archivePath === undefined || targetArchivePath === undefined) { return } - this.#emitSymlinkEntry(symlink, archivePath, targetArchivePath) + this.#emitSymlinkEntry(symlink, archivePath, targetArchivePath, options) } - #emitSymlinkEntry (symlink: string, archivePath: string, targetArchivePath: string): void { + #emitSymlinkEntry ( + symlink: string, + archivePath: string, + targetArchivePath: string, + options: { referenced?: boolean } = {}, + ): void { // The link target is computed between archive paths, not filesystem paths, // so it stays valid wherever the archive is extracted. Both are anchored to // '/' first: path.posix.relative() resolves bare relative paths against the @@ -604,6 +700,7 @@ class SymlinkResolver { physical: true, archivePath, symlinkTarget, + ...(options.referenced ? { referencedLink: true as const } : {}), }) } From a7b487b4de343d5d0b4f5bb5a4dd28cfcfe353fb Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Fri, 7 Aug 2026 13:28:50 +0900 Subject: [PATCH 4/7] fix(cli): bundle workspace-member links selectively; fail on out-of-project links [RED-713] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A package link whose target is a workspace member was expanded wholesale: matching it with an include pattern bundled the member's entire directory — sources, tests, its own node_modules links and everything those reach — where the CLI bundles every non-linked workspace dependency selectively (manifest, entry points, transitively imported files). Bundle the two the same way: the link travels, the member's manifest travels (it carries load-bearing metadata and keeps the link resolvable in the archive), files matched through the link travel at their real paths, and everything else is the import parser's business. The branch applies only where the parser genuinely compensates: the target must be the member directory itself, the link's node_modules name must equal the member's package name (the parser resolves by specifier, so an aliased dependency would otherwise ship as an empty package), and the link must have been matched by an include pattern — links reached through a store's dependency closure have no parser coverage and keep expansion. When selective treatment narrows a directly-named link, a warning says so and names the member's path. An include pattern naming a node_modules symlink that points outside the bundle root — a cache volume, a relocated virtual store, a globally linked package — previously had the target's contents silently flattened into the archive, producing bundles that only half-worked: a pnpm package's dependencies are its store siblings, which never came along. Fail with an actionable error instead, naming the bundle root and the ways out. The error is scoped to what it can honestly claim: only links the include patterns named directly (links the resolver reaches on its own keep the warn-and-copy fallback), and only node_modules shapes — an out-of-project file link or asset-directory link copies cleanly and continues to. Excluding a link via ignoreDirectoriesMatch counts as excluding its subtree even in the documented pattern spelling: a trailing globstar does not match the bare directory entry, so exclusion is probed with a synthetic child. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ckly.include-nested-node-modules.config.ts | 22 + .../__tests__/playwright-check.spec.ts | 46 +- .../__tests__/symlink-resolver.spec.ts | 470 +++++++++++++++--- .../services/playwright-project-bundler.ts | 23 +- packages/cli/src/services/symlink-resolver.ts | 465 +++++++++++------ 5 files changed, 792 insertions(+), 234 deletions(-) create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling/checkly.include-nested-node-modules.config.ts diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling/checkly.include-nested-node-modules.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling/checkly.include-nested-node-modules.config.ts new file mode 100644 index 000000000..5d2be64b2 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling/checkly.include-nested-node-modules.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'checkly' + +const config = defineConfig({ + projectName: 'Check Fixture', + logicalId: 'check-fixture', + checks: { + checkMatch: '**/*.check.ts', + ignoreDirectoriesMatch: [], + // The sub/node_modules directory is created by the test at run time (real + // directories, not the sandbox's out-of-project template link). + include: ['sub/node_modules/pkg/**'], + playwrightConfigPath: './playwright.config.ts', + playwrightChecks: [ + { + logicalId: 'playwright-check-suite', + name: 'Playwright Check Suite', + } + ], + }, +}) + +export default config diff --git a/packages/cli/src/constructs/__tests__/playwright-check.spec.ts b/packages/cli/src/constructs/__tests__/playwright-check.spec.ts index d5fe6f3e1..747721d9c 100644 --- a/packages/cli/src/constructs/__tests__/playwright-check.spec.ts +++ b/packages/cli/src/constructs/__tests__/playwright-check.spec.ts @@ -968,10 +968,23 @@ describe('PlaywrightCheck', () => { }, DEFAULT_TEST_TIMEOUT) it('should include explicit node_modules patterns bypassing default ignores', async () => { + // Built at run time as real directories: the sandbox's own top-level + // node_modules is a symlink to a shared template outside the sandbox, + // which include patterns are no longer allowed to reach through. + await fs.mkdir(path.join(fixt.root, 'sub', 'node_modules', 'pkg'), { recursive: true }) + await fs.writeFile( + path.join(fixt.root, 'sub', 'node_modules', 'pkg', 'package.json'), + '{"name":"pkg","version":"1.0.0"}', + ) + await fs.writeFile( + path.join(fixt.root, 'sub', 'node_modules', 'pkg', 'index.js'), + 'module.exports = {}', + ) + const output = await parseProject( fixt, '--config', - 'checkly.include-node-modules-if-explicit.config.ts', + 'checkly.include-nested-node-modules.config.ts', ) expect(output).toEqual(expect.objectContaining({ @@ -999,21 +1012,40 @@ describe('PlaywrightCheck', () => { const entries = await listTarEntries(codeBundlePath) const files = entries.map(entry => entry.path) + // The include pattern names a node_modules path, which switches off the + // default **/node_modules/** ignore — the whole point of this test. expect(files.sort()).toEqual(expect.arrayContaining([ - 'node_modules/checkly/package.json', + 'sub/node_modules/pkg/index.js', + 'sub/node_modules/pkg/package.json', 'package.json', 'playwright.config.ts', 'pnpm-lock.yaml', 'tests/example.spec.ts', ])) - - // The package list stays permissive because the checkly package's own - // contents change, but the archive still has to be extractable — and - // `node_modules/checkly` is a pnpm symlink, so this is exactly where a - // symlink entry used to appear with files nested beneath it. expectNoSymlinkHasChildren(entries) }, DEFAULT_TEST_TIMEOUT) + it('should fail with a clear error when include reaches through a link pointing outside the project', async () => { + // The sandbox's top-level node_modules is a symlink to a shared template + // outside the sandbox — the same shape as a node_modules symlinked to a + // cache volume. Previously the target's contents were silently flattened + // into the archive, producing bundles that only half-worked; now it is a + // fatal diagnostic (which fails `deploy` and `test`). + const output = await parseProject( + fixt, + '--config', + 'checkly.include-node-modules-if-explicit.config.ts', + ) + + expect(output).toEqual(expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('outside the project\'s bundle root'), + }), + ]), + })) + }, DEFAULT_TEST_TIMEOUT) + it('should still respect custom ignoreDirectoriesMatch for explicit patterns', async () => { const output = await parseProject( fixt, diff --git a/packages/cli/src/services/__tests__/symlink-resolver.spec.ts b/packages/cli/src/services/__tests__/symlink-resolver.spec.ts index dc7d4cc18..a959fc772 100644 --- a/packages/cli/src/services/__tests__/symlink-resolver.spec.ts +++ b/packages/cli/src/services/__tests__/symlink-resolver.spec.ts @@ -287,7 +287,298 @@ describe('resolveBundleFiles', () => { }) }) - it('should keep a workspace link and bundle the package it points at', async () => { + describe('workspace member links', () => { + // The customer shape: a workspace package's node_modules holds links + // straight to sibling member directories, and the member's content reaches + // the bundle through the import parser rather than through expansion. + const workspace: TreeSpec = { + 'packages/x/package.json': '{"name":"@scope/x"}', + 'packages/x/src/index.ts': 'export const x = 1', + 'packages/x/tests/a.spec.ts': 'test', + 'packages/x/node_modules/.keep': '', + 'packages/c/node_modules/@scope/x': link('../../../x'), + 'packages/c/package.json': '{"name":"@scope/c"}', + 'package.json': '{}', + } + const members = (root: string) => [ + { path: root, name: 'workspace-root' }, + { path: path.join(root, 'packages', 'c'), name: '@scope/c' }, + { path: path.join(root, 'packages', 'x'), name: '@scope/x' }, + ] + + async function bundleWorkspace (root: string, patterns: string[], extra: BundleOptions = {}) { + const { ignore = [], cwd = root } = extra + const matchedPaths = await findFilesWithPattern(cwd, patterns, ignore) + const files = await resolveBundleFiles({ + matchedPaths, + bundleRoot: root, + ignoreCwd: cwd, + ignorePatterns: ignore, + workspaceMembers: members(root), + }) + expectNoSymlinkHasChildren(files) + return files + } + + it('should keep the link and the manifest, without expanding the member', async () => { + const root = await makeSandbox(workspace) + + const files = await bundleWorkspace(root, ['node_modules/**'], { + cwd: path.join(root, 'packages', 'c'), + }) + + // The link and the member's real package.json travel; the member's other + // files and its node_modules do not — they are the parser's business. + // The manifest is also what keeps the link past the prune: it occupies + // the link's target. + expect(entries(files)).toEqual([ + 'packages/c/node_modules/@scope/x -> ../../../x', + 'packages/x/package.json', + ]) + }) + + it('should keep expansion for a member link the resolver reached on its own', async () => { + // A pnpm store package can depend on a workspace member, giving the store + // a member link no include pattern ever matched. The parser never reads + // store-internal code, so nothing would supply the member's sources — + // such links keep whole-target expansion. + const root = await makeSandbox({ + 'packages/x/package.json': '{"name":"@scope/x"}', + 'packages/x/src/index.js': 'x', + 'node_modules/.pnpm/foo@1.0.0/node_modules/foo/index.js': 'foo', + 'node_modules/.pnpm/foo@1.0.0/node_modules/@scope/x': link('../../../../../packages/x'), + 'node_modules/foo': link('.pnpm/foo@1.0.0/node_modules/foo'), + 'package.json': '{}', + }) + + const files = await resolveBundleFiles({ + matchedPaths: [path.join(root, 'node_modules', 'foo')], + bundleRoot: root, + ignoreCwd: root, + ignorePatterns: [], + workspaceMembers: [ + { path: root, name: 'workspace-root' }, + { path: path.join(root, 'packages', 'x'), name: '@scope/x' }, + ], + }) + expectNoSymlinkHasChildren(files) + + // The member link arrived via the store's dependency closure, not via an + // include pattern — its target is fully expanded. + expect(entries(files)).toEqual(expect.arrayContaining([ + 'node_modules/.pnpm/foo@1.0.0/node_modules/@scope/x -> ../../../../../packages/x', + 'packages/x/package.json', + 'packages/x/src/index.js', + ])) + }) + + it('should bundle files matched through the member link at their real paths', async () => { + const root = await makeSandbox(workspace) + + const files = await bundleWorkspace(root, ['node_modules/@scope/x/tests/**'], { + cwd: path.join(root, 'packages', 'c'), + }) + + expect(entries(files)).toEqual([ + 'packages/c/node_modules/@scope/x -> ../../../x', + 'packages/x/package.json', + 'packages/x/tests/a.spec.ts', + ]) + }) + + it('should give a member-local pnpm store the store treatment, not the member treatment', async () => { + // A store can live inside a member directory; its packages need expansion + // and the sibling closure no matter where the store sits. + const root = await makeSandbox({ + 'packages/c/node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/index.js': 'pkg', + 'packages/c/node_modules/.pnpm/pkg@1.0.0/node_modules/dep': link('../../dep@2.0.0/node_modules/dep'), + 'packages/c/node_modules/.pnpm/dep@2.0.0/node_modules/dep/index.js': 'dep', + 'packages/c/node_modules/pkg': link('.pnpm/pkg@1.0.0/node_modules/pkg'), + 'packages/c/package.json': '{"name":"@scope/c"}', + 'package.json': '{}', + }) + + const files = await resolveBundleFiles({ + matchedPaths: [path.join(root, 'packages', 'c', 'node_modules', 'pkg')], + bundleRoot: root, + ignoreCwd: path.join(root, 'packages', 'c'), + ignorePatterns: [], + workspaceMembers: [ + { path: root, name: 'workspace-root' }, + { path: path.join(root, 'packages', 'c'), name: '@scope/c' }, + ], + }) + expectNoSymlinkHasChildren(files) + + expect(entries(files)).toEqual([ + 'packages/c/node_modules/.pnpm/dep@2.0.0/node_modules/dep/index.js', + 'packages/c/node_modules/.pnpm/pkg@1.0.0/node_modules/dep -> ../../dep@2.0.0/node_modules/dep', + 'packages/c/node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/index.js', + 'packages/c/node_modules/pkg -> .pnpm/pkg@1.0.0/node_modules/pkg', + ]) + }) + + it('should keep expansion for a link into a member subdirectory', async () => { + // A link into a member's subdirectory (`link:./packages/y/dist`) names + // content the import parser will never bundle — selective treatment would + // ship a link to nothing. Such links keep whole-target expansion, which + // is bounded to the subdirectory. + const root = await makeSandbox({ + 'packages/y/package.json': '{"name":"@scope/y"}', + 'packages/y/dist/index.js': 'y', + 'packages/y/src/ignored-by-narrow-target.ts': 'src', + 'packages/c/node_modules/@scope/y-dist': link('../../../y/dist'), + 'packages/c/package.json': '{"name":"@scope/c"}', + 'package.json': '{}', + }) + + const files = await resolveBundleFiles({ + matchedPaths: [path.join(root, 'packages', 'c', 'node_modules', '@scope', 'y-dist')], + bundleRoot: root, + ignoreCwd: path.join(root, 'packages', 'c'), + ignorePatterns: [], + workspaceMembers: [ + { path: root, name: 'workspace-root' }, + { path: path.join(root, 'packages', 'c'), name: '@scope/c' }, + { path: path.join(root, 'packages', 'y'), name: '@scope/y' }, + ], + }) + expectNoSymlinkHasChildren(files) + + expect(entries(files)).toEqual([ + 'packages/c/node_modules/@scope/y-dist -> ../../../y/dist', + 'packages/y/dist/index.js', + ]) + }) + + it('should keep expansion for an aliased member dependency', async () => { + // `"ui": "file:../ui"` where the package is named @scope/ui: the parser + // resolves imports by specifier, so `import 'ui'` never reaches the + // member — selective treatment would ship an empty package. The name + // mismatch routes the link back to expansion. + const root = await makeSandbox({ + 'packages/ui/package.json': '{"name":"@scope/ui"}', + 'packages/ui/src/index.js': 'ui', + 'packages/c/node_modules/ui': link('../../ui'), + 'packages/c/package.json': '{"name":"@scope/c"}', + 'package.json': '{}', + }) + + const files = await resolveBundleFiles({ + matchedPaths: [path.join(root, 'packages', 'c', 'node_modules', 'ui')], + bundleRoot: root, + ignoreCwd: path.join(root, 'packages', 'c'), + ignorePatterns: [], + workspaceMembers: [ + { path: root, name: 'workspace-root' }, + { path: path.join(root, 'packages', 'c'), name: '@scope/c' }, + { path: path.join(root, 'packages', 'ui'), name: '@scope/ui' }, + ], + }) + expectNoSymlinkHasChildren(files) + + expect(entries(files)).toEqual([ + 'packages/c/node_modules/ui -> ../../ui', + 'packages/ui/package.json', + 'packages/ui/src/index.js', + ]) + }) + + it('should recognize members given in a lexical spelling', async () => { + // Workspace member paths can be lexical (npm/yarn workspaces record the + // directory the package.json was found at), while link targets arrive as + // realpaths. Registration canonicalizes, or every member would be missed + // and the branch would silently revert to expansion. + const outer = await makeSandbox({ + 'real/packages/x/package.json': '{"name":"@scope/x"}', + 'real/packages/x/src/index.ts': 'x', + 'real/packages/c/node_modules/@scope/x': link('../../../x'), + 'real/packages/c/package.json': '{"name":"@scope/c"}', + 'real/package.json': '{}', + 'alias': link('real'), + }) + const lexicalRoot = path.join(outer, 'alias') + + const files = await resolveBundleFiles({ + matchedPaths: [path.join(outer, 'real', 'packages', 'c', 'node_modules', '@scope', 'x')], + bundleRoot: lexicalRoot, + ignoreCwd: path.join(lexicalRoot, 'packages', 'c'), + ignorePatterns: [], + workspaceMembers: [ + { path: lexicalRoot, name: 'workspace-root' }, + { path: path.join(lexicalRoot, 'packages', 'c'), name: '@scope/c' }, + { path: path.join(lexicalRoot, 'packages', 'x'), name: '@scope/x' }, + ], + }) + expectNoSymlinkHasChildren(files) + + // Member branch, not expansion: no src/index.ts sweep. + expect(entries(files)).toEqual([ + 'packages/c/node_modules/@scope/x -> ../../../x', + 'packages/x/package.json', + ]) + }) + + it('should keep expansion for a plain directory link to a member target', async () => { + // The member branch is for package links: assets are not imports and the + // parser cannot compensate for them, so a plain directory link keeps + // today's whole-target expansion. + const root = await makeSandbox({ + 'packages/data/package.json': '{"name":"@scope/data"}', + 'packages/data/mock.json': '{}', + 'packages/c/fixtures': link('../data'), + 'packages/c/package.json': '{"name":"@scope/c"}', + 'package.json': '{}', + }) + + const files = await resolveBundleFiles({ + matchedPaths: [path.join(root, 'packages', 'c', 'fixtures')], + bundleRoot: root, + ignoreCwd: path.join(root, 'packages', 'c'), + ignorePatterns: [], + workspaceMembers: [ + { path: root, name: 'workspace-root' }, + { path: path.join(root, 'packages', 'c'), name: '@scope/c' }, + { path: path.join(root, 'packages', 'data'), name: '@scope/data' }, + ], + }) + expectNoSymlinkHasChildren(files) + + expect(entries(files)).toEqual([ + 'packages/c/fixtures -> ../data', + 'packages/data/mock.json', + 'packages/data/package.json', + ]) + }) + + it('should treat a self-dependency link to the root as a member link', async () => { + // pnpm creates node_modules/ -> .. for a `file:.` dependency; the + // root is a member, so the link travels with the root manifest and + // nothing gets expanded. + const root = await makeSandbox({ + 'src/index.ts': 'code', + 'private-notes.txt': 'secret', + 'node_modules/app': link('..'), + 'package.json': '{"name":"app"}', + }) + + const files = await resolveBundleFiles({ + matchedPaths: [path.join(root, 'node_modules', 'app')], + bundleRoot: root, + ignoreCwd: root, + ignorePatterns: [], + workspaceMembers: [{ path: root, name: 'app' }], + }) + expectNoSymlinkHasChildren(files) + + expect(entries(files)).toEqual([ + 'node_modules/app -> ..', + 'package.json', + ]) + }) + }) + + it('should keep a workspace-shaped link and bundle the package it points at when no members are known', async () => { const root = await makeSandbox({ 'packages/shared-lib/src/index.ts': 'export const x = 1', 'packages/shared-lib/package.json': '{"name":"@scope/shared-lib"}', @@ -363,67 +654,154 @@ describe('resolveBundleFiles', () => { }) describe('targets outside the archive root', () => { - it('should bundle the contents of an out-of-root link, without a symlink entry', async () => { + it('should copy an out-of-root file link at its spelled path', async () => { + // A plain file link (a shared .env, a linked config) has no pnpm-store + // failure mode: the bytes at the spelled path are a complete bundle. const outer = await makeSandbox({ - 'external/pkg/index.js': 'pkg', - 'project/node_modules/pkg': link('../../external/pkg'), + 'shared/config.json': '{"shared":true}', + 'project/package.json': '{}', + }) + const root = path.join(outer, 'project') + await fs.symlink(path.join('..', 'shared', 'config.json'), path.join(root, 'config.json')) + + const files = await bundle(root, ['*'], { bundleRoot: root }) + + expect(entries(files)).toEqual([ + 'config.json', + 'package.json', + ]) + }) + + it('should copy an out-of-root asset directory link at its spelled path', async () => { + const outer = await makeSandbox({ + 'shared-fixtures/data.json': '{}', + 'shared-fixtures/nested/more.json': '{}', + 'project/package.json': '{}', + }) + const root = path.join(outer, 'project') + await fs.symlink(path.join('..', 'shared-fixtures'), path.join(root, 'fixtures')) + + const files = await bundle(root, ['fixtures', 'package.json'], { bundleRoot: root }) + + expect(entries(files)).toEqual([ + 'fixtures/data.json', + 'fixtures/nested/more.json', + 'package.json', + ]) + }) + + it('should copy the contents of a directory link nested inside an out-of-root tree', async () => { + // glob reports the nested link as a file (`fixtures/*` matches it without + // matching the top link), and its contents must still travel — at its + // archive path, as plain files. + const outer = await makeSandbox({ + 'shared-fixtures/data.json': '{}', + 'vendored/lib.js': 'lib', 'project/package.json': '{}', }) const root = path.join(outer, 'project') + await fs.symlink(path.join('..', 'vendored'), path.join(outer, 'shared-fixtures', 'vendor')) + await fs.symlink(path.join('..', 'shared-fixtures'), path.join(root, 'fixtures')) - const files = await bundle(root, ['node_modules/pkg/**', 'package.json'], { bundleRoot: root }) + const files = await bundle(root, ['fixtures/*', 'package.json'], { bundleRoot: root }) - // The target cannot be named inside the archive, so a symlink entry would - // dangle after extraction. Copy the bytes to where the link sits instead. expect(entries(files)).toEqual([ - 'node_modules/pkg/index.js', + 'fixtures/data.json', + 'fixtures/vendor/lib.js', 'package.json', ]) - expect(files.filter(file => file.symlinkTarget !== undefined)).toEqual([]) }) - it('should not leave a symlink with entries beneath it when two links share a target', async () => { - // Two links to one directory, and a pattern that matches files through - // both. glob traverses a symlinked directory for `**/*` (it does not for a - // bare `**`), so files arrive under both link paths — and if the second - // link is archived as a symlink to the first, those files sit beneath a - // symlink entry. That is exactly the archive tar cannot extract. + it('should copy an out-of-root fan-out of directory links in linear time', async () => { + // Each level links twice to the next, so the number of routes is + // exponential in the depth while the number of directories is not. + // Copying per route would take minutes; copying per directory, with later + // routes becoming links to the first copy, stays instant. + const spec: TreeSpec = { 'project/package.json': '{}' } + const depth = 12 + for (let i = 0; i < depth; i++) { + spec[`external/l${i}/file.js`] = `l${i}` + } + const outer = await makeSandbox(spec) + for (let i = 0; i + 1 < depth; i++) { + await fs.symlink(path.join('..', `l${i + 1}`), path.join(outer, 'external', `l${i}`, 'a')) + await fs.symlink(path.join('..', `l${i + 1}`), path.join(outer, 'external', `l${i}`, 'b')) + } + const root = path.join(outer, 'project') + await fs.symlink(path.join('..', 'external', 'l0'), path.join(root, 'assets')) + + const files = await bundle(root, ['assets'], { bundleRoot: root }) + + // Each level's file appears once at the first route that reached it; the + // result stays proportional to the number of directories. + expect(files.length).toBeLessThan(depth * 4) + expect(entries(files)).toContain('assets/file.js') + }, 20_000) + + it('should error on a matched link whose target is outside the bundle root', async () => { + // The old behaviour silently flattened the target's contents into the + // archive — a bundle that only half-worked, since a pnpm package's + // dependencies are its store siblings and never came along. Failing + // loudly is the deliberate replacement. const outer = await makeSandbox({ 'external/pkg/index.js': 'pkg', - 'external/other/o.js': 'other', - 'external/pkg/aliasA': link('../other'), - 'external/pkg/aliasB': link('../other'), 'project/node_modules/pkg': link('../../external/pkg'), 'project/package.json': '{}', }) const root = path.join(outer, 'project') - const files = await bundle(root, ['node_modules/pkg/**/*'], { bundleRoot: root }) - - expectNoSymlinkHasChildren(files) - expect(entries(files)).toEqual(expect.arrayContaining([ - 'node_modules/pkg/aliasA/o.js', - 'node_modules/pkg/index.js', - ])) + await expect(bundle(root, ['node_modules/pkg/**', 'package.json'], { bundleRoot: root })) + .rejects.toThrow(/outside the project's bundle root/) }) - it('should dereference symlinks nested inside an out-of-root tree', async () => { + it('should skip an out-of-root link the ignore patterns exclude, instead of erroring', async () => { + // The escape hatch the error message names: excluding the link via + // ignoreDirectoriesMatch acknowledges it should not be bundled. Matched + // paths are passed directly here because the include glob's own ignore + // handling runs in a different namespace (the config directory) and can + // therefore miss patterns that do match the link's bundle-root-relative + // path — the resolver's check is the backstop. const outer = await makeSandbox({ 'external/pkg/index.js': 'pkg', - 'external/pkg/vendor': link('../vendored'), - 'external/vendored/lib.js': 'lib', 'project/node_modules/pkg': link('../../external/pkg'), 'project/package.json': '{}', }) const root = path.join(outer, 'project') - const files = await bundle(root, ['node_modules/pkg/**'], { bundleRoot: root }) + const files = await resolveBundleFiles({ + matchedPaths: [path.join(root, 'node_modules', 'pkg'), path.join(root, 'package.json')], + bundleRoot: root, + ignoreCwd: path.join(root, 'apps'), + ignorePatterns: ['node_modules/**'], + }) + + expect(entries(files)).toEqual([ + 'package.json', + ]) + }) + + it('should honor a directory-shaped ignore pattern for the whole node_modules link', async () => { + // The spelling the CLI's own docs teach: `**/node_modules/**` matches the + // contents but not the bare `node_modules` entry itself. Excluding the + // subtree must still count as excluding the link, or the escape hatch the + // error message advertises is a dead end. + const outer = await makeSandbox({ + 'cache/node_modules/pkg/index.js': 'pkg', + 'project/package.json': '{}', + }) + const root = path.join(outer, 'project') + await fs.symlink(path.join('..', 'cache', 'node_modules'), path.join(root, 'node_modules')) + + const files = await resolveBundleFiles({ + matchedPaths: [path.join(root, 'node_modules'), path.join(root, 'package.json')], + bundleRoot: root, + ignoreCwd: path.join(root, 'apps'), + ignorePatterns: ['**/node_modules/**'], + }) expect(entries(files)).toEqual([ - 'node_modules/pkg/index.js', - 'node_modules/pkg/vendor/lib.js', + 'package.json', ]) - expect(files.filter(file => file.symlinkTarget !== undefined)).toEqual([]) }) }) @@ -792,34 +1170,6 @@ describe('resolveBundleFiles', () => { ]) }) - it('should copy an out-of-root directory once, however many links reach it', async () => { - // Each level fans out to the next by two links, so the number of distinct - // routes through the tree is exponential in its depth while the number of - // directories is not. - const spec: TreeSpec = { 'project/package.json': '{}' } - const depth = 12 - for (let i = 0; i < depth; i++) { - spec[`external/l${i}/file.js`] = `l${i}` - if (i + 1 < depth) { - spec[`external/l${i}/a`] = link(`../l${i + 1}`) - spec[`external/l${i}/b`] = link(`../l${i + 1}`) - } - } - spec['project/node_modules/pkg'] = link('../../external/l0') - const outer = await makeSandbox(spec) - const root = path.join(outer, 'project') - - const files = await bundle(root, ['node_modules/pkg/**'], { bundleRoot: root }) - - // Each level's file is copied exactly once; the second route to a directory - // becomes a link to the first copy rather than another copy of it. - for (let i = 0; i < depth; i++) { - const copies = entries(files).filter(entry => entry.endsWith(`/file.js`) && entry.includes(`l${i}`) === false) - expect(copies.length).toBeLessThanOrEqual(depth) - } - expect(files.length).toBeLessThan(depth * 4) - }, 20_000) - it('should refuse to bundle pnpm state files even when named outright', async () => { const root = await makeSandbox({ 'node_modules/.modules.yaml': 'storeDir: /elsewhere', diff --git a/packages/cli/src/services/playwright-project-bundler.ts b/packages/cli/src/services/playwright-project-bundler.ts index 9f155fa39..16fb1a74b 100644 --- a/packages/cli/src/services/playwright-project-bundler.ts +++ b/packages/cli/src/services/playwright-project-bundler.ts @@ -15,16 +15,24 @@ import { findFilesWithPattern, pathToPosix } from './util.js' import { Session } from '../constructs/session.js' /** - * The directory archive paths are relative to. Must match the bundler's strip - * prefix (see Bundler.createForWorkspace), or archive paths won't line up. + * The directory archive paths are relative to — the workspace root, which is + * also the bundler's strip prefix (see Bundler.createForWorkspace) — together + * with the workspace's member directories. */ -function bundleRootPath (): string | undefined { +function workspaceBundleInfo (): { + bundleRoot: string + members: Array<{ path: string, name: string }> +} | undefined { const workspace = Session.workspace if (!workspace.isOk()) { return undefined } - return workspace.unwrap().root.path + const { root, packages } = workspace.unwrap() + return { + bundleRoot: root.path, + members: [root, ...packages].map(({ path, name }) => ({ path, name })), + } } export interface PlaywrightProjectBundle { @@ -111,8 +119,8 @@ export class PlaywrightProjectBundler { // Included paths may run through symlinks — under pnpm every package in // node_modules is one. Left alone they produce an archive that tar cannot // extract, so resolve them into entries that can be. - const bundleRoot = bundleRootPath() - if (bundleRoot === undefined) { + const workspaceInfo = workspaceBundleInfo() + if (workspaceInfo === undefined) { for (const filePath of includedFiles) { files.push({ filePath, @@ -122,7 +130,7 @@ export class PlaywrightProjectBundler { } else { files.push(...await resolveBundleFiles({ matchedPaths: includedFiles, - bundleRoot, + bundleRoot: workspaceInfo.bundleRoot, ignoreCwd: dir, ignorePatterns: ignoredFiles, // The config's own path references (testDir, globalSetup, ...) are @@ -130,6 +138,7 @@ export class PlaywrightProjectBundler { // as written — through any symlink on the way. Those links must travel // with the bundle or the spellings resolve to nothing on the runner. referencedPaths: Array.from(pwConfigParsed.referencedPaths.keys()), + workspaceMembers: workspaceInfo.members, })) } diff --git a/packages/cli/src/services/symlink-resolver.ts b/packages/cli/src/services/symlink-resolver.ts index ae0f3c580..704f955c4 100644 --- a/packages/cli/src/services/symlink-resolver.ts +++ b/packages/cli/src/services/symlink-resolver.ts @@ -45,6 +45,16 @@ export interface ResolveBundleFilesOptions { * bundled by whoever discovered the content. */ referencedPaths?: string[] + /** + * The workspace's member packages, in any order. A package link whose target + * is a member directory — and whose node_modules name matches the member's + * package name, so the import parser can resolve it — gets selective + * treatment: link + manifest + whatever was matched through it, instead of + * whole-directory expansion, mirroring how non-linked workspace dependencies + * are bundled. Empty or absent means no workspace: every in-root link target + * keeps the expansion behaviour. + */ + workspaceMembers?: Array<{ path: string, name: string }> } /** @@ -71,7 +81,11 @@ export interface ResolveBundleFilesOptions { */ export async function resolveBundleFiles (options: ResolveBundleFilesOptions): Promise { const resolver = new SymlinkResolver(options) - return await resolver.resolve(options.matchedPaths, options.referencedPaths ?? []) + return await resolver.resolve( + options.matchedPaths, + options.referencedPaths ?? [], + options.workspaceMembers ?? [], + ) } class SymlinkResolver { @@ -100,8 +114,10 @@ class SymlinkResolver { * even an acyclic graph would be walked once per distinct path through it. */ #closed = new Set() + /** Workspace member package names, keyed by canonical member directory. */ + #memberNames = new Map() /** Out-of-root directories already copied, and where each one landed. */ - #dereferenced = new Map() + #copiedTrees = new Map() #lstatCache = new Map() #warned = new Set() @@ -111,7 +127,11 @@ class SymlinkResolver { this.#roots = [options.bundleRoot] } - async resolve (matchedPaths: string[], referencedPaths: string[]): Promise { + async resolve ( + matchedPaths: string[], + referencedPaths: string[], + workspaceMembers: Array<{ path: string, name: string }>, + ): Promise { const [bundleRoot] = this.#roots // The canonical root is what real paths are measured against; the lexical @@ -126,6 +146,13 @@ class SymlinkResolver { // Root does not exist; nothing can be inside it anyway. } + // Member paths are stored canonically: the workspace model records them as + // given (sometimes lexical), while the link targets they are compared with + // arrive here as realpaths. + for (const member of workspaceMembers) { + this.#memberNames.set(await this.#realpath(member.path) ?? member.path, member.name) + } + // Which paths the include globs matched is a property of the path, not of // when it happens to be reached: expansion can arrive at a directly-matched // file first, and it must not then be judged by rules the glob already @@ -207,16 +234,11 @@ class SymlinkResolver { } for (const [symlink, target] of chain) { - this.#emitSymlink(symlink, target, { referenced: true }) - - // The same link may already be in the archive because an include pattern - // matched it; being referenced is a property of the link, not of which - // pass got to it first. - const archivePath = this.#archivePathOf(symlink) - const existing = archivePath !== undefined ? this.#entries.get(archivePath) : undefined - if (existing !== undefined && existing.symlinkTarget !== undefined) { - existing.referencedLink = true - } + this.#emitSymlink(symlink, target) + // Marked separately: the same link may already be in the archive because + // an include pattern matched it, and being referenced is a property of + // the link, not of which pass got to it first. + this.#markLinkReferenced(symlink) } } @@ -372,12 +394,50 @@ class SymlinkResolver { const targetArchivePath = this.#archivePathOf(target) if (targetArchivePath === undefined) { - // The target lives outside the archive root (a globally linked package, a - // dependency outside the repository, and — in our own test sandbox — a - // node_modules directory linked to a shared template). It has no - // expressible path in the archive, so a symlink entry would dangle after - // extraction. Copy the bytes across instead. - await this.#dereference(symlink, matchedPath) + const symlinkArchivePath = this.#archivePathOf(symlink) + if (symlinkArchivePath !== undefined && this.#isIgnoredArchivePathOrContents(symlinkArchivePath)) { + // The escape hatch: the user excluded the link itself. + this.#warnOnce( + symlink, + `${symlink} is excluded from the bundle by the ignore patterns. Skipping the symlink.`, + ) + return + } + + // For node_modules shapes the user matched outright — a package link, or + // a node_modules directory itself linked elsewhere (a cache volume, a + // relocated virtual store) — the old behaviour of silently flattening the + // target's contents produced bundles that only half-worked: a pnpm + // package's dependencies are its store siblings, which never came along. + // Fail loudly instead. The error is reserved for what the include + // patterns named directly: a link this resolver reached on its own (a + // store sibling pointing out of the project, say) must not turn a + // previously-bundling project into a hard failure. + const isNodeModulesShape = isInsideNodeModules(symlink) || path.basename(symlink) === NODE_MODULES + if (isNodeModulesShape && this.#directPaths.has(matchedPath)) { + throw new Error( + `${symlink} points at ${target}, which is outside the project's bundle root ` + + `(${this.#roots[0]}). Files outside it cannot be included in the code bundle. The ` + + `bundle root is your workspace root, or the nearest package.json directory when the ` + + `project is not part of a workspace — if the target belongs to your monorepo, make ` + + `sure the package containing your Checkly config is listed in the workspace ` + + `configuration. Otherwise, move the target inside the project, exclude the symlink ` + + `via ignoreDirectoriesMatch, or narrow your include patterns.`, + ) + } + + // A plain file or asset-directory link has no such failure mode: copying + // the bytes to the spelled path produces a complete, working bundle, as + // it always has. For node_modules shapes reached indirectly the copy is + // the best available fallback — say what it cannot deliver. + this.#warnOnce( + symlink, + isNodeModulesShape + ? `${symlink} is linked from outside the project. Its contents will be bundled, but its ` + + `dependencies cannot be, so it may fail to resolve them when the check runs.` + : `${symlink} points outside the project. Bundling its contents instead of the symlink.`, + ) + await this.#copyOutOfRootLink(matchedPath) return } @@ -408,6 +468,61 @@ class SymlinkResolver { const isPackageLink = isInsideNodeModules(symlink) + // A workspace member reached through a package link is handled selectively, + // the way the CLI treats every other workspace dependency: the link travels, + // the member's manifest travels, whatever the include patterns matched + // through the link travels — and the rest of the member's content is the + // import parser's business, not a wholesale directory copy. + // + // Three qualifiers, each load-bearing: + // - The store-shape check comes first: a pnpm store can live inside a member + // directory, and store packages need the expansion and sibling-closure + // treatment no matter where the store sits. + // - The target must be the member directory itself. A link into a member's + // subdirectory (`link:./packages/x/dist`) names content the parser will + // never bundle, so it keeps expansion. + // - The link's node_modules name must equal the member's package name. The + // parser resolves workspace dependencies by import specifier, so an + // aliased dependency (`"ui": "file:../ui"` for a package named @scope/ui) + // is invisible to it — selective treatment would ship an empty package. + // The member branch is reserved for links the include patterns matched + // (directly, or by matching files through them): those express user intent + // the parser complements. A member link this resolver reached on its own — + // a pnpm store package depending on a workspace member — has no parser + // coverage at all (the parser never reads store-internal code), so it keeps + // whole-target expansion below. + if ( + isPackageLink + && !this.#isPnpmStoreLocation(target) + && this.#memberNames.has(target) + && this.#directPaths.has(matchedPath) + ) { + if (this.#linkName(symlink) === this.#memberNames.get(target)) { + // The emitted manifest is also what keeps the link alive: it occupies + // the target, so the prune pass sees the link as resolvable. If the + // manifest cannot be emitted, the prune drops the link rather than + // shipping it dangling. + await this.#emitMemberPackageJson(target) + + if (matchedPath === symlink) { + // The include pattern named this link outright, but a workspace + // member travels selectively — a silent narrowing worth surfacing, + // since include exists for assets the import parser cannot see. + this.#warnOnce( + `${symlink}\0member`, + `${symlink} resolves to the workspace package at ${target}. Only its manifest and ` + + `files reached through imports or matching include patterns are bundled. To bundle ` + + `other files from it, add include patterns for its own path.`, + ) + } + + if (matchedPath !== symlink) { + await this.#classify(path.join(target, path.relative(symlink, matchedPath))) + } + return + } + } + // Expanding the target subtree is what puts the package's own files in the // archive. Do it when the pattern matched the link itself, and for package // links whatever the pattern's shape — `node_modules/pkg/**/*` matches only @@ -444,6 +559,143 @@ class SymlinkResolver { // every path has been classified. #pruneSymlinks decides that at the end. } + /** + * The name the link resolves as at run time — its path under the enclosing + * node_modules directory. When this equals the target package's declared + * name, the import parser can resolve the package; that equality is the + * member branch's precondition. + */ + #linkName (symlink: string): string | undefined { + const nodeModules = enclosingNodeModules(symlink) + if (nodeModules === undefined) { + return undefined + } + + return pathToPosix(path.relative(nodeModules, symlink)) + } + + /** + * Copies an out-of-root link's content to the archive at the spelled path, + * where it extracts as ordinary files. Nested directory links recurse + * (anything they point at is out of root as well); the ancestor set cuts + * cycles. + */ + async #copyOutOfRootLink (matchedPath: string): Promise { + const archivePath = this.#archivePathOf(matchedPath) + if (archivePath === undefined) { + return + } + + const stats = await this.#statThroughLink(matchedPath) + if (stats === undefined) { + // Dangling somewhere along the way; nothing to copy. + return + } + + if (!stats.isDirectory()) { + // The bytes are readable straight through the link at the matched path, + // which is exactly where they belong in the archive. + this.#emitFile(matchedPath, archivePath) + return + } + + // The matched path itself may be a nested directory link inside the + // out-of-root tree (glob reports such links as files); its contents belong + // at its archive path just like the top link's do. + await this.#copyTree(matchedPath, archivePath, new Set()) + } + + async #copyTree (directory: string, archiveDirectory: string, ancestors: Set): Promise { + const real = await this.#realpath(directory) + if (real === undefined || ancestors.has(real)) { + return + } + + // A directory reachable by more than one route is copied once; every later + // route becomes a link to the first copy. Re-copying per route would take + // time exponential in the depth of a link fan-out. If files also arrive + // beneath a later route, that link gains children and the prune pass drops + // it in favour of them. + const copied = this.#copiedTrees.get(real) + if (copied !== undefined) { + if (copied !== archiveDirectory) { + this.#emitSymlinkEntry(directory, archiveDirectory, copied) + } + return + } + this.#copiedTrees.set(real, archiveDirectory) + + const visited = new Set(ancestors).add(real) + + for (const entry of await this.#enumerate(real)) { + const archivePath = path.posix.join(archiveDirectory, pathToPosix(path.relative(real, entry))) + + if (this.#isIgnoredArchivePath(archivePath)) { + continue + } + + const stats = await this.#lstat(entry) + if (!stats?.isSymbolicLink()) { + this.#emitFile(entry, archivePath) + continue + } + + const linkStats = await this.#statThroughLink(entry) + if (linkStats === undefined) { + // Dangling. A link to nothing is worth nothing on the runner. + continue + } + + if (linkStats.isDirectory()) { + await this.#copyTree(entry, archivePath, visited) + continue + } + + this.#emitFile(entry, archivePath) + } + } + + /** + * The member's manifest carries load-bearing metadata (`type`, `exports`) and + * may exist in the archive only as the parser's faux placeholder; the real + * one wins by the registry's prefer-physical rule. + */ + async #emitMemberPackageJson (member: string): Promise { + const packageJson = path.join(member, 'package.json') + // Stat through any link: a manifest that is itself a symlink still reads as + // a file when the archive is built. + const stats = await this.#statThroughLink(packageJson) + if (stats === undefined || !stats.isFile()) { + return false + } + + const archivePath = this.#archivePathOf(packageJson) + if (archivePath === undefined || this.#isIgnoredArchivePath(archivePath)) { + return false + } + + this.#emitFile(packageJson, archivePath) + return true + } + + /** + * Marks a link whose target content arrives through the parser rather than + * through this resolver — the prune pass must not treat its target as absent, + * and the bundler should warn if a conflict ever forces the link out. + */ + #markLinkReferenced (symlink: string): void { + const archivePath = this.#archivePathOf(symlink) + const existing = archivePath !== undefined ? this.#entries.get(archivePath) : undefined + if (existing !== undefined && existing.symlinkTarget !== undefined) { + existing.referencedLink = true + } + } + + /** Whether a real directory sits inside a pnpm store (`.pnpm/@/node_modules/...`). */ + #isPnpmStoreLocation (target: string): boolean { + return pnpmStoreNodeModules(target) !== undefined + } + /** * Bundles a real directory that a symlink points at, and everything reachable * from it. Deduplicated by real path, which is what makes cyclic and diamond @@ -484,15 +736,11 @@ class SymlinkResolver { * pnpm's own strictness makes rare. */ async #addDependencyClosure (packageDirectory: string): Promise { - const nodeModules = enclosingNodeModules(packageDirectory) - if (nodeModules === undefined) { - return - } - // A pnpm store package's node_modules directory looks like // <...>/.pnpm/@/node_modules. Anything else — including a // node_modules directory bundled *inside* a package — must not trigger this. - if (path.basename(path.dirname(path.dirname(nodeModules))) !== PNPM_STORE) { + const nodeModules = pnpmStoreNodeModules(packageDirectory) + if (nodeModules === undefined) { return } @@ -543,117 +791,6 @@ class SymlinkResolver { } } - /** - * Copies the contents of an out-of-root symlink target into the archive at the - * path the link occupies, emitting no symlink entry. Nested symlinks are - * dereferenced too, since anything they point at is out of root as well. - */ - async #dereference (symlink: string, matchedPath: string): Promise { - if (isInsideNodeModules(symlink) && (await this.#isPnpmStorePackage(symlink))) { - // A package linked in from outside the project — a globally linked package, - // or a virtual store relocated out of the workspace. Its dependencies are - // siblings of it inside that store, and they have no archive path either, - // so they cannot travel with it: the package arrives without anything it - // needs. Say so, rather than reporting a success the runner will not see. - this.#warnOnce( - symlink, - `${symlink} is linked from outside the project. Its contents will be bundled, but its ` - + `dependencies cannot be, so it may fail to resolve them when the check runs.`, - ) - } else { - this.#warnOnce( - symlink, - `${symlink} points outside the project. Bundling its contents instead of the symlink.`, - ) - } - - // Whatever was matched belongs in the archive at the path it was matched at. - // Its bytes are readable straight through the link. - await this.#dereferenceEntry(matchedPath) - } - - /** - * Bundles one path from inside an out-of-root tree. The path may itself be a - * symlink — a package directory reached through a linked node_modules can - * contain more links — and those get dereferenced too, since anything they - * point at is out of root as well. - */ - async #dereferenceEntry (target: string): Promise { - const archivePath = this.#archivePathOf(target) - if (archivePath === undefined) { - return - } - - const stats = await this.#lstat(target) - if (stats?.isSymbolicLink()) { - const linkStats = await this.#statThroughLink(target) - if (linkStats === undefined) { - // Dangling. A link to nothing is worth nothing on the runner. - return - } - - if (linkStats.isDirectory()) { - await this.#dereferenceTree(target, archivePath, new Set()) - return - } - } - - this.#emitFile(target, archivePath) - } - - async #dereferenceTree (directory: string, archiveDirectory: string, ancestors: Set): Promise { - const real = await this.#realpath(directory) - if (real === undefined || ancestors.has(real)) { - return - } - - // A directory reachable by more than one route is copied once, and every - // later route becomes a link to that copy. Copying it again per route would - // duplicate the bytes and, on a graph where links fan out, take time - // exponential in its depth. - // - // Should files also arrive beneath the later route — glob does walk into a - // symlinked directory for a `**/*` pattern — that link would have children, - // and #pruneSymlinks drops it in favour of them. - const copied = this.#dereferenced.get(real) - if (copied !== undefined) { - if (copied !== archiveDirectory) { - this.#emitSymlinkEntry(directory, archiveDirectory, copied) - } - return - } - this.#dereferenced.set(real, archiveDirectory) - - const visited = new Set(ancestors).add(real) - - for (const entry of await this.#enumerate(real)) { - const archivePath = path.posix.join(archiveDirectory, pathToPosix(path.relative(real, entry))) - - if (this.#isIgnoredArchivePath(archivePath)) { - continue - } - - const stats = await this.#lstat(entry) - if (!stats?.isSymbolicLink()) { - this.#emitFile(entry, archivePath) - continue - } - - const linkStats = await this.#statThroughLink(entry) - if (linkStats === undefined) { - // Dangling. A link to nothing is worth nothing on the runner. - continue - } - - if (linkStats.isDirectory()) { - await this.#dereferenceTree(entry, archivePath, visited) - continue - } - - this.#emitFile(entry, archivePath) - } - } - /** * A broken symlink is not bundled. Whatever it points at does not exist here * and so cannot travel with it, leaving a link to nothing on the runner. (Were @@ -666,22 +803,17 @@ class SymlinkResolver { ) } - #emitSymlink (symlink: string, target: string, options: { referenced?: boolean } = {}): void { + #emitSymlink (symlink: string, target: string): void { const archivePath = this.#archivePathOf(symlink) const targetArchivePath = this.#archivePathOf(target) if (archivePath === undefined || targetArchivePath === undefined) { return } - this.#emitSymlinkEntry(symlink, archivePath, targetArchivePath, options) + this.#emitSymlinkEntry(symlink, archivePath, targetArchivePath) } - #emitSymlinkEntry ( - symlink: string, - archivePath: string, - targetArchivePath: string, - options: { referenced?: boolean } = {}, - ): void { + #emitSymlinkEntry (symlink: string, archivePath: string, targetArchivePath: string): void { // The link target is computed between archive paths, not filesystem paths, // so it stays valid wherever the archive is extracted. Both are anchored to // '/' first: path.posix.relative() resolves bare relative paths against the @@ -700,7 +832,6 @@ class SymlinkResolver { physical: true, archivePath, symlinkTarget, - ...(options.referenced ? { referencedLink: true as const } : {}), }) } @@ -765,6 +896,21 @@ class SymlinkResolver { return this.#isIgnoredArchivePath(archivePath) } + /** + * Whether the ignore patterns exclude an archive path either directly or in + * its entirety via a directory-shaped pattern. The distinction matters for + * deciding whether a *link* counts as excluded: the pattern shape the CLI's + * own docs teach (a globstar prefix, then the directory name, then a trailing + * globstar) matches everything under the directory but not the bare directory + * entry itself — a trailing globstar requires at least one segment. Probing + * with a synthetic child answers "did the user exclude this subtree" for both + * spellings. + */ + #isIgnoredArchivePathOrContents (archivePath: string): boolean { + return this.#isIgnoredArchivePath(archivePath) + || this.#isIgnoredArchivePath(path.posix.join(archivePath, 'x')) + } + #isIgnoredArchivePath (archivePath: string): boolean { return this.#ignorePatterns.some(pattern => minimatch(archivePath, pattern, { dot: true })) } @@ -813,21 +959,6 @@ class SymlinkResolver { } } - /** Whether a link resolves into a pnpm store, where a package's deps are siblings. */ - async #isPnpmStorePackage (symlink: string): Promise { - const target = await this.#realpath(symlink) - if (target === undefined) { - return false - } - - const nodeModules = enclosingNodeModules(target) - if (nodeModules === undefined) { - return false - } - - return path.basename(path.dirname(path.dirname(nodeModules))) === PNPM_STORE - } - /** Undefined when the path is a broken or cyclic symlink. */ async #realpath (target: string): Promise { try { @@ -886,6 +1017,20 @@ function isInsideNodeModules (target: string): boolean { return enclosingNodeModules(target) !== undefined } +/** + * The pnpm store node_modules directory enclosing a package directory + * (`<...>/.pnpm/@/node_modules`), or undefined when the package does + * not sit in a store. + */ +function pnpmStoreNodeModules (packageDirectory: string): string | undefined { + const nodeModules = enclosingNodeModules(packageDirectory) + if (nodeModules === undefined) { + return undefined + } + + return path.basename(path.dirname(path.dirname(nodeModules))) === PNPM_STORE ? nodeModules : undefined +} + /** * The node_modules directory a package directory belongs to, looking through a * scope directory when there is one: node_modules/@types/node lives two levels From a3e292234fcb69454544d06e19f47f2bed9bda1f Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Fri, 7 Aug 2026 13:57:59 +0900 Subject: [PATCH 5/7] test(cli): pin the workspace member-link bundling shape end to end [RED-713] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pnpm workspace whose Playwright testDir runs through a workspace-dependency link into a member package, bundled with include patterns that match both the member link and a pnpm-store dependency: asserts the member travels selectively (manifest, the testDir-discovered spec, its relative import, its by-name member dependency, and an include-matched asset — all at real paths, and nothing more), the store package expands with its sibling closure alongside, the matched links survive as symlink entries, no entry lands at a through-link spelling, and no symlink in the archive has children. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../package.json | 8 ++ .../packages/c/checkly.config.ts | 23 ++++ .../packages/c/package.json | 9 ++ .../packages/c/playwright.config.ts | 9 ++ .../packages/w/package.json | 6 + .../packages/w/src/index.js | 3 + .../packages/x/package.json | 8 ++ .../packages/x/src/assets/data.json | 1 + .../packages/x/src/helper.ts | 3 + .../packages/x/src/not-imported.ts | 3 + .../x/src/tests/flows/checkout.spec.ts | 9 ++ .../pnpm-lock.yaml | 69 ++++++++++++ .../pnpm-workspace.yaml | 2 + .../__tests__/playwright-check.spec.ts | 105 ++++++++++++++++++ 14 files changed, 258 insertions(+) create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/package.json create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/c/checkly.config.ts create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/c/package.json create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/c/playwright.config.ts create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/w/package.json create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/w/src/index.js create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/x/package.json create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/x/src/assets/data.json create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/x/src/helper.ts create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/x/src/not-imported.ts create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/x/src/tests/flows/checkout.spec.ts create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/pnpm-lock.yaml create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/pnpm-workspace.yaml diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/package.json b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/package.json new file mode 100644 index 000000000..ce856e9ec --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/package.json @@ -0,0 +1,8 @@ +{ + "name": "workspace-symlink-bundle-test", + "version": "1.0.0", + "private": true, + "dependencies": { + "@playwright/test": "^1.55.1" + } +} diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/c/checkly.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/c/checkly.config.ts new file mode 100644 index 000000000..a9c50bdd2 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/c/checkly.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'checkly' + +const config = defineConfig({ + projectName: 'Check Fixture', + logicalId: 'check-fixture', + checks: { + checkMatch: '**/*.check.ts', + ignoreDirectoriesMatch: [], + // The second pattern reaches *through* the member link to an asset file the + // parser cannot see (nothing imports it) — it must land at the member's + // real path. + include: ['node_modules/**', 'node_modules/@scope/x/src/assets/**'], + playwrightConfigPath: './playwright.config.ts', + playwrightChecks: [ + { + logicalId: 'playwright-check-suite', + name: 'Playwright Check Suite', + } + ], + }, +}) + +export default config diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/c/package.json b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/c/package.json new file mode 100644 index 000000000..664fe90a2 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/c/package.json @@ -0,0 +1,9 @@ +{ + "name": "@scope/c", + "version": "1.0.0", + "private": true, + "dependencies": { + "@playwright/test": "^1.55.1", + "@scope/x": "workspace:*" + } +} diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/c/playwright.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/c/playwright.config.ts new file mode 100644 index 000000000..00a69de5c --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/c/playwright.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + // A deep path through the @scope/x workspace link, which the test creates at + // run time as node_modules/@scope/x -> ../../../x. The tests themselves live + // in the linked workspace package, not in this package. + testDir: './node_modules/@scope/x/src/tests/flows', + timeout: 30000, +}) diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/w/package.json b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/w/package.json new file mode 100644 index 000000000..6564cd89e --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/w/package.json @@ -0,0 +1,6 @@ +{ + "name": "@scope/w", + "version": "1.0.0", + "private": true, + "main": "src/index.js" +} diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/w/src/index.js b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/w/src/index.js new file mode 100644 index 000000000..f83c0c937 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/w/src/index.js @@ -0,0 +1,3 @@ +export function entry () { + return 'w' +} diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/x/package.json b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/x/package.json new file mode 100644 index 000000000..ba93f0873 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/x/package.json @@ -0,0 +1,8 @@ +{ + "name": "@scope/x", + "version": "1.0.0", + "private": true, + "dependencies": { + "@scope/w": "workspace:*" + } +} diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/x/src/assets/data.json b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/x/src/assets/data.json new file mode 100644 index 000000000..a084ebbde --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/x/src/assets/data.json @@ -0,0 +1 @@ +{"fixture":true} diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/x/src/helper.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/x/src/helper.ts new file mode 100644 index 000000000..682f7408b --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/x/src/helper.ts @@ -0,0 +1,3 @@ +export function helper (): string { + return 'ok' +} diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/x/src/not-imported.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/x/src/not-imported.ts new file mode 100644 index 000000000..df7db4e4f --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/x/src/not-imported.ts @@ -0,0 +1,3 @@ +// Deliberately unreferenced: the member branch must not sweep this file into +// the bundle, since nothing imports it and no include pattern matches it. +export const unused = true diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/x/src/tests/flows/checkout.spec.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/x/src/tests/flows/checkout.spec.ts new file mode 100644 index 000000000..7152fc5ae --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/packages/x/src/tests/flows/checkout.spec.ts @@ -0,0 +1,9 @@ +import { test, expect } from '@playwright/test' + +import { helper } from '../../helper.js' +import { entry } from '@scope/w' + +test('checkout flow', async () => { + expect(helper()).toBe('ok') + expect(entry()).toBe('w') +}) diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/pnpm-lock.yaml b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/pnpm-lock.yaml new file mode 100644 index 000000000..e2c4fddfe --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/pnpm-lock.yaml @@ -0,0 +1,69 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@playwright/test': + specifier: ^1.55.1 + version: 1.59.1 + + packages/c: + dependencies: + '@playwright/test': + specifier: ^1.55.1 + version: 1.59.1 + '@scope/x': + specifier: workspace:* + version: link:../x + + packages/w: {} + + packages/x: + dependencies: + '@scope/w': + specifier: workspace:* + version: link:../w + +packages: + + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + +snapshots: + + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + + fsevents@2.3.2: + optional: true + + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/pnpm-workspace.yaml b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/pnpm-workspace.yaml new file mode 100644 index 000000000..dee51e928 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-bundling-workspace-symlinks/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/*" diff --git a/packages/cli/src/constructs/__tests__/playwright-check.spec.ts b/packages/cli/src/constructs/__tests__/playwright-check.spec.ts index 747721d9c..3fd6ef3e3 100644 --- a/packages/cli/src/constructs/__tests__/playwright-check.spec.ts +++ b/packages/cli/src/constructs/__tests__/playwright-check.spec.ts @@ -1221,6 +1221,111 @@ describe('PlaywrightCheck', () => { }, DEFAULT_TEST_TIMEOUT) }) + describe('bundling a pnpm workspace with member links', () => { + let fixt: FixtureSandbox + + beforeAll(async () => { + fixt = await FixtureSandbox.create({ + source: path.join(__dirname, 'fixtures', 'playwright-check', 'test-cases', 'test-bundling-workspace-symlinks'), + }) + + // What pnpm builds for workspace dependencies: links straight to the + // member directories. Built at run time (files exist first — Windows + // types links by their target). The Playwright config's testDir runs + // *through* the @scope/x link into a subdirectory of the member. + const cNodeModules = path.join(fixt.root, 'packages', 'c', 'node_modules', '@scope') + await fs.mkdir(cNodeModules, { recursive: true }) + await fs.symlink(path.join('..', '..', '..', 'x'), path.join(cNodeModules, 'x')) + + const xNodeModules = path.join(fixt.root, 'packages', 'x', 'node_modules', '@scope') + await fs.mkdir(xNodeModules, { recursive: true }) + await fs.symlink(path.join('..', '..', '..', 'w'), path.join(xNodeModules, 'w')) + + // A registry dependency in pnpm store shape next to the member link, so + // the two treatments coexist in one bundle: the store package expands + // with its sibling closure, the member stays selective. + const store = path.join(fixt.root, 'packages', 'c', 'node_modules', '.pnpm') + await fs.mkdir(path.join(store, 'pkg@1.0.0', 'node_modules', 'pkg'), { recursive: true }) + await fs.mkdir(path.join(store, 'dep@2.0.0', 'node_modules', 'dep'), { recursive: true }) + await fs.writeFile(path.join(store, 'pkg@1.0.0', 'node_modules', 'pkg', 'index.js'), 'pkg') + await fs.writeFile(path.join(store, 'dep@2.0.0', 'node_modules', 'dep', 'index.js'), 'dep') + await fs.symlink( + path.join('..', '..', 'dep@2.0.0', 'node_modules', 'dep'), + path.join(store, 'pkg@1.0.0', 'node_modules', 'dep'), + ) + await fs.symlink( + path.join('.pnpm', 'pkg@1.0.0', 'node_modules', 'pkg'), + path.join(fixt.root, 'packages', 'c', 'node_modules', 'pkg'), + ) + }, DEFAULT_TEST_TIMEOUT) + + afterAll(async () => { + await fixt?.destroy() + }) + + it('should bundle members selectively and keep the workspace links resolvable', async () => { + const result = await fixt.run('pnpm', [ + 'checkly', 'debug', 'parse-project', '--config', 'packages/c/checkly.config.ts', + ]) + expect(result.exitCode).toBe(0) + const output: ParseProjectOutput = JSON.parse(result.stdout) + + // The member branch announces that it narrows a directly-matched link. + expect(String(result.stderr)).toContain('resolves to the workspace package') + + const { + codeBundlePath, + } = output.payload.resources[0].payload as any + + const entries = await listTarEntries(codeBundlePath) + expectNoSymlinkHasChildren(entries) + + const files = entries.filter(entry => entry.type !== 'SymbolicLink').map(entry => entry.path) + const symlinks = entries + .filter(entry => entry.type === 'SymbolicLink') + .map(entry => `${entry.path} -> ${entry.linkpath}`) + .sort() + + // Exactly these links and no others. X's own @scope/w link is + // deliberately absent: no include pattern matches it, and like every + // parser-bundled workspace dependency it is recreated by the runner's + // install from pnpm-workspace.yaml + the bundled member directories. + expect(symlinks).toEqual([ + 'packages/c/node_modules/.pnpm/pkg@1.0.0/node_modules/dep -> ../../dep@2.0.0/node_modules/dep', + 'packages/c/node_modules/@scope/x -> ../../../x', + 'packages/c/node_modules/pkg -> .pnpm/pkg@1.0.0/node_modules/pkg', + ]) + + // The member's exact contribution: manifest, the testDir-discovered spec, + // its relative import, and the include-matched asset — at real paths, + // nothing more. not-imported.ts absent is the selectivity claim. + expect(files.filter(file => file.startsWith('packages/x/')).sort()).toEqual([ + 'packages/x/package.json', + 'packages/x/src/assets/data.json', + 'packages/x/src/helper.ts', + 'packages/x/src/tests/flows/checkout.spec.ts', + ]) + + // The member imported by name contributes manifest + entry file. + expect(files.filter(file => file.startsWith('packages/w/')).sort()).toEqual([ + 'packages/w/package.json', + 'packages/w/src/index.js', + ]) + + // The store package expands with its sibling closure, coexisting with the + // selective member treatment. + expect(files).toEqual(expect.arrayContaining([ + 'packages/c/node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/index.js', + 'packages/c/node_modules/.pnpm/dep@2.0.0/node_modules/dep/index.js', + 'packages/c/playwright.config.ts', + 'pnpm-workspace.yaml', + ])) + + // Nothing lands at through-link spellings. + expect(files.filter(file => file.includes('node_modules/@scope/x/'))).toEqual([]) + }, DEFAULT_TEST_TIMEOUT) + }) + describe('bundling with testDir through a symlink', () => { let fixt: FixtureSandbox From 4ff27d1d6afc6e68f4ce8acb0081cea3560981ac Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Fri, 7 Aug 2026 14:58:59 +0900 Subject: [PATCH 6/7] fix(cli): key code bundle entries by posix archive path [RED-713] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundler.registerFiles dedups files by archive path, but the fallback for parser-registered files used path.relative verbatim, which produces backslash separators on Windows. Symlink-resolver entries carry posix archive paths, so the same file could key twice — both entries survived and archiver normalized them to the same tar name, producing a duplicate tar entry and defeating the prefer-physical rule. Return the posix form so both producers key identically, and drop the now-redundant normalization inside dropSymlinksWithChildren. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014nP7rLGVD6QjmG73f9uBcF --- packages/cli/src/services/check-parser/bundler.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/services/check-parser/bundler.ts b/packages/cli/src/services/check-parser/bundler.ts index 4396f56a0..5070911ba 100644 --- a/packages/cli/src/services/check-parser/bundler.ts +++ b/packages/cli/src/services/check-parser/bundler.ts @@ -26,9 +26,14 @@ function archivePath (file: File, stripPrefix?: string): string { return file.archivePath } - return stripPrefix + // Posix form, because this value keys the bundler's dedup registry alongside + // resolver-carried archive paths, which are always posix. On Windows, + // path.relative produces a backslash spelling that would not collide with the + // posix spelling of the same path, and the archive would end up with + // duplicate entries (archiver normalizes both to the same tar name). + return pathToPosix(stripPrefix ? path.relative(stripPrefix, file.filePath) - : file.filePath + : file.filePath) } /** @@ -43,6 +48,8 @@ function archivePath (file: File, stripPrefix?: string): string { * registered at its path *through* that link, which puts it under a link the * resolver quite reasonably kept. * + * Entry names are posix by construction — archivePath() guarantees it. + * * The link is what goes, rather than the files: the files are content, and they * extract perfectly well as ordinary files, whereas the link takes the whole * archive down with it. @@ -52,7 +59,7 @@ function dropSymlinksWithChildren (entries: Array<[string, File]>): File[] { for (const [name] of entries) { for ( - let parent = path.posix.dirname(pathToPosix(name)); + let parent = path.posix.dirname(name); parent !== '.' && parent !== '/' && parent !== '' && !directories.has(parent); parent = path.posix.dirname(parent) ) { @@ -66,7 +73,7 @@ function dropSymlinksWithChildren (entries: Array<[string, File]>): File[] { return true } - if (!directories.has(pathToPosix(name))) { + if (!directories.has(name)) { return true } From 53a2e8d8405dfacf9d8aab5581b338934bb8393a Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Fri, 7 Aug 2026 14:59:04 +0900 Subject: [PATCH 7/7] test(cli): type fixture symlinks explicitly for Windows [RED-713] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows picks a symlink's type by looking at its target when the link is created; the pnpm-store fixture created its store-sibling link before writing the target files, yielding a file-typed link that cannot be opened as a directory — so the resolver (correctly) dropped it and the assertion missed one symlink entry, on Windows only. Pass an explicit 'dir' type to every fixture link so ordering is not load-bearing, and write files before links anyway. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014nP7rLGVD6QjmG73f9uBcF --- .../__tests__/playwright-check.spec.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/constructs/__tests__/playwright-check.spec.ts b/packages/cli/src/constructs/__tests__/playwright-check.spec.ts index 3fd6ef3e3..63a329fdc 100644 --- a/packages/cli/src/constructs/__tests__/playwright-check.spec.ts +++ b/packages/cli/src/constructs/__tests__/playwright-check.spec.ts @@ -1145,10 +1145,14 @@ describe('PlaywrightCheck', () => { await fs.writeFile(filePath, content) } + // The explicit 'dir' type matters on Windows, which otherwise picks the + // link's type by looking at its target when the link is created — and a + // target that does not exist yet produces a file-typed link that cannot + // then be opened as a directory. Other platforms ignore the type. const symlink = async (relativePath: string, target: string) => { const linkPath = path.join(nodeModules, relativePath) await fs.mkdir(path.dirname(linkPath), { recursive: true }) - await fs.symlink(target, linkPath) + await fs.symlink(target, linkPath, 'dir') } // What pnpm builds: packages live in a store, and node_modules holds links @@ -1156,9 +1160,9 @@ describe('PlaywrightCheck', () => { // not underneath it. await writeFile('.pnpm/pkg@1.0.0/node_modules/pkg/index.js', 'module.exports = require(\'dep\')\n') await writeFile('.pnpm/pkg@1.0.0/node_modules/pkg/package.json', '{"name":"pkg","version":"1.0.0"}') - await symlink('.pnpm/pkg@1.0.0/node_modules/dep', '../../dep@2.0.0/node_modules/dep') await writeFile('.pnpm/dep@2.0.0/node_modules/dep/index.js', 'module.exports = \'dep\'\n') await writeFile('.pnpm/dep@2.0.0/node_modules/dep/package.json', '{"name":"dep","version":"2.0.0"}') + await symlink('.pnpm/pkg@1.0.0/node_modules/dep', '../../dep@2.0.0/node_modules/dep') await symlink('pkg', '.pnpm/pkg@1.0.0/node_modules/pkg') // A linked workspace package, which is how a monorepo shares code. @@ -1168,7 +1172,7 @@ describe('PlaywrightCheck', () => { // parser registers what the spec imports at its path *through* the link, // without resolving it — so its files arrive beneath a link the symlink // resolver kept, from a code path the resolver never sees. - await fs.symlink('../shared-helpers', path.join(fixt.root, 'packages', 'e2e', 'helpers')) + await fs.symlink('../shared-helpers', path.join(fixt.root, 'packages', 'e2e', 'helpers'), 'dir') }, DEFAULT_TEST_TIMEOUT) afterAll(async () => { @@ -1230,16 +1234,17 @@ describe('PlaywrightCheck', () => { }) // What pnpm builds for workspace dependencies: links straight to the - // member directories. Built at run time (files exist first — Windows - // types links by their target). The Playwright config's testDir runs - // *through* the @scope/x link into a subdirectory of the member. + // member directories. Built at run time; the explicit 'dir' type is for + // Windows, which otherwise infers a link's type from its target. The + // Playwright config's testDir runs *through* the @scope/x link into a + // subdirectory of the member. const cNodeModules = path.join(fixt.root, 'packages', 'c', 'node_modules', '@scope') await fs.mkdir(cNodeModules, { recursive: true }) - await fs.symlink(path.join('..', '..', '..', 'x'), path.join(cNodeModules, 'x')) + await fs.symlink(path.join('..', '..', '..', 'x'), path.join(cNodeModules, 'x'), 'dir') const xNodeModules = path.join(fixt.root, 'packages', 'x', 'node_modules', '@scope') await fs.mkdir(xNodeModules, { recursive: true }) - await fs.symlink(path.join('..', '..', '..', 'w'), path.join(xNodeModules, 'w')) + await fs.symlink(path.join('..', '..', '..', 'w'), path.join(xNodeModules, 'w'), 'dir') // A registry dependency in pnpm store shape next to the member link, so // the two treatments coexist in one bundle: the store package expands @@ -1252,10 +1257,12 @@ describe('PlaywrightCheck', () => { await fs.symlink( path.join('..', '..', 'dep@2.0.0', 'node_modules', 'dep'), path.join(store, 'pkg@1.0.0', 'node_modules', 'dep'), + 'dir', ) await fs.symlink( path.join('.pnpm', 'pkg@1.0.0', 'node_modules', 'pkg'), path.join(fixt.root, 'packages', 'c', 'node_modules', 'pkg'), + 'dir', ) }, DEFAULT_TEST_TIMEOUT) @@ -1338,6 +1345,7 @@ describe('PlaywrightCheck', () => { await fs.symlink( path.join('shared', 'tests'), path.join(fixt.root, 'linked-tests'), + 'dir', ) }, DEFAULT_TEST_TIMEOUT)