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__/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__/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__/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 14175921a..63a329fdc 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', () => { @@ -932,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({ @@ -960,15 +1009,41 @@ 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) + // 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', ])) + 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 () => { @@ -1050,6 +1125,267 @@ 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) + } + + // 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, 'dir') + } + + // 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 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. + 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'), 'dir') + }, 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 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; 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'), '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'), '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 + // 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'), + '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) + + 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 + + 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'), + 'dir', + ) + }, 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 new file mode 100644 index 000000000..a959fc772 --- /dev/null +++ b/packages/cli/src/services/__tests__/symlink-resolver.spec.ts @@ -0,0 +1,1187 @@ +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 + /** 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, referencedPaths } = options + + const matchedPaths = await findFilesWithPattern(cwd, patterns, ignore) + + const files = await resolveBundleFiles({ + matchedPaths, + bundleRoot, + ignoreCwd: cwd, + ignorePatterns: ignore, + referencedPaths, + }) + + // 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([]) + }) + }) + + 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"}', + '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 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({ + '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, ['fixtures/*', 'package.json'], { bundleRoot: root }) + + expect(entries(files)).toEqual([ + 'fixtures/data.json', + 'fixtures/vendor/lib.js', + 'package.json', + ]) + }) + + 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', + 'project/node_modules/pkg': link('../../external/pkg'), + 'project/package.json': '{}', + }) + const root = path.join(outer, 'project') + + await expect(bundle(root, ['node_modules/pkg/**', 'package.json'], { bundleRoot: root })) + .rejects.toThrow(/outside the project's bundle root/) + }) + + 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', + 'project/node_modules/pkg': link('../../external/pkg'), + 'project/package.json': '{}', + }) + const root = path.join(outer, 'project') + + 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([ + 'package.json', + ]) + }) + }) + + 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('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'], + ['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 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/__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 000000000..97ab545d6 Binary files /dev/null and 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 differ 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 d8c2ccb86..5070911ba 100644 --- a/packages/cli/src/services/check-parser/bundler.ts +++ b/packages/cli/src/services/check-parser/bundler.ts @@ -12,9 +12,91 @@ 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 + } + + // 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) +} + +/** + * 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. + * + * 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. + */ +function dropSymlinksWithChildren (entries: Array<[string, File]>): File[] { + const directories = new Set() + + for (const [name] of entries) { + for ( + let parent = path.posix.dirname(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(name)) { + 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 + }) + .map(([, file]) => file) +} + export interface CreateBundleArchiveOptions { tempDir?: string stripPrefix?: string @@ -88,23 +170,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 +408,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 +421,7 @@ export class Bundler { } } - this.#files.set(newFile.filePath, newFile) + this.#files.set(key, newFile) } } @@ -312,10 +431,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/check-parser/parser.ts b/packages/cli/src/services/check-parser/parser.ts index eb73454c9..d6000366d 100644 --- a/packages/cli/src/services/check-parser/parser.ts +++ b/packages/cli/src/services/check-parser/parser.ts @@ -135,7 +135,28 @@ 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 + /** + * 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 = | VirtualFile @@ -239,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 6b06d2fd4..16fb1a74b 100644 --- a/packages/cli/src/services/playwright-project-bundler.ts +++ b/packages/cli/src/services/playwright-project-bundler.ts @@ -10,9 +10,31 @@ 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 — the workspace root, which is + * also the bundler's strip prefix (see Bundler.createForWorkspace) — together + * with the workspace's member directories. + */ +function workspaceBundleInfo (): { + bundleRoot: string + members: Array<{ path: string, name: string }> +} | undefined { + const workspace = Session.workspace + if (!workspace.isOk()) { + return undefined + } + + const { root, packages } = workspace.unwrap() + return { + bundleRoot: root.path, + members: [root, ...packages].map(({ path, name }) => ({ path, name })), + } +} + export interface PlaywrightProjectBundle { browsers: string[] relativePlaywrightConfigPath: string @@ -94,11 +116,30 @@ 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 workspaceInfo = workspaceBundleInfo() + if (workspaceInfo === undefined) { + for (const filePath of includedFiles) { + files.push({ + filePath, + physical: true, + }) + } + } else { + files.push(...await resolveBundleFiles({ + matchedPaths: includedFiles, + bundleRoot: workspaceInfo.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()), + workspaceMembers: workspaceInfo.members, + })) } return { diff --git a/packages/cli/src/services/symlink-resolver.ts b/packages/cli/src/services/symlink-resolver.ts new file mode 100644 index 000000000..704f955c4 --- /dev/null +++ b/packages/cli/src/services/symlink-resolver.ts @@ -0,0 +1,1051 @@ +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[] + /** + * 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[] + /** + * 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 }> +} + +/** + * 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, + options.referencedPaths ?? [], + options.workspaceMembers ?? [], + ) +} + +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() + /** Workspace member package names, keyed by canonical member directory. */ + #memberNames = new Map() + /** Out-of-root directories already copied, and where each one landed. */ + #copiedTrees = 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[], + 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 + // 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. + } + + // 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 + // applied to it. + for (const matchedPath of matchedPaths) { + this.#directPaths.add(matchedPath) + } + + for (const matchedPath of matchedPaths) { + 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) + // 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) + } + } + + /** + * 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. 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'}`) + 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) { + 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 + } + + 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) + + // 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 + // 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. + } + + /** + * 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 + * 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 { + // 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. + const nodeModules = pnpmStoreNodeModules(packageDirectory) + if (nodeModules === undefined) { + 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. + } + } + + /** + * 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) + } + + /** + * 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 })) + } + + #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 + } + } + + /** 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 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 + * 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 +}