diff --git a/packages/cli/src/ai-context/references/configure-playwright-checks.md b/packages/cli/src/ai-context/references/configure-playwright-checks.md index b8ccd2d48..2430d5577 100644 --- a/packages/cli/src/ai-context/references/configure-playwright-checks.md +++ b/packages/cli/src/ai-context/references/configure-playwright-checks.md @@ -14,6 +14,7 @@ - Use `installCommand` only when the default package-manager install command is not enough. - Checkly caches installed dependencies between runs, keyed off the lock file, `package.json` and `.npmrc` contents. To force a reinstall declaratively, set `caching.dependencyCache.version` (a string or a safe integer) at the top level of `checkly.config.ts` (not per check — one code bundle serves all Playwright Check Suites) and change its value whenever the cache should be invalidated; scheduled checks pick up the change on the next `checkly deploy`. Unset or empty-string values leave the cache key unchanged, so a dynamic value such as `version: process.env.DEPENDENCY_CACHE_VERSION` is safe when the variable is not always set. For a one-off reinstall during an ad-hoc run, use the `--refresh-cache` flag available on the run/test commands (`checkly test`, `checkly pw-test`, `checkly trigger`, `checkly checks run`) instead; the config value is the persistent knob that also applies to deployed, scheduled checks. - In Checkly CLI v8.0.0 and later, `include` patterns resolve relative to the Playwright config directory, not the project root. If `playwrightConfigPath` points to a subdirectory, adjust `include` globs. Example: `playwrightConfigPath: "./e2e/playwright.config.ts"` with a root fixture at `fixtures/data.json` needs `include: ["../fixtures/data.json"]`. +- If dependencies come from a private registry that Checkly's infrastructure cannot reach (for example an intranet-only Nexus mirror), list them in `checks.embeddedPackages` in `checkly.config.ts`. Each entry is a package name (embeds every version found in the lockfile) or an exact `name@version` pin; names may contain `*` wildcards (`@acme/*`, `acme-*`, `@acme/*-utils`) where each `*` matches any run of characters except `/` (never crossing the scope separator); as long as a spec matches at least one registry package, matches that cannot be embedded are skipped (workspace members silently, git/file/URL dependencies and integrity-less entries with a warning since the runner must fetch those itself), while a spec whose only matches cannot be embedded — or that matches nothing at all — is an error; a pattern embeds every lockfile version of every package it matches, so scope it to the packages the runner genuinely cannot fetch. List every unreachable package by name, including private packages that only appear as transitive dependencies of other private packages — dependencies of listed packages are not embedded automatically. The CLI resolves entries against the workspace-root lockfile (`pnpm-lock.yaml` or `package-lock.json`), reuses tarballs from local caches (its own, then npm's) or downloads them from the registry configured in `.npmrc`, verifies each against the lockfile's integrity hash, and ships them inside the code bundle at `.checkly/embedded-packages/*.tgz`, where the runner serves them through a local registry during install. Downloads are cached under the workspace root's `node_modules/.cache/checkly` (in a monorepo that is the repo root, not the member package; override with `CHECKLY_CACHE_DIR`; a per-user cache dir is the fallback when the project location isn't writable), so nothing lands in the project outside `node_modules`. CI setups that cache `node_modules` — or platforms that preserve `node_modules/.cache` — persist the tarballs automatically; otherwise persist `CHECKLY_CACHE_DIR` in CI to avoid re-downloading (note `npm ci` deletes `node_modules` wholesale, unlike incremental pnpm installs). The machine running `checkly deploy`/`test` needs registry access on a cold cache. Applies to Playwright Check Suites only, not browser or multistep checks. ## Install troubleshooting diff --git a/packages/cli/src/commands/debug/parse-project.ts b/packages/cli/src/commands/debug/parse-project.ts index e305c39b0..c357dfb8c 100644 --- a/packages/cli/src/commands/debug/parse-project.ts +++ b/packages/cli/src/commands/debug/parse-project.ts @@ -149,6 +149,7 @@ export default class ParseProjectCommand extends Command { checklyConfigConstructs, playwrightConfigPath: checklyConfig.checks?.playwrightConfigPath, include: includeFlag.length ? includeFlag : checklyConfig.checks?.include, + embeddedPackages: checklyConfig.checks?.embeddedPackages, playwrightChecks: checklyConfig.checks?.playwrightChecks, loadPlaywrightChecksOnly: emulatePwTest, warnOnWebServerConfig: emulatePwTest && !(includeFlag.length > 0), diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index ce630c908..9b5521d5d 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -178,6 +178,7 @@ export default class Deploy extends AuthCommand { checklyConfigConstructs, playwrightConfigPath: checklyConfig.checks?.playwrightConfigPath, include: checklyConfig.checks?.include, + embeddedPackages: checklyConfig.checks?.embeddedPackages, playwrightChecks: checklyConfig.checks?.playwrightChecks, }) const repoInfo = getGitInformation(project.repoUrl) diff --git a/packages/cli/src/commands/pw-test.ts b/packages/cli/src/commands/pw-test.ts index e7c2b1af5..96c88fccd 100644 --- a/packages/cli/src/commands/pw-test.ts +++ b/packages/cli/src/commands/pw-test.ts @@ -214,6 +214,7 @@ export default class PwTestCommand extends AuthCommand { checklyConfigConstructs, playwrightConfigPath, include: includeFlag.length ? includeFlag : checklyConfig.checks?.include, + embeddedPackages: checklyConfig.checks?.embeddedPackages, playwrightChecks: [playwrightCheck], loadPlaywrightChecksOnly: true, warnOnWebServerConfig: !(includeFlag.length > 0), diff --git a/packages/cli/src/commands/test.ts b/packages/cli/src/commands/test.ts index a7e284caa..5d5265419 100644 --- a/packages/cli/src/commands/test.ts +++ b/packages/cli/src/commands/test.ts @@ -206,6 +206,7 @@ export default class Test extends AuthCommand { checklyConfigConstructs, playwrightConfigPath: checklyConfig.checks?.playwrightConfigPath, include: checklyConfig.checks?.include, + embeddedPackages: checklyConfig.checks?.embeddedPackages, playwrightChecks: checklyConfig.checks?.playwrightChecks, checkFilter: check => { if (check instanceof HeartbeatMonitor) { diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index b1a0aed72..24a84adc0 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -62,6 +62,7 @@ export default class Validate extends AuthCommand { checklyConfigConstructs, playwrightConfigPath: checklyConfig.checks?.playwrightConfigPath, include: checklyConfig.checks?.include, + embeddedPackages: checklyConfig.checks?.embeddedPackages, playwrightChecks: checklyConfig.checks?.playwrightChecks, }) diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/embedded-tarballs/.gitignore b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/embedded-tarballs/.gitignore new file mode 100644 index 000000000..e3b2e2c73 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/embedded-tarballs/.gitignore @@ -0,0 +1,3 @@ +# The repo root ignores *.tgz for pnpm-pack output; these committed tarballs +# are test fixtures for the embedded-packages feature. +!*.tgz diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/embedded-tarballs/@acme+private-utils@1.2.3.tgz b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/embedded-tarballs/@acme+private-utils@1.2.3.tgz new file mode 100644 index 000000000..e5b74bc11 Binary files /dev/null and b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/embedded-tarballs/@acme+private-utils@1.2.3.tgz differ diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/embedded-tarballs/README.md b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/embedded-tarballs/README.md new file mode 100644 index 000000000..b3813417f --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/embedded-tarballs/README.md @@ -0,0 +1,34 @@ +# Embedded-packages tarball fixtures + +Tiny deterministic `.tgz` files used by the embedded-packages bundling tests +in `playwright-check.spec.ts`. Their sha512 integrities are hardcoded in the +`pnpm-lock.yaml` files of the `test-embedded-packages*` fixtures, so the +tarball bytes and the lockfile entries must change together. + +To regenerate (and then update the `resolution.integrity` values the script +prints into the fixture lockfiles): + +```python +import tarfile, gzip, io, json, hashlib, base64 + +def make_tgz(dest, name, version): + tar_buf = io.BytesIO() + with tarfile.open(fileobj=tar_buf, mode='w', format=tarfile.GNU_FORMAT) as tf: + pkg = json.dumps({"name": name, "version": version, "main": "index.js"}, indent=2).encode() + idx = f'module.exports = {json.dumps(name + "@" + version)}\n'.encode() + for path, data in [("package/package.json", pkg), ("package/index.js", idx)]: + info = tarfile.TarInfo(path) + info.size = len(data) + info.mtime = 0 + info.mode = 0o644 + tf.addfile(info, io.BytesIO(data)) + gz_buf = io.BytesIO() + with gzip.GzipFile(fileobj=gz_buf, mode='wb', mtime=0) as gz: + gz.write(tar_buf.getvalue()) + content = gz_buf.getvalue() + open(dest, 'wb').write(content) + print(dest, 'sha512-' + base64.b64encode(hashlib.sha512(content).digest()).decode()) + +make_tgz("@acme+private-utils@1.2.3.tgz", "@acme/private-utils", "1.2.3") +make_tgz("legacy-private-pkg@2.1.0.tgz", "legacy-private-pkg", "2.1.0") +``` diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/embedded-tarballs/legacy-private-pkg@2.1.0.tgz b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/embedded-tarballs/legacy-private-pkg@2.1.0.tgz new file mode 100644 index 000000000..4b33c67ed Binary files /dev/null and b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/embedded-tarballs/legacy-private-pkg@2.1.0.tgz differ diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/checkly.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/checkly.config.ts new file mode 100644 index 000000000..d087efedc --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/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: [], + playwrightConfigPath: './playwright.config.ts', + embeddedPackages: ['no-such-package'], + 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-embedded-packages-not-found/package.json b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/package.json new file mode 100644 index 000000000..b12adbc29 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/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-embedded-packages-not-found/playwright.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/playwright.config.ts new file mode 100644 index 000000000..eed093e29 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/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-embedded-packages-not-found/pnpm-lock.yaml b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/pnpm-lock.yaml new file mode 100644 index 000000000..9c3c4c244 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/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-embedded-packages-not-found/tests/example.spec.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/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-embedded-packages-not-found/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-embedded-packages-subdir/checkly.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/checkly.config.ts new file mode 100644 index 000000000..588a047eb --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/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: [], + playwrightConfigPath: './subdir/playwright.config.ts', + embeddedPackages: ['@acme/private-utils'], + 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-embedded-packages-subdir/package.json b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/package.json new file mode 100644 index 000000000..b12adbc29 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/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-embedded-packages-subdir/pnpm-lock.yaml b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/pnpm-lock.yaml new file mode 100644 index 000000000..f59fa575e --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/pnpm-lock.yaml @@ -0,0 +1,57 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@playwright/test': + specifier: ^1.55.1 + version: 1.59.1 + +packages: + + '@acme/private-utils@1.2.3': + resolution: {integrity: sha512-dnkm3WedrIfH8+nRoHESfj0/DDeZdBTCpP2B5ZUSR/6YsMiOtYmauw1FRb2hDNC00ZLWu8Ya8sZfR2D/s1VhTQ==} + + '@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: + + '@acme/private-utils@1.2.3': {} + + '@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-embedded-packages-subdir/subdir/playwright.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/subdir/playwright.config.ts new file mode 100644 index 000000000..eed093e29 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/subdir/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-embedded-packages-subdir/subdir/tests/example.spec.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/subdir/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-embedded-packages-subdir/subdir/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-embedded-packages/checkly.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/checkly.config.ts new file mode 100644 index 000000000..8cab2e358 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/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: [], + playwrightConfigPath: './playwright.config.ts', + embeddedPackages: ['@acme/private-utils', 'legacy-private-pkg@2.1.0'], + 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-embedded-packages/package.json b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/package.json new file mode 100644 index 000000000..b12adbc29 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/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-embedded-packages/playwright.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/playwright.config.ts new file mode 100644 index 000000000..eed093e29 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/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-embedded-packages/pnpm-lock.yaml b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/pnpm-lock.yaml new file mode 100644 index 000000000..dab310790 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/pnpm-lock.yaml @@ -0,0 +1,67 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@playwright/test': + specifier: ^1.55.1 + version: 1.59.1 + +packages: + + '@acme/private-utils@1.2.3': + resolution: {integrity: sha512-dnkm3WedrIfH8+nRoHESfj0/DDeZdBTCpP2B5ZUSR/6YsMiOtYmauw1FRb2hDNC00ZLWu8Ya8sZfR2D/s1VhTQ==} + + '@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] + + legacy-private-pkg@2.1.0: + resolution: {integrity: sha512-lyOrTMMajW/F3ryAPbDHLv3ZhJVoV+3W2cff/313EifQN/51nKtgyGxQMERcb/RZ8OahAl/8tPbK76Rsov7GyQ==} + + legacy-private-pkg@3.0.0: + resolution: {integrity: sha512-0000000000000000000000000000000000000000000000000000000000000000000000000000000000ABCDEF==} + + 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: + + '@acme/private-utils@1.2.3': {} + + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + + fsevents@2.3.2: + optional: true + + legacy-private-pkg@2.1.0: {} + + legacy-private-pkg@3.0.0: {} + + 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-embedded-packages/tests/example.spec.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/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-embedded-packages/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__/playwright-check.spec.ts b/packages/cli/src/constructs/__tests__/playwright-check.spec.ts index 45eb01235..9dd5cc8f5 100644 --- a/packages/cli/src/constructs/__tests__/playwright-check.spec.ts +++ b/packages/cli/src/constructs/__tests__/playwright-check.spec.ts @@ -1,19 +1,30 @@ +import { createHash } from 'node:crypto' import fs from 'node:fs/promises' +import os from 'node:os' import path from 'node:path' import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { list } from 'tar' -import { FixtureSandbox } from '../../testing/fixture-sandbox.js' +import { FixtureSandbox, RunOptions } from '../../testing/fixture-sandbox.js' import { ParseProjectOutput } from '../../commands/debug/parse-project.js' +import { TarballCache } from '../../services/embedded-packages/cache.js' async function parseProject (fixt: FixtureSandbox, ...args: string[]): Promise { + return await parseProjectWithOptions(fixt, {}, ...args) +} + +async function parseProjectWithOptions ( + fixt: FixtureSandbox, + options: RunOptions, + ...args: string[] +): Promise { const result = await fixt.run('pnpm', [ 'checkly', 'debug', 'parse-project', ...args, - ]) + ], options) if (result.exitCode !== 0) { // eslint-disable-next-line no-console @@ -1494,6 +1505,121 @@ describe('PlaywrightCheck', () => { }, DEFAULT_TEST_TIMEOUT) }) + /** + * Creates a temp CLI cache dir seeded with committed tarball fixtures so + * that embedded-packages tests run offline: with CHECKLY_CACHE_DIR set to + * the returned dir, the materializer finds every tarball in the CLI cache + * and never contacts a registry. + */ + async function seedTarballCache (...tarballFilenames: string[]): Promise { + const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-embed-cache-')) + const cache = TarballCache.default({ CHECKLY_CACHE_DIR: cacheDir }) + for (const filename of tarballFilenames) { + const content = await fs.readFile( + path.join(__dirname, 'fixtures', 'playwright-check', 'embedded-tarballs', filename), + ) + const integrity = `sha512-${createHash('sha512').update(content).digest('base64')}` + await cache.put(integrity, content) + } + return cacheDir + } + + describe('bundling with embedded packages', () => { + let fixt: FixtureSandbox + let cacheDir: string + + beforeAll(async () => { + fixt = await FixtureSandbox.create({ + source: path.join(__dirname, 'fixtures', 'playwright-check', 'test-cases', 'test-embedded-packages'), + }) + cacheDir = await seedTarballCache('@acme+private-utils@1.2.3.tgz', 'legacy-private-pkg@2.1.0.tgz') + }, DEFAULT_TEST_TIMEOUT) + + afterAll(async () => { + await fixt?.destroy() + if (cacheDir) { + await fs.rm(cacheDir, { recursive: true, force: true }) + } + }) + + it('should embed configured tarballs at the contract path', async () => { + const output = await parseProjectWithOptions(fixt, { env: { CHECKLY_CACHE_DIR: cacheDir } }) + + expect(output.diagnostics.fatal).toBe(false) + + const { + codeBundlePath, + } = output.payload.resources[0].payload as any + + const files = await listTarFiles(codeBundlePath) + + expect(files).toContain('.checkly/embedded-packages/@acme+private-utils@1.2.3.tgz') + expect(files).toContain('.checkly/embedded-packages/legacy-private-pkg@2.1.0.tgz') + // The lockfile also contains legacy-private-pkg@3.0.0; the exact + // version pin must exclude it. + expect(files).not.toContain('.checkly/embedded-packages/legacy-private-pkg@3.0.0.tgz') + }, DEFAULT_TEST_TIMEOUT) + }) + + describe('bundling with embedded packages and subdirectory playwright config', () => { + let fixt: FixtureSandbox + let cacheDir: string + + beforeAll(async () => { + fixt = await FixtureSandbox.create({ + source: path.join(__dirname, 'fixtures', 'playwright-check', 'test-cases', 'test-embedded-packages-subdir'), + }) + cacheDir = await seedTarballCache('@acme+private-utils@1.2.3.tgz') + }, DEFAULT_TEST_TIMEOUT) + + afterAll(async () => { + await fixt?.destroy() + if (cacheDir) { + await fs.rm(cacheDir, { recursive: true, force: true }) + } + }) + + it('should embed tarballs at the contract path when playwright config is in a subdirectory', async () => { + const output = await parseProjectWithOptions(fixt, { env: { CHECKLY_CACHE_DIR: cacheDir } }) + + expect(output.diagnostics.fatal).toBe(false) + + const { + codeBundlePath, + } = output.payload.resources[0].payload as any + + const files = await listTarFiles(codeBundlePath) + + expect(files).toContain('.checkly/embedded-packages/@acme+private-utils@1.2.3.tgz') + }, DEFAULT_TEST_TIMEOUT) + }) + + describe('embedded packages validation', () => { + let fixt: FixtureSandbox + + beforeAll(async () => { + fixt = await FixtureSandbox.create({ + source: path.join(__dirname, 'fixtures', 'playwright-check', 'test-cases', 'test-embedded-packages-not-found'), + }) + }, DEFAULT_TEST_TIMEOUT) + + afterAll(async () => { + await fixt?.destroy() + }) + + it('should fail validation for a package that is not in the lockfile', async () => { + const output = await parseProject(fixt) + + expect(output.diagnostics.fatal).toBe(true) + expect(output.payload).toBeNull() + + const observation = output.diagnostics.observations.find(obs => obs.message.includes('no-such-package')) + expect(observation).toBeDefined() + expect(observation?.fatal).toBe(true) + expect(observation?.message).toContain('does not match any package in the lockfile') + }, DEFAULT_TEST_TIMEOUT) + }) + describe('bundling with absolute include path', () => { let fixt: FixtureSandbox diff --git a/packages/cli/src/constructs/__tests__/project-embedded-packages.spec.ts b/packages/cli/src/constructs/__tests__/project-embedded-packages.spec.ts new file mode 100644 index 000000000..d859f217c --- /dev/null +++ b/packages/cli/src/constructs/__tests__/project-embedded-packages.spec.ts @@ -0,0 +1,164 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest' + +import { Project } from '../project.js' +import { PlaywrightCheck } from '../playwright-check.js' +import { Session } from '../session.js' +import { Diagnostics, WarningDiagnostic } from '../diagnostics.js' +import { InvalidPropertyValueDiagnostic, UnsatisfiedLocalPrerequisitesDiagnostic } from '../construct-diagnostics.js' +import { Package, Workspace } from '../../services/check-parser/package-files/workspace.js' +import { Ok, Err } from '../../services/check-parser/package-files/result.js' + +describe('Project embedded packages validation', () => { + let dir: string + + beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-embed-validate-')) + await fs.writeFile(path.join(dir, 'playwright.config.ts'), 'export default {}\n') + await fs.writeFile(path.join(dir, 'pnpm-lock.yaml'), [ + `lockfileVersion: '9.0'`, + `packages:`, + ` present-pkg@1.0.0:`, + ` resolution: {integrity: sha512-aaa}`, + ` 'present-git@https://codeload.github.com/user/present-git/tar.gz/abc':`, + ` resolution: {tarball: https://codeload.github.com/user/present-git/tar.gz/abc}`, + ].join('\n')) + await fs.writeFile(path.join(dir, 'yarn.lock'), '') + }) + + afterAll(async () => { + await fs.rm(dir, { recursive: true, force: true }) + }) + + beforeEach(() => { + Session.reset() + }) + + afterEach(() => { + Session.reset() + }) + + // `lockfile: null` sets up a workspace without a lockfile. + const setupProject = ({ withPlaywrightCheck = true, lockfile = 'pnpm-lock.yaml' as string | null } = {}) => { + const project = new Project('embed-validate', { name: 'Embed Validate' }) + Session.project = project + Session.basePath = dir + Session.contextPath = dir + Session.checkDefaults = {} + Session.workspace = Ok(new Workspace({ + root: new Package({ name: 'embed-validate', path: dir }), + packages: [], + lockfile: lockfile !== null + ? Ok(path.join(dir, lockfile)) + : Err(new Error('no lockfile')), + configFile: Err(new Error('no config file')), + })) + + if (withPlaywrightCheck) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const check = new PlaywrightCheck('pw-suite', { + name: 'PW Suite', + playwrightConfigPath: path.join(dir, 'playwright.config.ts'), + }) + } + + return project + } + + const validateEmbeddedDiagnostics = async (project: Project) => { + const diagnostics = new Diagnostics() + await project.validate(diagnostics) + // Ignore diagnostics produced by the checks themselves; only the + // project-level embedded-packages ones are under test here. + return diagnostics.observations.filter(diag => + diag instanceof UnsatisfiedLocalPrerequisitesDiagnostic + || (diag instanceof InvalidPropertyValueDiagnostic && diag.property === 'checks.embeddedPackages')) + } + + it('surfaces plan warnings as non-fatal warning diagnostics', async () => { + const project = setupProject() + Session.embeddedPackages = ['present-*'] + const diagnostics = new Diagnostics() + await project.validate(diagnostics) + const warning = diagnostics.observations.find((diag): diag is WarningDiagnostic => + diag instanceof WarningDiagnostic && diag.title === 'Embedded packages') + expect(warning).toBeDefined() + expect(warning?.message).toContain('present-git') + expect(warning?.isFatal()).toBe(false) + // The wildcard resolves present-pkg, so no fatal issue accompanies it. + expect(diagnostics.observations.filter(diag => + diag instanceof InvalidPropertyValueDiagnostic && diag.property === 'checks.embeddedPackages')).toEqual([]) + }) + + it('maps a missing lockfile to an unsatisfied-prerequisites diagnostic', async () => { + const project = setupProject({ lockfile: null }) + Session.embeddedPackages = ['present-pkg'] + + const observations = await validateEmbeddedDiagnostics(project) + expect(observations).toHaveLength(1) + expect(observations[0]).toBeInstanceOf(UnsatisfiedLocalPrerequisitesDiagnostic) + expect(observations[0].message).toContain('require a lockfile') + }) + + it('maps an unsupported lockfile to an unsatisfied-prerequisites diagnostic', async () => { + const project = setupProject({ lockfile: 'yarn.lock' }) + Session.embeddedPackages = ['present-pkg'] + + const observations = await validateEmbeddedDiagnostics(project) + expect(observations).toHaveLength(1) + expect(observations[0]).toBeInstanceOf(UnsatisfiedLocalPrerequisitesDiagnostic) + expect(observations[0].message).toContain('yarn.lock') + }) + + it('groups multiple spec issues into a single diagnostic', async () => { + const project = setupProject() + Session.embeddedPackages = ['missing-one', 'missing-two'] + + const observations = await validateEmbeddedDiagnostics(project) + expect(observations).toHaveLength(1) + expect(observations[0]).toBeInstanceOf(InvalidPropertyValueDiagnostic) + expect(observations[0].message).toContain('missing-one') + expect(observations[0].message).toContain('missing-two') + }) + + it('accepts specs that resolve against the lockfile', async () => { + const project = setupProject() + Session.embeddedPackages = ['present-pkg'] + + const observations = await validateEmbeddedDiagnostics(project) + expect(observations).toHaveLength(0) + }) + + it('skips validation when the project has no Playwright checks', async () => { + const project = setupProject({ withPlaywrightCheck: false }) + Session.embeddedPackages = ['missing-one'] + + const observations = await validateEmbeddedDiagnostics(project) + expect(observations).toHaveLength(0) + }) +}) + +describe('Session.getEmbeddedPackagesMaterializer()', () => { + afterEach(() => { + Session.reset() + }) + + it('returns undefined without configuration', () => { + expect(Session.getEmbeddedPackagesMaterializer()).toBeUndefined() + Session.embeddedPackages = [] + expect(Session.getEmbeddedPackagesMaterializer()).toBeUndefined() + }) + + it('memoizes the instance and reset() clears it', () => { + Session.embeddedPackages = ['some-pkg'] + const first = Session.getEmbeddedPackagesMaterializer() + expect(first).toBeDefined() + expect(Session.getEmbeddedPackagesMaterializer()).toBe(first) + + Session.reset() + expect(Session.embeddedPackagesMaterializer).toBeUndefined() + }) +}) diff --git a/packages/cli/src/constructs/project.ts b/packages/cli/src/constructs/project.ts index 271db5335..9ed341f9f 100644 --- a/packages/cli/src/constructs/project.ts +++ b/packages/cli/src/constructs/project.ts @@ -6,10 +6,15 @@ import { Construct } from './construct.js' import { Check, AlertChannelSubscription, AlertChannel, CheckGroup, MaintenanceWindow, Dashboard, PrivateLocation, HeartbeatMonitor, PrivateLocationCheckAssignment, PrivateLocationGroupAssignment, - StatusPage, StatusPageService, + StatusPage, StatusPageService, PlaywrightCheck, } from './/index.js' -import { Diagnostics } from './diagnostics.js' -import { ConstructDiagnostic, ConstructDiagnostics, InvalidPropertyValueDiagnostic } from './construct-diagnostics.js' +import { Diagnostics, WarningDiagnostic } from './diagnostics.js' +import { + ConstructDiagnostic, + ConstructDiagnostics, + InvalidPropertyValueDiagnostic, + UnsatisfiedLocalPrerequisitesDiagnostic, +} from './construct-diagnostics.js' import { ProjectBundle, ProjectDataBundle } from './project-bundle.js' import { Bundler } from '../services/check-parser/bundler.js' import { Session } from './session.js' @@ -110,6 +115,64 @@ export class Project extends Construct { ) diagnostics.extend(...constructDiagnostics) + + await this.#validateEmbeddedPackages(diagnostics) + } + + /** + * Validates the project-wide `checks.embeddedPackages` option once per + * project (individual checks share the session-level materializer). Only + * local checks run here — resolving the configured specs against the + * lockfile — no tarballs are fetched until bundling. Skipped when the + * project has no Playwright checks: the option only affects Playwright + * code bundles, and no bundling (or materialization) happens without one. + * Deliberately ignores testOnly flags and the session check filter — a + * configuration problem should surface even on a run that happens to + * filter out every Playwright check. + */ + async #validateEmbeddedPackages (diagnostics: Diagnostics): Promise { + const materializer = Session.getEmbeddedPackagesMaterializer() + if (materializer === undefined) { + return + } + + const hasPlaywrightChecks = Object.values(this.data.check) + .some(check => check instanceof PlaywrightCheck) + if (!hasPlaywrightChecks) { + return + } + + const { issues, warnings } = await materializer.plan() + + for (const warning of warnings) { + diagnostics.add(new WarningDiagnostic({ + title: 'Embedded packages', + message: warning, + })) + } + + // A large monorepo can legitimately embed dozens of packages, so a + // stale config could produce dozens of issues; keep the output + // readable by grouping the per-entry issues into one diagnostic. + const lockfileIssues = issues.filter(issue => + issue.type === 'missing-lockfile' || issue.type === 'unsupported-lockfile') + const specIssues = issues.filter(issue => !lockfileIssues.includes(issue)) + + for (const issue of lockfileIssues) { + diagnostics.add(new UnsatisfiedLocalPrerequisitesDiagnostic(new Error(issue.message))) + } + + if (specIssues.length === 1) { + diagnostics.add(new InvalidPropertyValueDiagnostic('checks.embeddedPackages', new Error(specIssues[0].message))) + } else if (specIssues.length > 1) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'checks.embeddedPackages', + new Error( + `${specIssues.length} entries have problems:\n\n` + + specIssues.map(issue => ` - ${issue.message}`).join('\n'), + ), + )) + } } allowTestOnly (enabled: boolean) { diff --git a/packages/cli/src/constructs/session.ts b/packages/cli/src/constructs/session.ts index 1af9d900f..742042409 100644 --- a/packages/cli/src/constructs/session.ts +++ b/packages/cli/src/constructs/session.ts @@ -18,6 +18,7 @@ import { Workspace } from '../services/check-parser/package-files/workspace.js' import { npmPackageManager, PackageManager } from '../services/check-parser/package-files/package-manager.js' import { Err, Result } from '../services/check-parser/package-files/result.js' import { Runtime } from '../runtimes/index.js' +import { EmbeddedPackagesMaterializer } from '../services/embedded-packages/materializer.js' import { PlaywrightProjectBundler } from '../services/playwright-project-bundler.js' import { PROJECT_CONSTRUCT_TYPE } from '../constants.js' @@ -70,8 +71,10 @@ export class Session { static privateLocations: PrivateLocationApi[] static parsers = new Map() static playwrightProjectBundler?: PlaywrightProjectBundler + static embeddedPackagesMaterializer?: EmbeddedPackagesMaterializer static constructExports: ConstructExport[] = [] static ignoreDirectoriesMatch: string[] = [] + static embeddedPackages?: string[] static warnOnWebServerConfig?: boolean static packageManager: PackageManager = npmPackageManager static workspace: Result = Err(new Error(`Workspace support not initialized`)) @@ -96,8 +99,10 @@ export class Session { this.privateLocations = [] this.parsers = new Map() this.playwrightProjectBundler = undefined + this.embeddedPackagesMaterializer = undefined this.constructExports = [] this.ignoreDirectoriesMatch = [] + this.embeddedPackages = undefined this.warnOnWebServerConfig = false this.packageManager = npmPackageManager this.workspace = Err(new Error(`Workspace support not initialized`)) @@ -228,6 +233,27 @@ export class Session { return this.playwrightProjectBundler } + /** + * The materializer for the project's `checks.embeddedPackages` option, or + * undefined when the option is not set. Memoized so that validation and + * every concurrently bundling check share one plan and one download run. + */ + static getEmbeddedPackagesMaterializer (): EmbeddedPackagesMaterializer | undefined { + const specs = this.embeddedPackages + if (specs === undefined || specs.length === 0) { + return undefined + } + if (this.embeddedPackagesMaterializer === undefined) { + this.embeddedPackagesMaterializer = new EmbeddedPackagesMaterializer({ + specs, + lockfilePath: this.workspace.ok()?.lockfile.ok(), + workspaceRoot: this.basePath, + contextDir: this.contextPath, + }) + } + return this.embeddedPackagesMaterializer + } + static relativePosixPath (filePath: string): string { return pathToPosix(path.relative(Session.basePath!, filePath)) } diff --git a/packages/cli/src/services/__tests__/checkly-config-loader.spec.ts b/packages/cli/src/services/__tests__/checkly-config-loader.spec.ts index dc5699be6..c7b84ab63 100644 --- a/packages/cli/src/services/__tests__/checkly-config-loader.spec.ts +++ b/packages/cli/src/services/__tests__/checkly-config-loader.spec.ts @@ -105,6 +105,32 @@ describe('loadChecklyConfig()', () => { ['dependency-cache-version-bad-type.js'], )).rejects.toThrow(`Config field 'caching.dependencyCache.version' must be a string or a safe integer if set`) }) + it('accepts valid checks.embeddedPackages entries', async () => { + const { config } = await loadChecklyConfig( + path.join(__dirname, 'fixtures', 'configs'), + ['embedded-packages-valid.ts'], + ) + expect(config.checks?.embeddedPackages) + .toEqual(['@acme/private-utils', 'legacy-private-pkg@2.1.0', '@acme/*', 'acme-*']) + }) + it('rejects a checks.embeddedPackages that is not an array', async () => { + await expect(loadChecklyConfig( + path.join(__dirname, 'fixtures', 'configs'), + ['embedded-packages-not-array.js'], + )).rejects.toThrow(`Config field 'checks.embeddedPackages' must be an array of strings if set`) + }) + it('rejects a checks.embeddedPackages entry that is not a valid package name', async () => { + await expect(loadChecklyConfig( + path.join(__dirname, 'fixtures', 'configs'), + ['embedded-packages-bad-name.js'], + )).rejects.toThrow(`is not a valid npm package name`) + }) + it('rejects a checks.embeddedPackages entry with a version range', async () => { + await expect(loadChecklyConfig( + path.join(__dirname, 'fixtures', 'configs'), + ['embedded-packages-range-version.js'], + )).rejects.toThrow(`is not an exact semver version`) + }) it('config from absolute path', async () => { const filename = 'good-config.ts' const configFile = `./fixtures/configs/${filename}` diff --git a/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-bad-name.js b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-bad-name.js new file mode 100644 index 000000000..a315b6ec1 --- /dev/null +++ b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-bad-name.js @@ -0,0 +1,11 @@ +// Plain JS on purpose: bypasses the TypeScript type of ChecklyConfig so the +// runtime validation of checks.embeddedPackages is what rejects it. +const config = { + projectName: 'test-config-project', + logicalId: 'test-config-project', + checks: { + embeddedPackages: ['Not A Valid Name'], + }, +} + +export default config diff --git a/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-not-array.js b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-not-array.js new file mode 100644 index 000000000..464fc3a62 --- /dev/null +++ b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-not-array.js @@ -0,0 +1,11 @@ +// Plain JS on purpose: bypasses the TypeScript type of ChecklyConfig so the +// runtime validation of checks.embeddedPackages is what rejects it. +const config = { + projectName: 'test-config-project', + logicalId: 'test-config-project', + checks: { + embeddedPackages: '@acme/private-utils', + }, +} + +export default config diff --git a/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-range-version.js b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-range-version.js new file mode 100644 index 000000000..d184042d2 --- /dev/null +++ b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-range-version.js @@ -0,0 +1,11 @@ +// Plain JS on purpose: bypasses the TypeScript type of ChecklyConfig so the +// runtime validation of checks.embeddedPackages is what rejects it. +const config = { + projectName: 'test-config-project', + logicalId: 'test-config-project', + checks: { + embeddedPackages: ['@acme/private-utils@^2.0.0'], + }, +} + +export default config diff --git a/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-valid.ts b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-valid.ts new file mode 100644 index 000000000..a127b77a2 --- /dev/null +++ b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-valid.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'checkly' + +const config = defineConfig({ + projectName: 'test-config-project', + logicalId: 'test-config-project', + checks: { + embeddedPackages: ['@acme/private-utils', 'legacy-private-pkg@2.1.0', '@acme/*', 'acme-*'], + }, +}) + +export default config diff --git a/packages/cli/src/services/__tests__/project-parser-session.spec.ts b/packages/cli/src/services/__tests__/project-parser-session.spec.ts new file mode 100644 index 000000000..51572ab75 --- /dev/null +++ b/packages/cli/src/services/__tests__/project-parser-session.spec.ts @@ -0,0 +1,53 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest' + +import { parseProject } from '../project-parser.js' +import { Session } from '../../constructs/session.js' + +describe('parseProject() Session plumbing', () => { + let dir: string + + beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-project-parser-')) + await fs.writeFile(path.join(dir, 'package.json'), JSON.stringify({ name: 'empty-project' })) + }) + + afterEach(() => { + Session.reset() + }) + + afterAll(async () => { + await fs.rm(dir, { recursive: true, force: true }) + }) + + it('threads embeddedPackages into Session and reset() clears it', async () => { + await parseProject({ + directory: dir, + projectLogicalId: 'test-project', + projectName: 'Test Project', + availableRuntimes: {}, + defaultRuntimeId: '2025.04', + embeddedPackages: ['@acme/private-utils', 'legacy-private-pkg@2.1.0'], + }) + + expect(Session.embeddedPackages).toEqual(['@acme/private-utils', 'legacy-private-pkg@2.1.0']) + + Session.reset() + expect(Session.embeddedPackages).toBeUndefined() + }) + + it('leaves Session.embeddedPackages undefined when not configured', async () => { + await parseProject({ + directory: dir, + projectLogicalId: 'test-project', + projectName: 'Test Project', + availableRuntimes: {}, + defaultRuntimeId: '2025.04', + }) + + expect(Session.embeddedPackages).toBeUndefined() + }) +}) diff --git a/packages/cli/src/services/checkly-config-loader.ts b/packages/cli/src/services/checkly-config-loader.ts index d9bb91ea8..d25144321 100644 --- a/packages/cli/src/services/checkly-config-loader.ts +++ b/packages/cli/src/services/checkly-config-loader.ts @@ -10,6 +10,7 @@ import { ReporterType } from '../reporters/reporter.js' import { PlaywrightConfig } from '../constructs/playwright-config.js' import { FileLoader } from '../loader/index.js' import { normalizeDependencyCacheVersion } from './check-parser/cache-hash.js' +import { parseEmbeddedPackageSpec } from './embedded-packages/spec.js' export type CheckConfigDefaults = Pick { + const home = path.sep === '/' ? '/home/user' : 'C:\\Users\\user' + + it('makes CHECKLY_CACHE_DIR the sole location', () => { + expect(resolveCacheDirs({ CHECKLY_CACHE_DIR: '/tmp/custom-cache' }, '/proj', 'linux', home)) + .toEqual([path.resolve('/tmp/custom-cache')]) + }) + + it('puts node_modules/.cache/checkly first, backed by the per-user dir', () => { + expect(resolveCacheDirs({}, '/proj', 'linux', home)).toEqual([ + path.join('/proj', 'node_modules', '.cache', 'checkly'), + path.join(home, '.cache', 'checkly'), + ]) + }) + + it('uses Library/Caches on macOS without a project root', () => { + expect(resolveCacheDirs({}, undefined, 'darwin', home)) + .toEqual([path.join(home, 'Library', 'Caches', 'checkly')]) + }) + + it('uses XDG_CACHE_HOME when set without a project root', () => { + expect(resolveCacheDirs({ XDG_CACHE_HOME: '/xdg-cache' }, undefined, 'linux', home)) + .toEqual([path.join('/xdg-cache', 'checkly')]) + }) + + it('falls back to ~/.cache elsewhere without a project root', () => { + expect(resolveCacheDirs({}, undefined, 'linux', home)).toEqual([path.join(home, '.cache', 'checkly')]) + }) +}) + +describe('TarballCache', () => { + let dir: string + let cache: TarballCache + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-tarball-cache-')) + cache = new TarballCache(dir) + }) + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }) + }) + + it('misses on an empty cache', async () => { + await expect(cache.get(integrity)).resolves.toBeUndefined() + }) + + it('round-trips content through put and get', async () => { + const putPath = await cache.put(integrity, content) + await expect(fs.readFile(putPath)).resolves.toEqual(content) + await expect(cache.get(integrity)).resolves.toBe(putPath) + }) + + it('treats a corrupted entry as a miss and removes it', async () => { + const putPath = await cache.put(integrity, content) + await fs.writeFile(putPath, 'corrupted') + await expect(cache.get(integrity)).resolves.toBeUndefined() + await expect(fs.access(putPath)).rejects.toThrow() + }) + + it('rejects put without a supported integrity hash', async () => { + await expect(cache.put('md5-abcdef', content)).rejects.toThrow(/supported integrity hash/) + }) +}) + +describe('lookupNpmCacache()', () => { + let home: string + + beforeEach(async () => { + home = await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-cacache-home-')) + const contentPath = path.join( + home, '.npm', '_cacache', 'content-v2', 'sha512', + sha512Hex.slice(0, 2), sha512Hex.slice(2, 4), sha512Hex.slice(4), + ) + await fs.mkdir(path.dirname(contentPath), { recursive: true }) + await fs.writeFile(contentPath, content) + }) + + afterEach(async () => { + await fs.rm(home, { recursive: true, force: true }) + }) + + it('finds content by sha512 integrity', async () => { + await expect(lookupNpmCacache(integrity, {}, 'linux', home)).resolves.toEqual(content) + }) + + it('honors npm_config_cache', async () => { + const otherCache = path.join(home, 'other-npm-cache') + await fs.cp(path.join(home, '.npm'), otherCache, { recursive: true }) + await fs.rm(path.join(home, '.npm'), { recursive: true }) + await expect(lookupNpmCacache(integrity, { npm_config_cache: otherCache }, 'linux', home)) + .resolves.toEqual(content) + }) + + it('misses for absent content', async () => { + const missing = `sha512-${createHash('sha512').update('other').digest('base64')}` + await expect(lookupNpmCacache(missing, {}, 'linux', home)).resolves.toBeUndefined() + }) + + it('skips sha1-only integrity', async () => { + const sha1 = `sha1-${createHash('sha1').update(content).digest('base64')}` + await expect(lookupNpmCacache(sha1, {}, 'linux', home)).resolves.toBeUndefined() + }) + + it('uses the LOCALAPPDATA npm-cache location on Windows', async () => { + const localAppData = path.join(home, 'AppDataLocal') + const contentPath = path.join( + localAppData, 'npm-cache', '_cacache', 'content-v2', 'sha512', + sha512Hex.slice(0, 2), sha512Hex.slice(2, 4), sha512Hex.slice(4), + ) + await fs.mkdir(path.dirname(contentPath), { recursive: true }) + await fs.writeFile(contentPath, content) + await expect(lookupNpmCacache(integrity, { LOCALAPPDATA: localAppData }, 'win32', home)) + .resolves.toEqual(content) + }) + + it('rejects cacache content that fails integrity verification', async () => { + const contentPath = path.join( + home, '.npm', '_cacache', 'content-v2', 'sha512', + sha512Hex.slice(0, 2), sha512Hex.slice(2, 4), sha512Hex.slice(4), + ) + await fs.writeFile(contentPath, 'tampered') + await expect(lookupNpmCacache(integrity, {}, 'linux', home)).resolves.toBeUndefined() + }) +}) + +describe('TarballCache.default()', () => { + let home: string + let projectRoot: string + + beforeEach(async () => { + home = await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-cache-home-')) + projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-cache-proj-')) + }) + + afterEach(async () => { + await fs.rm(home, { recursive: true, force: true }) + await fs.rm(projectRoot, { recursive: true, force: true }) + }) + + it('writes to node_modules/.cache under the project root', async () => { + const cache = TarballCache.default({}, projectRoot, 'linux', home) + const putPath = await cache.put(integrity, content) + expect(putPath.startsWith( + path.join(projectRoot, 'node_modules', '.cache', 'checkly', 'embedded-packages'), + )).toBe(true) + }) + + it('falls back to the per-user cache when the project location is not writable', async () => { + // A regular file where node_modules would go makes every mkdir under it + // fail deterministically on all platforms. + await fs.writeFile(path.join(projectRoot, 'node_modules'), 'not a directory') + + const cache = TarballCache.default({}, projectRoot, 'linux', home) + const putPath = await cache.put(integrity, content) + expect(putPath.startsWith(path.join(home, '.cache', 'checkly', 'embedded-packages'))).toBe(true) + }) + + it('reads entries from the per-user fallback tier', async () => { + const userCache = TarballCache.default({}, undefined, 'linux', home) + await userCache.put(integrity, content) + + const cache = TarballCache.default({}, projectRoot, 'linux', home) + await expect(cache.get(integrity)).resolves.toBeDefined() + }) + + it('throws an actionable error when no cache location is writable', async () => { + const blocker = path.join(projectRoot, 'blocker') + await fs.writeFile(blocker, 'not a directory') + + const cache = TarballCache.default( + { CHECKLY_CACHE_DIR: path.join(blocker, 'cache') }, projectRoot, 'linux', home, + ) + const error = await cache.put(integrity, content).catch(err => err) + expect(error).toBeInstanceOf(Error) + expect(error.message).toContain('Unable to write the embedded-packages cache') + expect(error.message).toContain(path.join(blocker, 'cache')) + expect(error.message).toContain('CHECKLY_CACHE_DIR') + expect(error.cause).toBeDefined() + }) +}) diff --git a/packages/cli/src/services/embedded-packages/__tests__/integrity.spec.ts b/packages/cli/src/services/embedded-packages/__tests__/integrity.spec.ts new file mode 100644 index 000000000..22272b120 --- /dev/null +++ b/packages/cli/src/services/embedded-packages/__tests__/integrity.spec.ts @@ -0,0 +1,65 @@ +import { createHash } from 'node:crypto' + +import { describe, it, expect } from 'vitest' + +import { integrityHashToHex, parseIntegrity, strongestIntegrityHash, verifyIntegrity } from '../integrity.js' + +const content = Buffer.from('fake tarball content') +const sha512 = `sha512-${createHash('sha512').update(content).digest('base64')}` +const sha1 = `sha1-${createHash('sha1').update(content).digest('base64')}` + +describe('parseIntegrity()', () => { + it('parses a single sha512 entry', () => { + expect(parseIntegrity(sha512)).toEqual([ + { algorithm: 'sha512', digestBase64: sha512.slice('sha512-'.length) }, + ]) + }) + + it('parses multiple space-separated entries', () => { + expect(parseIntegrity(`${sha1} ${sha512}`)).toHaveLength(2) + }) + + it('skips unsupported algorithms', () => { + expect(parseIntegrity(`md5-abcdef ${sha512}`)).toHaveLength(1) + }) + + it('returns nothing for garbage', () => { + expect(parseIntegrity('not-sri at all')).toEqual([]) + }) +}) + +describe('strongestIntegrityHash()', () => { + it('prefers sha512 over sha1 regardless of order', () => { + expect(strongestIntegrityHash(`${sha1} ${sha512}`)?.algorithm).toBe('sha512') + expect(strongestIntegrityHash(`${sha512} ${sha1}`)?.algorithm).toBe('sha512') + }) + + it('returns undefined when no supported hash exists', () => { + expect(strongestIntegrityHash('md5-abcdef')).toBeUndefined() + }) +}) + +describe('verifyIntegrity()', () => { + it('accepts matching sha512 content', () => { + expect(verifyIntegrity(content, sha512)).toBe(true) + }) + + it('accepts matching sha1 content', () => { + expect(verifyIntegrity(content, sha1)).toBe(true) + }) + + it('rejects tampered content', () => { + expect(verifyIntegrity(Buffer.from('tampered'), sha512)).toBe(false) + }) + + it('rejects unsupported integrity strings', () => { + expect(verifyIntegrity(content, 'md5-abcdef')).toBe(false) + }) +}) + +describe('integrityHashToHex()', () => { + it('round-trips base64 to hex', () => { + const hash = strongestIntegrityHash(sha512)! + expect(integrityHashToHex(hash)).toBe(createHash('sha512').update(content).digest('hex')) + }) +}) diff --git a/packages/cli/src/services/embedded-packages/__tests__/lockfile-packages.spec.ts b/packages/cli/src/services/embedded-packages/__tests__/lockfile-packages.spec.ts new file mode 100644 index 000000000..6a27a43c9 --- /dev/null +++ b/packages/cli/src/services/embedded-packages/__tests__/lockfile-packages.spec.ts @@ -0,0 +1,323 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { describe, it, expect } from 'vitest' + +import { + UnsupportedLockfileError, + loadLockfilePackages, + parseNpmLockfilePackages, + parsePnpmLockfilePackages, +} from '../lockfile-packages.js' + +describe('parsePnpmLockfilePackages()', () => { + it('parses v9 registry entries', () => { + const { registry, excluded } = parsePnpmLockfilePackages(` +lockfileVersion: '9.0' +packages: + '@acme/foo@1.2.3': + resolution: {integrity: sha512-aaa} + bar@2.0.0: + resolution: {integrity: sha512-bbb} +`) + expect(registry).toEqual([ + { name: '@acme/foo', version: '1.2.3', integrity: 'sha512-aaa', tarballUrl: undefined }, + { name: 'bar', version: '2.0.0', integrity: 'sha512-bbb', tarballUrl: undefined }, + ]) + expect(excluded).toEqual([]) + }) + + it('parses v6 keys with leading slash and peer suffixes, deduplicating', () => { + const { registry } = parsePnpmLockfilePackages(` +lockfileVersion: '6.0' +packages: + /@acme/foo@1.2.3(react@18.2.0): + resolution: {integrity: sha512-aaa} + /@acme/foo@1.2.3(react@17.0.0): + resolution: {integrity: sha512-aaa} +`) + expect(registry).toEqual([ + { name: '@acme/foo', version: '1.2.3', integrity: 'sha512-aaa', tarballUrl: undefined }, + ]) + }) + + it('records a resolution tarball URL when present', () => { + const { registry } = parsePnpmLockfilePackages(` +lockfileVersion: '9.0' +packages: + bar@2.0.0: + resolution: {integrity: sha512-bbb, tarball: https://nexus.local/repository/npm/bar/-/bar-2.0.0.tgz} +`) + expect(registry[0].tarballUrl).toBe('https://nexus.local/repository/npm/bar/-/bar-2.0.0.tgz') + }) + + it('excludes git and file dependencies with a reason', () => { + const { registry, excluded } = parsePnpmLockfilePackages(` +lockfileVersion: '9.0' +packages: + 'foo@https://codeload.github.com/user/foo/tar.gz/abc123': + resolution: {tarball: https://codeload.github.com/user/foo/tar.gz/abc123} + 'baz@file:vendor/baz': + resolution: {directory: vendor/baz, type: directory} +`) + expect(registry).toEqual([]) + expect(excluded).toHaveLength(2) + expect(excluded[0].name).toBe('foo') + expect(excluded[0].reason).toContain('git, file or URL dependency') + }) + + it('keeps the package name intact when a git ref itself contains @', () => { + const { excluded } = parsePnpmLockfilePackages(` +lockfileVersion: '9.0' +packages: + 'foo@git+ssh://git@github.com/user/foo.git#abc123': + resolution: {commit: abc123, repo: git+ssh://git@github.com/user/foo.git} + '@acme/bar@git+ssh://git@github.com/acme/bar.git#def456': + resolution: {commit: def456, repo: git+ssh://git@github.com/acme/bar.git} +`) + expect(excluded.map(entry => entry.name).sort()).toEqual(['@acme/bar', 'foo']) + }) + + it('excludes entries without an integrity hash', () => { + const { registry, excluded } = parsePnpmLockfilePackages(` +lockfileVersion: '9.0' +packages: + bar@2.0.0: + resolution: {} +`) + expect(registry).toEqual([]) + expect(excluded[0].reason).toContain('no integrity hash') + }) + + it('accepts an unquoted lockfileVersion that YAML reads as a number', () => { + const { registry } = parsePnpmLockfilePackages(` +lockfileVersion: 9.0 +packages: + bar@2.0.0: + resolution: {integrity: sha512-bbb} +`) + expect(registry).toHaveLength(1) + }) + + it('falls back to the derived URL for a non-http resolution tarball', () => { + const { registry } = parsePnpmLockfilePackages(` +lockfileVersion: '9.0' +packages: + bar@2.0.0: + resolution: {integrity: sha512-bbb, tarball: file:vendor/bar-2.0.0.tgz} +`) + expect(registry[0].tarballUrl).toBeUndefined() + }) + + it('rejects unsupported lockfile versions', () => { + expect(() => parsePnpmLockfilePackages(`lockfileVersion: 5.4`)).toThrow(UnsupportedLockfileError) + }) +}) + +describe('parseNpmLockfilePackages()', () => { + it('parses v3 registry entries, skipping the root and member paths', () => { + const { registry, excluded } = parseNpmLockfilePackages(JSON.stringify({ + lockfileVersion: 3, + packages: { + '': { name: 'root', version: '1.0.0' }, + 'packages/a': { name: 'member-a', version: '1.0.0' }, + 'node_modules/@acme/foo': { + version: '1.2.3', + resolved: 'https://registry.npmjs.org/@acme/foo/-/foo-1.2.3.tgz', + integrity: 'sha512-aaa', + }, + 'node_modules/a/node_modules/bar': { + version: '2.0.0', + resolved: 'https://registry.npmjs.org/bar/-/bar-2.0.0.tgz', + integrity: 'sha512-bbb', + }, + }, + })) + expect(registry).toEqual([ + { + name: '@acme/foo', + version: '1.2.3', + integrity: 'sha512-aaa', + tarballUrl: 'https://registry.npmjs.org/@acme/foo/-/foo-1.2.3.tgz', + }, + { + name: 'bar', + version: '2.0.0', + integrity: 'sha512-bbb', + tarballUrl: 'https://registry.npmjs.org/bar/-/bar-2.0.0.tgz', + }, + ]) + expect(excluded).toEqual([]) + }) + + it('excludes workspace links, git dependencies and integrity-less entries', () => { + const { registry, excluded } = parseNpmLockfilePackages(JSON.stringify({ + lockfileVersion: 3, + packages: { + 'node_modules/member-a': { resolved: 'packages/a', link: true }, + 'node_modules/git-dep': { version: '1.0.0', resolved: 'git+ssh://git@github.com/user/git-dep.git#abc' }, + 'node_modules/bundled-dep': { version: '3.0.0', inBundle: true }, + }, + })) + expect(registry).toEqual([]) + expect(excluded.map(entry => entry.name).sort()).toEqual(['bundled-dep', 'git-dep', 'member-a']) + expect(excluded.find(entry => entry.name === 'member-a')?.reason).toContain('workspace link') + expect(excluded.find(entry => entry.name === 'bundled-dep')?.reason).toContain('no integrity hash') + }) + + it('does not let an integrity-less duplicate shadow a real registry entry', () => { + const { registry } = parseNpmLockfilePackages(JSON.stringify({ + lockfileVersion: 3, + packages: { + // A nested bundled copy without integrity sorts before the real + // hoisted entry of the same name@version. + 'node_modules/a/node_modules/dep': { version: '1.0.0', inBundle: true }, + 'node_modules/dep': { + version: '1.0.0', + resolved: 'https://registry.npmjs.org/dep/-/dep-1.0.0.tgz', + integrity: 'sha512-ddd', + }, + }, + })) + expect(registry).toEqual([ + { + name: 'dep', + version: '1.0.0', + integrity: 'sha512-ddd', + tarballUrl: 'https://registry.npmjs.org/dep/-/dep-1.0.0.tgz', + }, + ]) + }) + + it('uses the real package name for aliased installs', () => { + const { registry } = parseNpmLockfilePackages(JSON.stringify({ + lockfileVersion: 3, + packages: { + 'node_modules/my-alias': { + name: 'real-package', + version: '1.0.0', + resolved: 'https://registry.npmjs.org/real-package/-/real-package-1.0.0.tgz', + integrity: 'sha512-ccc', + }, + }, + })) + expect(registry[0].name).toBe('real-package') + }) + + it('rejects v1 lockfiles', () => { + expect(() => parseNpmLockfilePackages(JSON.stringify({ lockfileVersion: 1 }))) + .toThrow(UnsupportedLockfileError) + }) +}) + +describe('parsePnpmLockfilePackages() workspace links', () => { + it('records workspace-linked packages as excluded with a precise reason', () => { + const { registry, excluded } = parsePnpmLockfilePackages(` +lockfileVersion: '9.0' +importers: + .: + dependencies: + '@acme/shared': + specifier: workspace:* + version: link:packages/shared +packages: + bar@2.0.0: + resolution: {integrity: sha512-bbb} +`) + expect(registry).toHaveLength(1) + expect(excluded).toEqual([ + { + name: '@acme/shared', + reason: `'@acme/shared' is a workspace package, which cannot be embedded as a registry tarball`, + kind: 'workspace', + }, + ]) + }) +}) + +describe('parsePnpmLockfilePackages() outside links', () => { + it('distinguishes workspace links from links escaping the workspace', () => { + const { excluded } = parsePnpmLockfilePackages(` +lockfileVersion: '9.0' +importers: + .: + dependencies: + '@acme/member': + specifier: workspace:* + version: link:packages/member + '@acme/outside': + specifier: file:../elsewhere + version: link:../elsewhere +packages: {} +`) + expect(excluded.map(entry => ({ name: entry.name, kind: entry.kind }))).toEqual([ + { name: '@acme/member', kind: 'workspace' }, + { name: '@acme/outside', kind: 'unfetchable' }, + ]) + }) +}) + +describe('parseNpmLockfilePackages() links', () => { + it('distinguishes workspace links from links escaping the workspace', () => { + const { excluded } = parseNpmLockfilePackages(JSON.stringify({ + lockfileVersion: 3, + packages: { + 'node_modules/@acme/member': { link: true, resolved: 'packages/member' }, + 'node_modules/@acme/outside': { link: true, resolved: '../elsewhere/outside' }, + }, + })) + expect(excluded.map(entry => ({ name: entry.name, kind: entry.kind }))).toEqual([ + { name: '@acme/member', kind: 'workspace' }, + { name: '@acme/outside', kind: 'unfetchable' }, + ]) + }) +}) + +describe('build metadata in versions', () => { + it('keeps build metadata as recorded in the lockfile', () => { + const pnpm = parsePnpmLockfilePackages(` +lockfileVersion: '9.0' +packages: + 'meta-pkg@1.0.0+sha.abcdef': + resolution: {integrity: sha512-eee} +`) + expect(pnpm.registry[0].version).toBe('1.0.0+sha.abcdef') + + const npm = parseNpmLockfilePackages(JSON.stringify({ + lockfileVersion: 3, + packages: { + 'node_modules/meta-pkg': { + version: '1.0.0+sha.abcdef', + resolved: 'https://registry.npmjs.org/meta-pkg/-/meta-pkg-1.0.0+sha.abcdef.tgz', + integrity: 'sha512-eee', + }, + }, + })) + expect(npm.registry[0].version).toBe('1.0.0+sha.abcdef') + }) +}) + +describe('loadLockfilePackages()', () => { + it('dispatches package-lock.json to the npm parser', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-lockfile-')) + try { + const lockfilePath = path.join(dir, 'package-lock.json') + await fs.writeFile(lockfilePath, JSON.stringify({ + lockfileVersion: 3, + packages: { + 'node_modules/bar': { + version: '2.0.0', + resolved: 'https://registry.npmjs.org/bar/-/bar-2.0.0.tgz', + integrity: 'sha512-bbb', + }, + }, + })) + const { registry } = await loadLockfilePackages(lockfilePath) + expect(registry).toHaveLength(1) + expect(registry[0].name).toBe('bar') + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts b/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts new file mode 100644 index 000000000..c4b5bd174 --- /dev/null +++ b/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts @@ -0,0 +1,574 @@ +import { createHash } from 'node:crypto' +import fs from 'node:fs/promises' +import http from 'node:http' +import os from 'node:os' +import path from 'node:path' +import { AddressInfo } from 'node:net' + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' + +import { EmbeddedPackageError, EmbeddedPackagesMaterializer } from '../materializer.js' + +const fooTarball = Buffer.from('fake tarball content for @acme/foo') +const fooIntegrity = `sha512-${createHash('sha512').update(fooTarball).digest('base64')}` +const barTarball = Buffer.from('fake tarball content for bar') +const barIntegrity = `sha512-${createHash('sha512').update(barTarball).digest('base64')}` + +function lockfileContent (): string { + return ` +lockfileVersion: '9.0' +packages: + '@acme/foo@1.2.3': + resolution: {integrity: ${fooIntegrity}} + bar@2.0.0: + resolution: {integrity: ${barIntegrity}} + bar@3.0.0: + resolution: {integrity: ${barIntegrity}} + 'git-dep@https://codeload.github.com/user/git-dep/tar.gz/abc123': + resolution: {tarball: https://codeload.github.com/user/git-dep/tar.gz/abc123} +` +} + +async function captureStderr (fn: () => Promise): Promise { + const written: string[] = [] + const original = process.stderr.write.bind(process.stderr) + process.stderr.write = ((chunk: string) => { + written.push(String(chunk)) + return true + }) as never + try { + await fn() + } finally { + process.stderr.write = original + } + return written +} + +describe('EmbeddedPackagesMaterializer', () => { + let workspaceRoot: string + let homedir: string + let cacheDir: string + let lockfilePath: string + let server: http.Server + let serverUrl: string + let requests: Array<{ url: string, authorization?: string, acceptEncoding?: string }> + + const makeMaterializer = (specs: string[], overrides: Record = {}) => { + return new EmbeddedPackagesMaterializer({ + specs, + lockfilePath, + workspaceRoot, + env: { CHECKLY_CACHE_DIR: cacheDir }, + homedir, + ...overrides, + }) + } + + beforeEach(async () => { + workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-embed-ws-')) + homedir = await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-embed-home-')) + cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-embed-cache-')) + lockfilePath = path.join(workspaceRoot, 'pnpm-lock.yaml') + await fs.writeFile(lockfilePath, lockfileContent()) + + requests = [] + server = http.createServer((req, res) => { + requests.push({ + url: req.url!, + authorization: req.headers.authorization, + acceptEncoding: req.headers['accept-encoding'] as string | undefined, + }) + if (req.url === '/@acme/foo/-/foo-1.2.3.tgz') { + res.end(fooTarball) + } else if (req.url === '/bar/-/bar-2.0.0.tgz') { + res.end(barTarball) + } else if (req.url === '/bar/-/bar-3.0.0.tgz') { + res.end(barTarball) + } else if (req.url === '/secured/-/secured-1.0.0.tgz' && req.headers.authorization !== 'Bearer secret') { + res.statusCode = 401 + res.end('unauthorized') + } else { + res.statusCode = 404 + res.end('not found') + } + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const { address, port } = server.address() as AddressInfo + serverUrl = `http://${address}:${port}/` + await fs.writeFile(path.join(workspaceRoot, '.npmrc'), `registry=${serverUrl}\n`) + }) + + afterEach(async () => { + await new Promise((resolve, reject) => server.close(err => err ? reject(err) : resolve())) + for (const dir of [workspaceRoot, homedir, cacheDir]) { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + + describe('plan()', () => { + it('resolves a bare name to every lockfile version', async () => { + const { tarballs, issues } = await makeMaterializer(['bar']).plan() + expect(issues).toEqual([]) + expect(tarballs.map(t => t.archiveFilename)).toEqual(['bar@2.0.0.tgz', 'bar@3.0.0.tgz']) + }) + + it('resolves a name@version pin to that version only', async () => { + const { tarballs, issues } = await makeMaterializer(['bar@2.0.0']).plan() + expect(issues).toEqual([]) + expect(tarballs.map(t => t.archiveFilename)).toEqual(['bar@2.0.0.tgz']) + }) + + it('deduplicates overlapping specs', async () => { + const { tarballs } = await makeMaterializer(['bar', 'bar@2.0.0']).plan() + expect(tarballs.map(t => t.archiveFilename)).toEqual(['bar@2.0.0.tgz', 'bar@3.0.0.tgz']) + }) + + it('resolves a scope wildcard to every matching package and version', async () => { + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +packages: + '@acme/foo@1.2.3': + resolution: {integrity: ${fooIntegrity}} + '@acme/foo-utils@2.0.0': + resolution: {integrity: ${barIntegrity}} + '@other/pkg@1.0.0': + resolution: {integrity: ${barIntegrity}} + bar@2.0.0: + resolution: {integrity: ${barIntegrity}} +`) + const { tarballs, issues } = await makeMaterializer(['@acme/*']).plan() + expect(issues).toEqual([]) + expect(tarballs.map(t => t.archiveFilename).sort()).toEqual([ + '@acme+foo-utils@2.0.0.tgz', + '@acme+foo@1.2.3.tgz', + ]) + }) + + it('resolves prefix and suffix wildcards against unscoped names only', async () => { + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +packages: + acme-utils@1.0.0: + resolution: {integrity: ${barIntegrity}} + acme-core@1.0.0: + resolution: {integrity: ${barIntegrity}} + '@acme/acme-extra@1.0.0': + resolution: {integrity: ${barIntegrity}} +`) + const { tarballs, issues } = await makeMaterializer(['acme-*']).plan() + expect(issues).toEqual([]) + // The wildcard does not cross the scope separator, so the scoped + // package stays out even though its name part matches. + expect(tarballs.map(t => t.archiveFilename).sort()).toEqual([ + 'acme-core@1.0.0.tgz', + 'acme-utils@1.0.0.tgz', + ]) + }) + + it('filters wildcard matches by an exact version pin', async () => { + const { tarballs, issues } = await makeMaterializer(['ba*@2.0.0']).plan() + expect(issues).toEqual([]) + expect(tarballs.map(t => t.archiveFilename)).toEqual(['bar@2.0.0.tgz']) + }) + + it('reports a wildcard that matches nothing in the lockfile', async () => { + const { issues } = await makeMaterializer(['@nomatch/*']).plan() + expect(issues).toHaveLength(1) + expect(issues[0].type).toBe('spec-not-found') + expect(issues[0].message).toContain('pattern matches') + }) + + it('reports a wildcard that only matches workspace packages', async () => { + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +importers: + .: + dependencies: + '@acme/shared': + specifier: workspace:* + version: link:packages/shared +packages: {} +`) + const { issues } = await makeMaterializer(['@acme/*']).plan() + expect(issues).toHaveLength(1) + expect(issues[0].type).toBe('spec-not-embeddable') + expect(issues[0].message).toContain('workspace package') + }) + + it('silently skips workspace packages a wildcard also matches', async () => { + // The monorepo case: the scope holds both registry packages and + // workspace members. The wildcard embeds the former and skips the + // latter without erroring. + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +importers: + .: + dependencies: + '@acme/shared': + specifier: workspace:* + version: link:packages/shared +packages: + '@acme/foo@1.2.3': + resolution: {integrity: ${fooIntegrity}} +`) + const { tarballs, issues } = await makeMaterializer(['@acme/*']).plan() + expect(issues).toEqual([]) + expect(tarballs.map(t => t.archiveFilename)).toEqual(['@acme+foo@1.2.3.tgz']) + }) + + it('reports unfetchable wildcard matches as plan warnings and announces matches when materializing', async () => { + // A bare * matches bar (registry, both versions) and git-dep (a git + // dependency the CLI cannot embed): the registry matches embed, the + // git dependency surfaces as a plan warning naming it, and the + // wildcard's selection is announced during materialization. + const materializer = makeMaterializer(['*']) + const { tarballs, issues, warnings } = await materializer.plan() + expect(issues).toEqual([]) + expect(tarballs.map(t => t.archiveFilename)).toEqual(['bar@2.0.0.tgz', 'bar@3.0.0.tgz']) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain('git-dep') + expect(warnings[0]).toContain('cannot be embedded') + const written = await captureStderr(async () => { + await materializer.materialize() + }) + const announcement = written.find(line => line.includes('matched 2 package(s)')) + expect(announcement).toContain(`'*'`) + expect(announcement).toContain('bar@2.0.0') + }) + + it('does not warn about unfetchable matches a version pin already excludes', async () => { + const npmLockfilePath = path.join(workspaceRoot, 'package-lock.json') + await fs.writeFile(npmLockfilePath, JSON.stringify({ + lockfileVersion: 3, + packages: { + 'node_modules/@acme/foo': { + version: '1.2.3', + resolved: 'https://registry.npmjs.org/@acme/foo/-/foo-1.2.3.tgz', + integrity: fooIntegrity, + }, + 'node_modules/@acme/legacy': { + version: '2.0.0', + resolved: 'git+ssh://git@github.com/acme/legacy.git#abc123', + }, + }, + })) + const { warnings, issues } = await makeMaterializer(['@acme/*@1.2.3'], { lockfilePath: npmLockfilePath }).plan() + expect(issues).toEqual([]) + // @acme/legacy@2.0.0 was excluded by the pin, not by embeddability — + // warning about it would send the user chasing a non-issue. + expect(warnings).toEqual([]) + }) + + it('does not warn about integrity-less duplicates of embedded registry entries', async () => { + // npm nests integrity-less bundled copies of packages that also + // exist as proper registry entries; the artifact IS embedded, so + // the duplicate must not surface as a skipped unfetchable match. + const npmLockfilePath = path.join(workspaceRoot, 'package-lock.json') + await fs.writeFile(npmLockfilePath, JSON.stringify({ + lockfileVersion: 3, + packages: { + 'node_modules/dup': { + version: '1.0.0', + resolved: 'https://registry.npmjs.org/dup/-/dup-1.0.0.tgz', + integrity: barIntegrity, + }, + 'node_modules/a/node_modules/dup': { version: '1.0.0', inBundle: true }, + }, + })) + const { tarballs, warnings, issues } = await makeMaterializer(['du*'], { lockfilePath: npmLockfilePath }).plan() + expect(issues).toEqual([]) + expect(warnings).toEqual([]) + expect(tarballs.map(t => t.archiveFilename)).toEqual(['dup@1.0.0.tgz']) + }) + + it('prefers the actionable excluded reason over version blame for an exact pinned spec', async () => { + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +packages: + foo@2.0.0: + resolution: {integrity: ${barIntegrity}} + foo@1.0.0: + resolution: {} +`) + const { issues } = await makeMaterializer(['foo@1.0.0']).plan() + expect(issues).toHaveLength(1) + expect(issues[0].type).toBe('spec-not-embeddable') + expect(issues[0].message).toContain('integrity') + }) + + it('stays silent on stderr for plain specs', async () => { + const materializer = makeMaterializer(['bar@2.0.0']) + const written = await captureStderr(async () => { + const { warnings } = await materializer.plan() + expect(warnings).toEqual([]) + await materializer.materialize() + }) + // Filtered rather than asserting total silence: the debug package + // also writes to stderr when DEBUG is enabled. + expect(written.filter(line => line.includes('Embedded package'))).toEqual([]) + }) + + it('blames the version pin when a wildcard matches names but no version', async () => { + // The workspace link sharing the scope must not be blamed: the + // pattern matched registry names, the pin filtered them out. + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +importers: + .: + dependencies: + '@acme/shared': + specifier: workspace:* + version: link:packages/shared +packages: + '@acme/foo@1.2.3': + resolution: {integrity: ${fooIntegrity}} +`) + const { issues } = await makeMaterializer(['@acme/*@9.9.9']).plan() + expect(issues).toHaveLength(1) + expect(issues[0].type).toBe('spec-not-found') + expect(issues[0].message).toContain('9.9.9') + expect(issues[0].message).not.toContain('workspace') + }) + + it('converts scope slashes for the archive filename', async () => { + const { tarballs } = await makeMaterializer(['@acme/foo']).plan() + expect(tarballs.map(t => t.archiveFilename)).toEqual(['@acme+foo@1.2.3.tgz']) + }) + + it('reports a spec that matches nothing in the lockfile', async () => { + const { issues } = await makeMaterializer(['no-such-package']).plan() + expect(issues).toHaveLength(1) + expect(issues[0].type).toBe('spec-not-found') + expect(issues[0].message).toContain('no-such-package') + }) + + it('reports a version pin that matches nothing in the lockfile', async () => { + const { issues } = await makeMaterializer(['bar@9.9.9']).plan() + expect(issues[0].type).toBe('spec-not-found') + }) + + it('reports a spec that only matches a git dependency', async () => { + const { issues } = await makeMaterializer(['git-dep']).plan() + expect(issues).toHaveLength(1) + expect(issues[0].type).toBe('spec-not-embeddable') + expect(issues[0].message).toContain('git, file or URL dependency') + }) + + it('reports an invalid spec as an issue', async () => { + const { issues } = await makeMaterializer(['Not A Valid Name']).plan() + expect(issues[0].type).toBe('invalid-spec') + expect(issues[0].message).toContain('not a valid npm package name') + }) + + it('reports a workspace package with a precise reason', async () => { + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +importers: + .: + dependencies: + '@acme/shared': + specifier: workspace:* + version: link:packages/shared +packages: {} +`) + const { issues } = await makeMaterializer(['@acme/shared']).plan() + expect(issues[0].type).toBe('spec-not-embeddable') + expect(issues[0].message).toContain('workspace package') + }) + + it('reports a missing lockfile', async () => { + const materializer = makeMaterializer(['bar'], { lockfilePath: undefined }) + const { issues } = await materializer.plan() + expect(issues[0].type).toBe('missing-lockfile') + }) + + it('reports an unsupported lockfile', async () => { + const yarnLockfilePath = path.join(workspaceRoot, 'yarn.lock') + await fs.writeFile(yarnLockfilePath, '') + const { issues } = await makeMaterializer(['bar'], { lockfilePath: yarnLockfilePath }).plan() + expect(issues[0].type).toBe('unsupported-lockfile') + expect(issues[0].message).toContain('yarn.lock') + }) + + it('reports an unparseable lockfile as an issue instead of throwing', async () => { + await fs.writeFile(lockfilePath, [ + 'lockfileVersion:', + '<<<<<<< HEAD', + ` '9.0'`, + '=======', + ` '6.0'`, + '>>>>>>> other-branch', + ].join('\n')) + const { issues } = await makeMaterializer(['bar']).plan() + expect(issues[0].type).toBe('unsupported-lockfile') + expect(issues[0].message).toContain('Failed to read or parse the lockfile') + expect(issues[0].message).toContain(lockfilePath) + }) + }) + + describe('materialize()', () => { + it('downloads tarballs from the registry and verifies them', async () => { + const tarballs = await makeMaterializer(['@acme/foo', 'bar@2.0.0']).materialize() + expect(tarballs.map(t => t.archivePath)).toEqual([ + '.checkly/embedded-packages/@acme+foo@1.2.3.tgz', + '.checkly/embedded-packages/bar@2.0.0.tgz', + ]) + await expect(fs.readFile(tarballs[0].filePath)).resolves.toEqual(fooTarball) + expect(requests.map(r => r.url).sort()).toEqual([ + '/@acme/foo/-/foo-1.2.3.tgz', + '/bar/-/bar-2.0.0.tgz', + ]) + // The raw artifact must be requested: a gzip-labelled response would + // be transparently decompressed and fail integrity verification. + expect(requests.every(r => r.acceptEncoding === 'identity')).toBe(true) + }) + + it('defaults the cache to node_modules/.cache/checkly under the workspace root', async () => { + const tarballs = await makeMaterializer(['bar@2.0.0'], { env: {} }).materialize() + expect(tarballs[0].filePath.startsWith( + path.join(workspaceRoot, 'node_modules', '.cache', 'checkly', 'embedded-packages'), + )).toBe(true) + }) + + it('derives the project root from the lockfile path when no workspace root is given', async () => { + const tarballs = await makeMaterializer(['bar@2.0.0'], { env: {}, workspaceRoot: undefined }).materialize() + expect(tarballs[0].filePath.startsWith(path.join( + path.dirname(lockfilePath), 'node_modules', '.cache', 'checkly', 'embedded-packages', + ))).toBe(true) + }) + + it('reuses the CLI cache instead of downloading again', async () => { + await makeMaterializer(['bar@2.0.0']).materialize() + expect(requests).toHaveLength(1) + await makeMaterializer(['bar@2.0.0']).materialize() + expect(requests).toHaveLength(1) + }) + + it('uses npm cacache content without hitting the network', async () => { + const npmCacheDir = path.join(homedir, '.npm') + const hex = createHash('sha512').update(barTarball).digest('hex') + const contentPath = path.join( + npmCacheDir, '_cacache', 'content-v2', 'sha512', + hex.slice(0, 2), hex.slice(2, 4), hex.slice(4), + ) + await fs.mkdir(path.dirname(contentPath), { recursive: true }) + await fs.writeFile(contentPath, barTarball) + + // Pin the npm cache location: the platform default differs (~/.npm on + // POSIX, %LOCALAPPDATA%\npm-cache on Windows) and the production code + // uses the real process.platform. + const materializer = makeMaterializer(['bar@2.0.0'], { + env: { CHECKLY_CACHE_DIR: cacheDir, npm_config_cache: npmCacheDir }, + }) + const tarballs = await materializer.materialize() + expect(requests).toHaveLength(0) + await expect(fs.readFile(tarballs[0].filePath)).resolves.toEqual(barTarball) + }) + + it('sends npmrc credentials for the registry', async () => { + await fs.writeFile(path.join(workspaceRoot, '.npmrc'), [ + `registry=${serverUrl}`, + `//127.0.0.1:${(server.address() as AddressInfo).port}/:_authToken=secret`, + ].join('\n')) + + await makeMaterializer(['bar@2.0.0']).materialize() + expect(requests[0].authorization).toBe('Bearer secret') + }) + + it('prefers a lockfile-recorded tarball URL over the derived one', async () => { + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +packages: + bar@2.0.0: + resolution: {integrity: ${barIntegrity}, tarball: ${serverUrl}custom/path/bar-2.0.0.tgz} +`) + server.removeAllListeners('request') + server.on('request', (req, res) => { + requests.push({ url: req.url!, authorization: req.headers.authorization }) + res.end(barTarball) + }) + + await makeMaterializer(['bar@2.0.0']).materialize() + expect(requests[0].url).toBe('/custom/path/bar-2.0.0.tgz') + }) + + it('fails with a clear error on an integrity mismatch', async () => { + server.removeAllListeners('request') + server.on('request', (req, res) => res.end('tampered content')) + + await expect(makeMaterializer(['bar@2.0.0']).materialize()) + .rejects.toThrow(/does not match the integrity hash recorded in the lockfile/) + }) + + it('fails with a clear error on a download failure', async () => { + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +packages: + secured@1.0.0: + resolution: {integrity: ${barIntegrity}} +`) + await expect(makeMaterializer(['secured']).materialize()) + .rejects.toThrow(/Failed to download embedded package 'secured@1\.0\.0'.*HTTP 401.*credentials/s) + }) + + it('refuses to materialize when the plan has issues', async () => { + await expect(makeMaterializer(['no-such-package']).materialize()) + .rejects.toThrow(EmbeddedPackageError) + }) + + it('prefers the context directory .npmrc over the workspace root one', async () => { + const contextDir = path.join(workspaceRoot, 'packages', 'a') + await fs.mkdir(contextDir, { recursive: true }) + await fs.writeFile(path.join(workspaceRoot, '.npmrc'), 'registry=http://127.0.0.1:1/\n') + await fs.writeFile(path.join(contextDir, '.npmrc'), `registry=${serverUrl}\n`) + + const tarballs = await makeMaterializer(['bar@2.0.0'], { contextDir }).materialize() + expect(tarballs).toHaveLength(1) + expect(requests).toHaveLength(1) + }) + + it('honors an npm_config_registry environment override', async () => { + await fs.writeFile(path.join(workspaceRoot, '.npmrc'), 'registry=http://127.0.0.1:1/\n') + + const materializer = makeMaterializer(['bar@2.0.0'], { + env: { CHECKLY_CACHE_DIR: cacheDir, npm_config_registry: serverUrl }, + }) + const tarballs = await materializer.materialize() + expect(tarballs).toHaveLength(1) + expect(requests).toHaveLength(1) + }) + + it('fails with a clear error for a registry URL without a protocol', async () => { + await fs.writeFile(path.join(workspaceRoot, '.npmrc'), 'registry=nexus.local/repository/npm/\n') + + await expect(makeMaterializer(['bar@2.0.0']).materialize()) + .rejects.toThrow(/is not a valid URL.*registry/s) + }) + + it('redacts registry credentials from download error messages', async () => { + const { port } = server.address() as AddressInfo + await fs.writeFile( + path.join(workspaceRoot, '.npmrc'), + `registry=http://ci-user:super-secret@127.0.0.1:${port}/\n`, + ) + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +packages: + missing-pkg@1.0.0: + resolution: {integrity: ${barIntegrity}} +`) + + const error = await makeMaterializer(['missing-pkg']).materialize().catch(err => err) + expect(error).toBeInstanceOf(EmbeddedPackageError) + expect(error.message).not.toContain('super-secret') + expect(error.message).toContain('missing-pkg') + }) + + it('memoizes materialization within an instance', async () => { + const materializer = makeMaterializer(['bar@2.0.0']) + const [first, second] = await Promise.all([materializer.materialize(), materializer.materialize()]) + expect(first).toBe(second) + expect(requests).toHaveLength(1) + }) + }) +}) diff --git a/packages/cli/src/services/embedded-packages/__tests__/npmrc.spec.ts b/packages/cli/src/services/embedded-packages/__tests__/npmrc.spec.ts new file mode 100644 index 000000000..8370a4ef1 --- /dev/null +++ b/packages/cli/src/services/embedded-packages/__tests__/npmrc.spec.ts @@ -0,0 +1,205 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { describe, it, expect, beforeAll, afterAll } from 'vitest' + +import { + DEFAULT_REGISTRY_URL, + NpmrcEnvVarError, + defaultNpmrcPaths, + loadNpmrcConfig, + npmrcConfigFromEnv, + parseNpmrc, + resolveAuthHeader, + resolveRegistryUrl, +} from '../npmrc.js' + +describe('parseNpmrc()', () => { + it('parses key=value lines, skipping comments and blanks', () => { + const config = parseNpmrc([ + '# a comment', + '; another comment', + '', + 'registry=https://nexus.local/repository/npm/', + ' @acme:registry = https://nexus.local/repository/npm-private/ ', + '//nexus.local/repository/npm-private/:_authToken=secret-token', + ].join('\n')) + + expect(config.get('registry')).toBe('https://nexus.local/repository/npm/') + expect(config.get('@acme:registry')).toBe('https://nexus.local/repository/npm-private/') + expect(config.get('//nexus.local/repository/npm-private/:_authToken')).toBe('secret-token') + }) + + it('strips matching quotes around values', () => { + expect(parseNpmrc(`registry="https://example.com/"`).get('registry')).toBe('https://example.com/') + }) +}) + +describe('loadNpmrcConfig()', () => { + let dir: string + + beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-npmrc-')) + await fs.writeFile(path.join(dir, 'project.npmrc'), 'registry=https://project.example.com/\n') + await fs.writeFile(path.join(dir, 'user.npmrc'), [ + 'registry=https://user.example.com/', + '//user.example.com/:_authToken=user-token', + ].join('\n')) + }) + + afterAll(async () => { + await fs.rm(dir, { recursive: true, force: true }) + }) + + it('gives earlier files precedence and merges the rest', async () => { + const config = await loadNpmrcConfig([ + path.join(dir, 'project.npmrc'), + path.join(dir, 'user.npmrc'), + ], {}) + expect(config.get('registry')).toBe('https://project.example.com/') + expect(config.get('//user.example.com/:_authToken')).toBe('user-token') + }) + + it('skips missing files', async () => { + const config = await loadNpmrcConfig([ + path.join(dir, 'does-not-exist.npmrc'), + path.join(dir, 'project.npmrc'), + ], {}) + expect(config.get('registry')).toBe('https://project.example.com/') + }) + + it('gives npm_config_* environment variables precedence over files', async () => { + const config = await loadNpmrcConfig( + [path.join(dir, 'project.npmrc')], + { npm_config_registry: 'https://env.example.com/' }, + ) + expect(config.get('registry')).toBe('https://env.example.com/') + }) +}) + +describe('npmrcConfigFromEnv()', () => { + it('extracts npm_config_* keys with a case-insensitive prefix', () => { + const config = npmrcConfigFromEnv({ + npm_config_registry: 'https://env.example.com/', + NPM_CONFIG_STRICT_SSL: 'false', + UNRELATED: 'x', + }) + expect(config.get('registry')).toBe('https://env.example.com/') + expect(config.get('strict_ssl')).toBe('false') + expect(config.has('UNRELATED')).toBe(false) + }) + + it('preserves the case-sensitive spelling of nerf-darted auth keys', () => { + const config = npmrcConfigFromEnv({ + 'npm_config_//nexus.local/:_authToken': 'env-secret', + }) + expect(resolveAuthHeader(config, 'https://nexus.local/foo', {})).toBe('Bearer env-secret') + }) +}) + +describe('defaultNpmrcPaths()', () => { + it('orders context dir before workspace root before home', () => { + expect(defaultNpmrcPaths('/ws', '/home/user', '/ws/packages/a')).toEqual([ + path.join('/ws/packages/a', '.npmrc'), + path.join('/ws', '.npmrc'), + path.join('/home/user', '.npmrc'), + ]) + }) + + it('deduplicates when the context dir is the workspace root', () => { + expect(defaultNpmrcPaths('/ws', '/home/user', '/ws')).toEqual([ + path.join('/ws', '.npmrc'), + path.join('/home/user', '.npmrc'), + ]) + }) +}) + +describe('resolveRegistryUrl()', () => { + it('defaults to the public registry', () => { + expect(resolveRegistryUrl(new Map(), 'some-package')).toBe(DEFAULT_REGISTRY_URL) + }) + + it('uses the registry entry and appends a trailing slash', () => { + const config = parseNpmrc('registry=https://nexus.local/repository/npm') + expect(resolveRegistryUrl(config, 'some-package')).toBe('https://nexus.local/repository/npm/') + }) + + it('prefers a scoped registry for scoped packages', () => { + const config = parseNpmrc([ + 'registry=https://nexus.local/repository/npm/', + '@acme:registry=https://nexus.local/repository/npm-private/', + ].join('\n')) + expect(resolveRegistryUrl(config, '@acme/private-utils')).toBe('https://nexus.local/repository/npm-private/') + expect(resolveRegistryUrl(config, 'some-package')).toBe('https://nexus.local/repository/npm/') + }) + + it('expands ${VAR} references from the environment', () => { + const config = parseNpmrc('registry=${MY_REGISTRY}') + expect(resolveRegistryUrl(config, 'some-package', { MY_REGISTRY: 'https://example.com' })) + .toBe('https://example.com/') + }) + + it('throws a clear error for unset ${VAR} references', () => { + const config = parseNpmrc('registry=${MY_UNSET_REGISTRY}') + expect(() => resolveRegistryUrl(config, 'some-package', {})).toThrow(NpmrcEnvVarError) + }) + + it('ignores unset ${VAR} references in entries that are not used', () => { + const config = parseNpmrc([ + 'registry=https://nexus.local/repository/npm/', + '//unrelated.example.com/:_authToken=${SOME_UNSET_TOKEN}', + ].join('\n')) + expect(resolveRegistryUrl(config, 'some-package', {})).toBe('https://nexus.local/repository/npm/') + expect(resolveAuthHeader(config, 'https://nexus.local/repository/npm/foo', {})).toBeUndefined() + }) +}) + +describe('resolveAuthHeader()', () => { + it('matches an _authToken by nerf dart', () => { + const config = parseNpmrc('//nexus.local/repository/npm-private/:_authToken=secret') + const header = resolveAuthHeader( + config, + 'https://nexus.local/repository/npm-private/@acme/foo/-/foo-1.0.0.tgz', + {}, + ) + expect(header).toBe('Bearer secret') + }) + + it('walks the URL path upward to find host-level credentials', () => { + const config = parseNpmrc('//nexus.local/:_authToken=host-secret') + const header = resolveAuthHeader(config, 'https://nexus.local/repository/npm/foo/-/foo-1.0.0.tgz', {}) + expect(header).toBe('Bearer host-secret') + }) + + it('includes the port in the nerf dart', () => { + const config = parseNpmrc('//nexus.local:8443/:_authToken=port-secret') + expect(resolveAuthHeader(config, 'https://nexus.local:8443/foo/-/foo-1.0.0.tgz', {})).toBe('Bearer port-secret') + expect(resolveAuthHeader(config, 'https://nexus.local/foo/-/foo-1.0.0.tgz', {})).toBeUndefined() + }) + + it('supports pre-encoded _auth as Basic', () => { + const config = parseNpmrc('//nexus.local/:_auth=dXNlcjpwYXNz') + expect(resolveAuthHeader(config, 'https://nexus.local/foo', {})).toBe('Basic dXNlcjpwYXNz') + }) + + it('supports username and base64 _password as Basic', () => { + const config = parseNpmrc([ + '//nexus.local/:username=user', + `//nexus.local/:_password=${Buffer.from('pass').toString('base64')}`, + ].join('\n')) + expect(resolveAuthHeader(config, 'https://nexus.local/foo', {})) + .toBe(`Basic ${Buffer.from('user:pass').toString('base64')}`) + }) + + it('expands ${VAR} tokens from the environment', () => { + const config = parseNpmrc('//nexus.local/:_authToken=${NPM_TOKEN}') + expect(resolveAuthHeader(config, 'https://nexus.local/foo', { NPM_TOKEN: 'env-secret' })) + .toBe('Bearer env-secret') + }) + + it('returns undefined without matching credentials', () => { + const config = parseNpmrc('//other.example.com/:_authToken=secret') + expect(resolveAuthHeader(config, 'https://nexus.local/foo', {})).toBeUndefined() + }) +}) diff --git a/packages/cli/src/services/embedded-packages/__tests__/spec.spec.ts b/packages/cli/src/services/embedded-packages/__tests__/spec.spec.ts new file mode 100644 index 000000000..fa9c5a8e3 --- /dev/null +++ b/packages/cli/src/services/embedded-packages/__tests__/spec.spec.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from 'vitest' + +import { parseEmbeddedPackageSpec, InvalidEmbeddedPackageSpecError, specMatchesPackageName } from '../spec.js' + +describe('parseEmbeddedPackageSpec()', () => { + it('parses a bare package name', () => { + expect(parseEmbeddedPackageSpec('some-package')).toEqual({ + raw: 'some-package', + name: 'some-package', + version: undefined, + }) + }) + + it('parses a scoped package name', () => { + expect(parseEmbeddedPackageSpec('@acme/private-utils')).toEqual({ + raw: '@acme/private-utils', + name: '@acme/private-utils', + version: undefined, + }) + }) + + it('parses a name@version pin', () => { + expect(parseEmbeddedPackageSpec('some-package@2.1.0')).toEqual({ + raw: 'some-package@2.1.0', + name: 'some-package', + version: '2.1.0', + }) + }) + + it('parses a scoped name@version pin', () => { + expect(parseEmbeddedPackageSpec('@acme/private-utils@1.0.0-beta.3')).toEqual({ + raw: '@acme/private-utils@1.0.0-beta.3', + name: '@acme/private-utils', + version: '1.0.0-beta.3', + }) + }) + + it('normalizes a v-prefixed version', () => { + expect(parseEmbeddedPackageSpec('some-package@v2.1.0').version).toBe('2.1.0') + }) + + it('accepts legacy package names with uppercase letters', () => { + expect(parseEmbeddedPackageSpec('JSONStream').name).toBe('JSONStream') + expect(parseEmbeddedPackageSpec('@acme/AuthClient@1.0.0')).toEqual({ + raw: '@acme/AuthClient@1.0.0', + name: '@acme/AuthClient', + version: '1.0.0', + }) + }) + + it('preserves build metadata in a pinned version', () => { + expect(parseEmbeddedPackageSpec('some-package@1.0.0+build.7').version).toBe('1.0.0+build.7') + }) + + it('trims whitespace around a pinned version', () => { + expect(parseEmbeddedPackageSpec('some-package@ 2.1.0 ').version).toBe('2.1.0') + }) + + it('rejects an empty string', () => { + expect(() => parseEmbeddedPackageSpec('')).toThrow(InvalidEmbeddedPackageSpecError) + }) + + it('rejects a non-string value', () => { + expect(() => parseEmbeddedPackageSpec(42 as any)).toThrow(InvalidEmbeddedPackageSpecError) + }) + + it('rejects an invalid package name', () => { + expect(() => parseEmbeddedPackageSpec('Not A Valid Name')).toThrow(/not a valid npm package name/) + }) + + it('rejects a bare scope', () => { + expect(() => parseEmbeddedPackageSpec('@acme')).toThrow(/not a valid npm package name/) + }) + + it('rejects a version range', () => { + expect(() => parseEmbeddedPackageSpec('some-package@^2.0.0')).toThrow(/not an exact semver version/) + }) + + it('rejects a dist-tag as version', () => { + expect(() => parseEmbeddedPackageSpec('some-package@latest')).toThrow(/not an exact semver version/) + }) +}) + +describe('wildcard specs', () => { + const parse = parseEmbeddedPackageSpec + const matches = (raw: string, name: string) => specMatchesPackageName(parse(raw), name) + + it('parses a scope wildcard', () => { + const spec = parse('@checkly/*') + expect(spec.name).toBe('@checkly/*') + expect(spec.namePattern).toBeDefined() + expect(spec.version).toBeUndefined() + }) + + it('leaves plain specs without a pattern', () => { + expect(parse('@checkly/foo').namePattern).toBeUndefined() + }) + + it('matches every package in a scope', () => { + expect(matches('@checkly/*', '@checkly/foo')).toBe(true) + expect(matches('@checkly/*', '@checkly/foo-bar')).toBe(true) + expect(matches('@checkly/*', '@other/foo')).toBe(false) + expect(matches('@checkly/*', 'checkly')).toBe(false) + }) + + it('matches unscoped prefixes and suffixes', () => { + expect(matches('checkly-*', 'checkly-utils')).toBe(true) + expect(matches('checkly-*', 'checkly')).toBe(false) + expect(matches('*-utils', 'checkly-utils')).toBe(true) + expect(matches('*-utils', 'utils')).toBe(false) + }) + + it('matches infix wildcards inside a scope', () => { + expect(matches('@checkly/foo-*', '@checkly/foo-bar')).toBe(true) + expect(matches('@checkly/foo-*', '@checkly/foobar')).toBe(false) + expect(matches('@checkly/*-foo', '@checkly/bar-foo')).toBe(true) + expect(matches('@checkly/*-foo', '@checkly/foo')).toBe(false) + }) + + it('never crosses the scope separator', () => { + expect(matches('*', 'unscoped')).toBe(true) + expect(matches('*', '@checkly/foo')).toBe(false) + expect(matches('checkly-*', '@checkly/x')).toBe(false) + }) + + it('does not treat other regex characters as special', () => { + expect(matches('@checkly/foo.*', '@checkly/fooXbar')).toBe(false) + expect(matches('@checkly/foo.*', '@checkly/foo.bar')).toBe(true) + }) + + it('combines a wildcard with an exact version pin', () => { + const spec = parse('@checkly/*@1.2.3') + expect(spec.namePattern).toBeDefined() + expect(spec.version).toBe('1.2.3') + }) + + it('collapses consecutive wildcards, avoiding pathological backtracking', () => { + expect(matches('a**b', 'axb')).toBe(true) + expect(matches('a**b', 'ab')).toBe(true) + // A long scoped mismatch resolves instantly rather than backtracking. + expect(matches('*'.repeat(20), `@${'x'.repeat(120)}/pkg`)).toBe(false) + }) + + it('rejects a wildcard that is not name-shaped', () => { + expect(() => parse('@/*')).toThrow(/not a valid npm package name pattern/) + }) +}) diff --git a/packages/cli/src/services/embedded-packages/cache.ts b/packages/cli/src/services/embedded-packages/cache.ts new file mode 100644 index 000000000..f12fb0bc8 --- /dev/null +++ b/packages/cli/src/services/embedded-packages/cache.ts @@ -0,0 +1,219 @@ +import { randomUUID } from 'node:crypto' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import Debug from 'debug' + +import { IntegrityHash, integrityHashToHex, strongestIntegrityHash, verifyIntegrity } from './integrity.js' + +const debug = Debug('checkly:cli:services:embedded-packages') + +function platformCacheDir ( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, + homedir: string, +): string { + switch (platform) { + case 'darwin': + return path.join(homedir, 'Library', 'Caches', 'checkly') + case 'win32': { + const localAppData = env.LOCALAPPDATA !== undefined && env.LOCALAPPDATA !== '' + ? env.LOCALAPPDATA + : path.join(homedir, 'AppData', 'Local') + return path.join(localAppData, 'checkly', 'Cache') + } + default: { + const xdgCacheHome = env.XDG_CACHE_HOME + const cacheHome = xdgCacheHome !== undefined && xdgCacheHome !== '' + ? xdgCacheHome + : path.join(homedir, '.cache') + return path.join(cacheHome, 'checkly') + } + } +} + +/** + * The Checkly CLI's cache directories, in precedence order. A + * `CHECKLY_CACHE_DIR` override is the sole location. Otherwise the primary + * is the project-local `node_modules/.cache/checkly` (the conventional + * tool-cache location — incremental installs leave it alone, and CI setups + * that cache `node_modules` persist it automatically), backed by a + * per-user platform cache directory (macOS: `~/Library/Caches/checkly`, + * Windows: `%LOCALAPPDATA%\checkly\Cache`, elsewhere: + * `$XDG_CACHE_HOME/checkly` or `~/.cache/checkly`) that serves as a read + * tier and as the write fallback when the project location isn't writable + * (e.g. a read-only checkout). + */ +export function resolveCacheDirs ( + env: NodeJS.ProcessEnv = process.env, + projectRoot?: string, + platform: NodeJS.Platform = process.platform, + homedir = os.homedir(), +): string[] { + const override = env.CHECKLY_CACHE_DIR + if (override !== undefined && override !== '') { + return [path.resolve(override)] + } + + const dirs = [] + if (projectRoot !== undefined) { + dirs.push(path.join(projectRoot, 'node_modules', '.cache', 'checkly')) + } + dirs.push(platformCacheDir(env, platform, homedir)) + return [...new Set(dirs)] +} + +/** + * A content-addressed store of package tarballs, keyed by the lockfile's + * integrity hash, spread over one or more root directories in precedence + * order (typically the project-local cache backed by the per-user one). + * Reads consult every root and verify the content, so a corrupt entry + * degrades to a cache miss rather than a user-facing error. Writes go to + * the first root that accepts them, so an unwritable project tree falls + * back to the per-user cache instead of failing. + */ +export class TarballCache { + #rootDirs: string[] + + constructor (rootDirs: string | string[]) { + this.#rootDirs = Array.isArray(rootDirs) ? rootDirs : [rootDirs] + } + + static default ( + env: NodeJS.ProcessEnv = process.env, + projectRoot?: string, + platform: NodeJS.Platform = process.platform, + homedir = os.homedir(), + ): TarballCache { + return new TarballCache(resolveCacheDirs(env, projectRoot, platform, homedir) + .map(dir => path.join(dir, 'embedded-packages'))) + } + + #pathFor (rootDir: string, hash: IntegrityHash): string { + const hex = integrityHashToHex(hash) + return path.join(rootDir, hash.algorithm, hex.slice(0, 2), `${hex.slice(2)}.tgz`) + } + + /** + * Returns the path of a cached, integrity-verified tarball, or undefined + * on a miss. A file that fails verification is deleted best-effort. + */ + async get (integrity: string): Promise { + const hash = strongestIntegrityHash(integrity) + if (hash === undefined) { + return undefined + } + + for (const rootDir of this.#rootDirs) { + const filePath = this.#pathFor(rootDir, hash) + + let content: Buffer + try { + content = await fs.readFile(filePath) + } catch { + continue + } + + if (!verifyIntegrity(content, integrity)) { + await fs.rm(filePath, { force: true }).catch(() => {}) + continue + } + + return filePath + } + + return undefined + } + + /** + * Stores verified tarball content in the first writable root and returns + * its path. The write is atomic (temp file + rename), so concurrent + * processes sharing the cache never observe a torn file. The caller is + * responsible for verifying the content against the lockfile integrity + * beforehand. + */ + async put (integrity: string, content: Buffer): Promise { + const hash = strongestIntegrityHash(integrity) + if (hash === undefined) { + throw new Error(`Cannot cache a tarball without a supported integrity hash ('${integrity}')`) + } + + let lastError: unknown + for (const [index, rootDir] of this.#rootDirs.entries()) { + const filePath = this.#pathFor(rootDir, hash) + const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp` + try { + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(tempPath, content) + await fs.rename(tempPath, filePath) + if (index > 0) { + debug('cache write fell back to %s', rootDir) + } + return filePath + } catch (err) { + debug('cache root %s is not writable: %s', rootDir, (err as Error).message) + lastError = err + } finally { + await fs.rm(tempPath, { force: true }).catch(() => {}) + } + } + + throw new Error( + `Unable to write the embedded-packages cache` + + ` (tried ${this.#rootDirs.map(dir => `'${dir}'`).join(', ')}).` + + ` Set CHECKLY_CACHE_DIR to a writable directory to override the cache location.`, + { cause: lastError }, + ) + } +} + +/** + * Looks up a tarball in npm's cache (cacache), which stores raw registry + * tarballs content-addressed by the same sha512 the lockfile records, at + * `content-v2/sha512///`. Returns verified + * content, or undefined when absent, unverifiable, or keyed by an + * algorithm other than sha512. Read-only: npm's cache is never written to. + */ +export async function lookupNpmCacache ( + integrity: string, + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + homedir = os.homedir(), +): Promise { + const hash = strongestIntegrityHash(integrity) + if (hash === undefined || hash.algorithm !== 'sha512') { + return undefined + } + + const npmCacheDir = env.npm_config_cache !== undefined && env.npm_config_cache !== '' + ? env.npm_config_cache + : platform === 'win32' + ? path.join( + env.LOCALAPPDATA !== undefined && env.LOCALAPPDATA !== '' + ? env.LOCALAPPDATA + : path.join(homedir, 'AppData', 'Local'), + 'npm-cache', + ) + : path.join(homedir, '.npm') + + const hex = integrityHashToHex(hash) + const contentPath = path.join( + npmCacheDir, '_cacache', 'content-v2', 'sha512', + hex.slice(0, 2), hex.slice(2, 4), hex.slice(4), + ) + + let content: Buffer + try { + content = await fs.readFile(contentPath) + } catch { + return undefined + } + + if (!verifyIntegrity(content, integrity)) { + return undefined + } + + return content +} diff --git a/packages/cli/src/services/embedded-packages/integrity.ts b/packages/cli/src/services/embedded-packages/integrity.ts new file mode 100644 index 000000000..cc13d371a --- /dev/null +++ b/packages/cli/src/services/embedded-packages/integrity.ts @@ -0,0 +1,73 @@ +import { createHash } from 'node:crypto' + +/** + * A single parsed SRI (Subresource Integrity) hash, e.g. one + * `sha512-` segment of a lockfile `integrity` value. + */ +export interface IntegrityHash { + algorithm: string + digestBase64: string +} + +// Ordered strongest first. Lockfiles produced in the last decade only use +// sha512 and (for very old entries) sha1, but sha384/sha256 are valid SRI. +const SUPPORTED_ALGORITHMS = ['sha512', 'sha384', 'sha256', 'sha1'] + +/** + * Parses an SRI string (one or more space-separated `algorithm-base64` + * entries) into its supported hashes, unknown algorithms excluded. + */ +export function parseIntegrity (integrity: string): IntegrityHash[] { + const hashes: IntegrityHash[] = [] + + for (const entry of integrity.trim().split(/\s+/)) { + const separator = entry.indexOf('-') + if (separator === -1) { + continue + } + const algorithm = entry.slice(0, separator) + const digestBase64 = entry.slice(separator + 1) + if (!SUPPORTED_ALGORITHMS.includes(algorithm) || digestBase64 === '') { + continue + } + hashes.push({ algorithm, digestBase64 }) + } + + return hashes +} + +/** + * Returns the strongest supported hash of an SRI string, or undefined if + * none of its entries use a supported algorithm. + */ +export function strongestIntegrityHash (integrity: string): IntegrityHash | undefined { + const hashes = parseIntegrity(integrity) + for (const algorithm of SUPPORTED_ALGORITHMS) { + const match = hashes.find(hash => hash.algorithm === algorithm) + if (match !== undefined) { + return match + } + } + return undefined +} + +/** + * Verifies content against an SRI string using its strongest supported + * hash. Returns false when no supported hash is present. + */ +export function verifyIntegrity (content: Buffer, integrity: string): boolean { + const hash = strongestIntegrityHash(integrity) + if (hash === undefined) { + return false + } + const digest = createHash(hash.algorithm).update(content).digest('base64') + return digest === hash.digestBase64 +} + +/** + * The hex encoding of an SRI hash's digest. npm's cacache stores content + * under this encoding, e.g. `content-v2/sha512///`. + */ +export function integrityHashToHex (hash: IntegrityHash): string { + return Buffer.from(hash.digestBase64, 'base64').toString('hex') +} diff --git a/packages/cli/src/services/embedded-packages/lockfile-packages.ts b/packages/cli/src/services/embedded-packages/lockfile-packages.ts new file mode 100644 index 000000000..6208076dd --- /dev/null +++ b/packages/cli/src/services/embedded-packages/lockfile-packages.ts @@ -0,0 +1,299 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +import { parse as parseYaml } from 'yaml' +import JSON5 from 'json5' +import semver from 'semver' + +/** + * One embeddable `name@version` entry from the lockfile: a package that a + * registry serves as a tarball, with the integrity hash recorded for it. + */ +export interface LockfileRegistryPackage { + name: string + version: string + integrity: string + /** + * The full tarball URL when the lockfile records one (npm's `resolved`, + * pnpm's `resolution.tarball`). When absent, the URL is derived from the + * registry configuration. + */ + tarballUrl?: string +} + +/** + * A lockfile entry that cannot be embedded as a registry tarball, kept so + * that a configured spec matching only such entries gets a precise error + * instead of a generic "not found in the lockfile". + */ +export interface ExcludedLockfilePackage { + name: string + version?: string + reason: string + /** + * Why the entry is excluded, machine-readable: 'workspace' entries are + * part of the project itself (safe for a wildcard to skip silently), + * while 'unfetchable' entries (git/file/URL dependencies, or entries + * without an integrity hash) cannot be embedded but may still be needed + * at install time. + */ + kind: 'workspace' | 'unfetchable' +} + +export interface LockfilePackages { + registry: LockfileRegistryPackage[] + excluded: ExcludedLockfilePackage[] +} + +export class UnsupportedLockfileError extends Error { + constructor (message: string) { + super(message) + this.name = 'UnsupportedLockfileError' + } +} + +/** + * Enumerates every package entry in a lockfile, classified into embeddable + * registry packages and excluded (git/file/link/integrity-less) entries. + * Supports `pnpm-lock.yaml` (v6/v9) and `package-lock.json` (v2/v3). + */ +export async function loadLockfilePackages (lockfilePath: string): Promise { + const basename = path.basename(lockfilePath) + const content = await fs.readFile(lockfilePath, 'utf8') + + switch (basename) { + case 'pnpm-lock.yaml': + return parsePnpmLockfilePackages(content) + case 'package-lock.json': + return parseNpmLockfilePackages(content) + default: + throw new UnsupportedLockfileError( + `Embedded packages are not supported for '${basename}' lockfiles yet.` + + ` Only pnpm (pnpm-lock.yaml) and npm (package-lock.json) are currently supported.`, + ) + } +} + +/** + * Strips a pnpm peer-dependency suffix (`(react@18.2.0)`) from a package + * key. The v9 `packages` section doesn't use them (they live in + * `snapshots`), but v6 keys do. + */ +function stripPeerSuffix (key: string): string { + const cut = key.indexOf('(') + return cut === -1 ? key : key.slice(0, cut) +} + +export function parsePnpmLockfilePackages (content: string): LockfilePackages { + const data = parseYaml(content) + + // The version can arrive as a number: pnpm writes `lockfileVersion: '9.0'` + // quoted, but a YAML re-serializer (merge tooling, formatters) may drop + // the quotes, turning it into the number 9. + const lockfileVersion = String(data?.lockfileVersion ?? '') + const lockfileMajor = Number.parseInt(lockfileVersion, 10) + if (lockfileMajor !== 6 && lockfileMajor !== 9) { + throw new UnsupportedLockfileError( + `Embedded packages require pnpm lockfile version 6 or 9` + + ` (found '${lockfileVersion || 'unknown'}'). Regenerate the lockfile with a supported` + + ` pnpm version, or update the Checkly CLI if the lockfile is newer.`, + ) + } + + const result: LockfilePackages = { registry: [], excluded: [] } + + // Workspace-linked packages never appear in the `packages` section — only + // as `link:` dependencies under `importers`. Record them so a user listing + // their own workspace package gets a precise "cannot be embedded" error + // instead of a "not found, check the spelling" one. + const importers = data?.importers + if (typeof importers === 'object' && importers !== null) { + const linkedNames = new Set() + for (const importer of Object.values(importers)) { + for (const group of ['dependencies', 'devDependencies', 'optionalDependencies']) { + for (const [name, dep] of Object.entries(importer?.[group] ?? {})) { + const version = typeof dep === 'string' ? dep : dep?.version + if (typeof version === 'string' && version.startsWith('link:') && !linkedNames.has(name)) { + linkedNames.add(name) + // Same distinction as npm's `link: true` entries: a link whose + // target escapes the workspace is not part of the project the + // bundle carries. + const target = version.slice('link:'.length) + const escapesWorkspace = target === '..' || target.startsWith('../') || path.isAbsolute(target) + result.excluded.push({ + name, + reason: escapesWorkspace + ? `'${name}' is a local directory link outside the workspace, which cannot be embedded` + + ` as a registry tarball` + : `'${name}' is a workspace package, which cannot be embedded as a registry tarball`, + kind: escapesWorkspace ? 'unfetchable' : 'workspace', + }) + } + } + } + } + } + + const packages = data?.packages + if (typeof packages !== 'object' || packages === null) { + return result + } + + const seen = new Set() + for (const [rawKey, rawEntry] of Object.entries(packages)) { + // v6 keys have a leading slash (`/name@1.2.3`), v9 keys do not. + const key = stripPeerSuffix(rawKey.startsWith('/') ? rawKey.slice(1) : rawKey) + // The name/ref separator is the first `@` past the name. Searching from + // the front (after the scope, when present) keeps the name intact when + // the ref itself contains `@`, as git refs do + // (`foo@git+ssh://git@github.com/...`). + const searchFrom = key.startsWith('@') ? key.indexOf('/') + 1 : 1 + const separator = searchFrom > 0 ? key.indexOf('@', searchFrom) : -1 + if (separator <= 0) { + continue + } + const name = key.slice(0, separator) + const ref = key.slice(separator + 1) + + if (seen.has(`${name}@${ref}`)) { + continue + } + seen.add(`${name}@${ref}`) + + // Validate with semver but keep the ref as written: semver.valid() + // normalizes away build metadata (`1.0.0+sha.abc` → `1.0.0`), which + // would break both version-pin matching and the derived tarball URL. + const version = semver.valid(ref) !== null ? ref : null + if (version === null) { + result.excluded.push({ + name, + reason: `'${name}@${ref}' resolves to a git, file or URL dependency,` + + ` which cannot be embedded as a registry tarball`, + kind: 'unfetchable', + }) + continue + } + + const resolution = rawEntry?.resolution + const integrity = resolution?.integrity + if (typeof integrity !== 'string' || integrity === '') { + result.excluded.push({ + name, + version, + reason: `the lockfile records no integrity hash for '${name}@${version}',` + + ` which is required to embed it`, + kind: 'unfetchable', + }) + continue + } + + const tarball = resolution?.tarball + result.registry.push({ + name, + version, + integrity, + // Only absolute http(s) URLs are usable for downloading; anything + // else falls back to the registry-derived URL. + tarballUrl: typeof tarball === 'string' && /^https?:/.test(tarball) ? tarball : undefined, + }) + } + + return result +} + +export function parseNpmLockfilePackages (content: string): LockfilePackages { + const data = JSON5.parse(content) + + const lockfileVersion = data?.lockfileVersion + if (lockfileVersion !== 2 && lockfileVersion !== 3) { + throw new UnsupportedLockfileError( + `Embedded packages require npm lockfile version 2 or 3` + + ` (found '${lockfileVersion ?? 'unknown'}'). Update npm and regenerate the lockfile.`, + ) + } + + const packages = data?.packages + const result: LockfilePackages = { registry: [], excluded: [] } + if (typeof packages !== 'object' || packages === null) { + return result + } + + const seen = new Set() + for (const [key, entry] of Object.entries(packages)) { + const lastNodeModules = key.lastIndexOf('node_modules/') + if (lastNodeModules === -1) { + // The workspace root ('') and workspace member paths are not + // installable registry artifacts. + continue + } + // Aliased installs record the real package name in the entry; the key + // segment is the alias. + const name = typeof entry?.name === 'string' + ? entry.name + : key.slice(lastNodeModules + 'node_modules/'.length) + + if (entry?.link === true) { + // `link: true` covers both workspace members and `file:` directory + // dependencies. A link whose target escapes the workspace is not + // part of the project the bundle carries, so a wildcard must not + // skip it silently. + const target = typeof entry?.resolved === 'string' ? entry.resolved : '' + const escapesWorkspace = target === '..' || target.startsWith('../') || path.isAbsolute(target) + result.excluded.push({ + name: key.slice(lastNodeModules + 'node_modules/'.length), + reason: escapesWorkspace + ? `'${key}' is a local directory link outside the workspace, which cannot be embedded` + + ` as a registry tarball` + : `'${key}' is a workspace link, which cannot be embedded as a registry tarball`, + kind: escapesWorkspace ? 'unfetchable' : 'workspace', + }) + continue + } + + // As above: validate with semver but keep the version as recorded. + const version = typeof entry?.version === 'string' && semver.valid(entry.version) !== null + ? entry.version as string + : null + const resolved = typeof entry?.resolved === 'string' ? entry.resolved : undefined + + if (version === null || (resolved !== undefined && !/^https?:/.test(resolved))) { + result.excluded.push({ + name, + version: version ?? undefined, + reason: `'${key}' resolves to a git, file or URL dependency,` + + ` which cannot be embedded as a registry tarball`, + kind: 'unfetchable', + }) + continue + } + + if (seen.has(`${name}@${version}`)) { + continue + } + + const integrity = entry?.integrity + if (typeof integrity !== 'string' || integrity === '') { + // Deliberately not marked as seen: an integrity-less copy (typically + // a nested bundled dependency) must not shadow a proper registry + // entry of the same name@version appearing later in the map. + result.excluded.push({ + name, + version, + reason: `the lockfile records no integrity hash for '${name}@${version}'` + + ` (typically a bundled dependency), which is required to embed it`, + kind: 'unfetchable', + }) + continue + } + seen.add(`${name}@${version}`) + + result.registry.push({ + name, + version, + integrity, + tarballUrl: resolved, + }) + } + + return result +} diff --git a/packages/cli/src/services/embedded-packages/materializer.ts b/packages/cli/src/services/embedded-packages/materializer.ts new file mode 100644 index 000000000..930954e81 --- /dev/null +++ b/packages/cli/src/services/embedded-packages/materializer.ts @@ -0,0 +1,425 @@ +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import axios from 'axios' +import Debug from 'debug' +import PQueue from 'p-queue' + +import { assignProxy } from '../proxy.js' +import { TarballCache, lookupNpmCacache } from './cache.js' +import { verifyIntegrity } from './integrity.js' +import { + LockfileRegistryPackage, + UnsupportedLockfileError, + loadLockfilePackages, +} from './lockfile-packages.js' +import { NpmrcConfig, defaultNpmrcPaths, loadNpmrcConfig, resolveAuthHeader, resolveRegistryUrl } from './npmrc.js' +import { EmbeddedPackageSpec, parseEmbeddedPackageSpec, specMatchesPackageName } from './spec.js' + +const debug = Debug('checkly:cli:services:embedded-packages') + +/** + * The directory inside the code bundle where embedded package tarballs + * live. This path is a contract with Checkly runners: tarballs found there + * are served through a local registry during the bundle's install step. + */ +export const EMBEDDED_PACKAGES_ARCHIVE_DIR = '.checkly/embedded-packages' + +export interface EmbeddedPackagesIssue { + type: 'invalid-spec' | 'missing-lockfile' | 'unsupported-lockfile' | 'spec-not-found' | 'spec-not-embeddable' + /** The offending `checks.embeddedPackages` entry, when tied to one. */ + spec?: string + message: string +} + +/** + * One tarball selected for embedding, resolved from the lockfile. + */ +export interface PlannedTarball extends LockfileRegistryPackage { + /** Archive filename, e.g. `@acme+foo@1.2.3.tgz` (scope slash → `+`). */ + archiveFilename: string +} + +export interface EmbeddedPackagesPlan { + tarballs: PlannedTarball[] + issues: EmbeddedPackagesIssue[] + /** + * Non-fatal problems worth surfacing (e.g. a spec also matching + * dependencies that cannot be embedded). Reported through the + * diagnostics channel during project validation. + */ + warnings: string[] + /** What each wildcard spec resolved to, announced during bundling. */ + wildcardMatches: Array<{ spec: string, packages: string[] }> +} + +/** + * A planned tarball that has been sourced into the CLI cache and is ready + * to be added to the code bundle. + */ +export interface MaterializedTarball extends PlannedTarball { + /** Absolute path of the verified tarball in the CLI cache. */ + filePath: string + /** Bundle-root-relative archive path (POSIX). */ + archivePath: string +} + +export class EmbeddedPackageError extends Error { + constructor (message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'EmbeddedPackageError' + } +} + +export interface EmbeddedPackagesMaterializerOptions { + /** Raw `checks.embeddedPackages` entries. */ + specs: string[] + /** Absolute path of the workspace root lockfile, when one exists. */ + lockfilePath?: string + /** Workspace root directory, used to locate the root `.npmrc`. */ + workspaceRoot?: string + /** + * The directory the Checkly project lives in (a workspace member in a + * monorepo), whose `.npmrc` takes precedence over the workspace root's. + */ + contextDir?: string + env?: NodeJS.ProcessEnv + homedir?: string +} + +const DOWNLOAD_CONCURRENCY = 5 +const DOWNLOAD_TIMEOUT_MS = 120_000 +const MAX_TARBALL_BYTES = 1024 * 1024 * 1024 + +/** + * Removes userinfo credentials from a URL so it can be safely included in + * error messages and logs (a registry URL may embed a token). + */ +function redactUrl (url: string): string { + try { + const parsed = new URL(url) + parsed.username = '' + parsed.password = '' + return parsed.toString() + } catch { + // Not parseable as a URL (e.g. a scheme-less registry entry) — strip + // anything that looks like a userinfo segment before displaying it. + return url.replace(/(^|\/\/)[^/@\s]+@/, '$1') + } +} + +/** + * Resolves the configured `checks.embeddedPackages` specs against the + * workspace lockfile (plan) and sources the selected tarballs into the CLI + * cache (materialize), through a chain of CLI cache → npm cacache → + * registry download, always verified against the lockfile integrity. + * + * Both stages memoize their in-flight promise: multiple Playwright checks + * bundle concurrently, and validation and bundling share one instance per + * parsed project, so the work runs exactly once. + */ +export class EmbeddedPackagesMaterializer { + #options: EmbeddedPackagesMaterializerOptions + #cache: TarballCache + #env: NodeJS.ProcessEnv + #homedir: string + + #plan?: Promise + #materialized?: Promise + + constructor (options: EmbeddedPackagesMaterializerOptions) { + this.#options = options + this.#env = options.env ?? process.env + this.#homedir = options.homedir ?? os.homedir() + this.#cache = TarballCache.default(this.#env, this.#projectRoot, process.platform, this.#homedir) + } + + get #projectRoot (): string | undefined { + const { workspaceRoot, lockfilePath } = this.#options + return workspaceRoot ?? (lockfilePath !== undefined ? path.dirname(lockfilePath) : undefined) + } + + plan (): Promise { + this.#plan ??= this.#createPlan() + return this.#plan + } + + #info (message: string): void { + process.stderr.write(`${message}\n`) + } + + materialize (): Promise { + this.#materialized ??= this.#materializeAll() + return this.#materialized + } + + async #createPlan (): Promise { + const issues: EmbeddedPackagesIssue[] = [] + const warnings: string[] = [] + const wildcardMatches: Array<{ spec: string, packages: string[] }> = [] + + const specs: EmbeddedPackageSpec[] = [] + for (const raw of this.#options.specs) { + try { + specs.push(parseEmbeddedPackageSpec(raw)) + } catch (err) { + issues.push({ type: 'invalid-spec', spec: String(raw), message: (err as Error).message }) + } + } + + const { lockfilePath } = this.#options + if (lockfilePath === undefined) { + issues.push({ + type: 'missing-lockfile', + message: `Embedded packages require a lockfile to resolve package versions and` + + ` integrity hashes, but no lockfile was found for the project.`, + }) + return { tarballs: [], issues, warnings, wildcardMatches } + } + + let packages + try { + packages = await loadLockfilePackages(lockfilePath) + } catch (err) { + // Any failure to read or parse the lockfile (missing file, merge + // conflict markers, unknown format) becomes a diagnostic naming the + // lockfile instead of an unhandled exception aborting the command. + const message = err instanceof UnsupportedLockfileError + ? err.message + : `Failed to read or parse the lockfile ('${lockfilePath}'): ${(err as Error).message}` + issues.push({ type: 'unsupported-lockfile', message }) + return { tarballs: [], issues, warnings, wildcardMatches } + } + + debug( + 'lockfile %s: %d registry entries, %d excluded entries', + lockfilePath, packages.registry.length, packages.excluded.length, + ) + + // Excluded entries that share a name@version with a proper registry + // entry are shadowed duplicates (npm nests integrity-less bundled + // copies): the artifact IS embeddable through its registry entry, so + // they must not trigger not-embeddable errors or skip warnings. + const registryKeys = new Set(packages.registry.map(entry => `${entry.name}@${entry.version}`)) + const relevantExcluded = packages.excluded.filter(entry => + entry.version === undefined || !registryKeys.has(`${entry.name}@${entry.version}`)) + + const tarballs = new Map() + for (const spec of specs) { + const nameMatches = packages.registry.filter(entry => specMatchesPackageName(spec, entry.name)) + const candidates = nameMatches + .filter(entry => spec.version === undefined || entry.version === spec.version) + + const nameExcluded = relevantExcluded.filter(entry => specMatchesPackageName(spec, entry.name)) + const looseExcluded = nameExcluded.filter(entry => + spec.version === undefined || entry.version === undefined || entry.version === spec.version) + + if (candidates.length === 0) { + // Excluded entries matching the exact pin (or any entry, when + // unpinned) carry the most actionable reason and win; a version + // pin that filtered out real registry matches is blamed next. + // Version-less excluded entries (e.g. workspace links) are a last + // resort, so a pinned spec is never blamed on one while a better + // explanation exists. + const strictExcluded = nameExcluded.filter(entry => + spec.version === undefined || entry.version === spec.version) + const excludedMatches = strictExcluded.length > 0 + ? strictExcluded + : nameMatches.length === 0 ? looseExcluded : [] + if (excludedMatches.length > 0) { + const reasons = [...new Set(excludedMatches.map(entry => entry.reason))] + const shownReasons = reasons.slice(0, 8).join('; ') + const moreReasons = reasons.length > 8 ? `; and ${reasons.length - 8} more` : '' + issues.push({ + type: 'spec-not-embeddable', + spec: spec.raw, + message: `Embedded package '${spec.raw}' cannot be embedded: ${shownReasons}${moreReasons}.`, + }) + } else if (nameMatches.length > 0) { + issues.push({ + type: 'spec-not-found', + spec: spec.raw, + message: `Embedded package '${spec.raw}' matches package name(s) in the lockfile` + + ` ('${lockfilePath}'), but none of them at version ${spec.version}.`, + }) + } else { + const hint = spec.namePattern !== undefined + ? `pattern matches its name${spec.version !== undefined ? ' and the version is spelled correctly' : ''}` + : `name ${spec.version !== undefined ? 'and version are' : 'is'} spelled correctly` + issues.push({ + type: 'spec-not-found', + spec: spec.raw, + message: `Embedded package '${spec.raw}' does not match any package in the lockfile` + + ` ('${lockfilePath}'). Make sure the package is installed and the ${hint}.`, + }) + } + continue + } + + // When the spec also reaches entries it cannot embed, that is not + // the hard error a fully-unresolvable spec gets. Workspace members + // (part of the project itself) are skipped silently; git/file/URL + // and integrity-less matches cannot be embedded but may still be + // needed at install time, so skipping them is said out loud. + const workspace = looseExcluded.filter(entry => entry.kind === 'workspace') + if (workspace.length > 0) { + debug('spec %s: %d workspace matches skipped: %j', + spec.raw, workspace.length, workspace.map(entry => entry.name)) + } + const unfetchable = looseExcluded.filter(entry => entry.kind === 'unfetchable') + if (unfetchable.length > 0) { + const names = [...new Set(unfetchable.map(entry => entry.name))] + const shown = names.slice(0, 8).join(', ') + const more = names.length > 8 ? ` and ${names.length - 8} more` : '' + warnings.push( + `Embedded package '${spec.raw}' also matches ${names.length} package(s) that cannot` + + ` be embedded as registry tarballs and were skipped: ${shown}${more}.` + + ` The runner must be able to fetch these itself.`, + ) + } + if (spec.namePattern !== undefined) { + wildcardMatches.push({ + spec: spec.raw, + packages: candidates.map(entry => `${entry.name}@${entry.version}`), + }) + } + + for (const entry of candidates) { + tarballs.set(`${entry.name}@${entry.version}`, { + ...entry, + archiveFilename: `${entry.name.replace(/\//g, '+')}@${entry.version}.tgz`, + }) + } + } + + debug('plan: %d tarballs, %d issues, %d warnings', tarballs.size, issues.length, warnings.length) + + return { + tarballs: [...tarballs.values()].sort((a, b) => a.archiveFilename.localeCompare(b.archiveFilename)), + issues, + warnings, + wildcardMatches, + } + } + + async #materializeAll (): Promise { + const { tarballs, issues, wildcardMatches } = await this.plan() + + // Commands validate before bundling and exit on fatal diagnostics, so + // this is a defensive backstop for direct/programmatic use. + if (issues.length > 0) { + throw new EmbeddedPackageError( + `Cannot embed packages due to configuration issues:\n\n` + + issues.map(issue => ` ${issue.message}`).join('\n'), + ) + } + + // Wildcards select invisibly, so say what they selected. + for (const match of wildcardMatches) { + const shown = match.packages.slice(0, 8).join(', ') + const more = match.packages.length > 8 ? ` and ${match.packages.length - 8} more` : '' + this.#info( + `Embedded package pattern '${match.spec}' matched ${match.packages.length} package(s): ${shown}${more}.`, + ) + } + + if (tarballs.length === 0) { + return [] + } + + // Safe to assert: a missing lockfile is a plan issue, and issues abort + // above. + const npmrcConfig = await loadNpmrcConfig(defaultNpmrcPaths( + this.#projectRoot!, + this.#homedir, + this.#options.contextDir, + ), this.#env) + + const queue = new PQueue({ concurrency: DOWNLOAD_CONCURRENCY }) + const results = await queue.addAll(tarballs.map(tarball => async (): Promise => { + const filePath = await this.#obtainTarball(tarball, npmrcConfig) + return { + ...tarball, + filePath, + archivePath: `${EMBEDDED_PACKAGES_ARCHIVE_DIR}/${tarball.archiveFilename}`, + } + })) + + return results + } + + async #obtainTarball (tarball: PlannedTarball, npmrcConfig: NpmrcConfig): Promise { + const cached = await this.#cache.get(tarball.integrity) + if (cached !== undefined) { + debug('%s@%s: CLI cache hit', tarball.name, tarball.version) + return cached + } + + const fromNpmCacache = await lookupNpmCacache(tarball.integrity, this.#env, process.platform, this.#homedir) + if (fromNpmCacache !== undefined) { + debug('%s@%s: npm cache hit', tarball.name, tarball.version) + return await this.#cache.put(tarball.integrity, fromNpmCacache) + } + + const url = tarball.tarballUrl ?? this.#deriveTarballUrl(tarball, npmrcConfig) + if (!URL.canParse(url)) { + throw new EmbeddedPackageError( + `The tarball URL for embedded package '${tarball.name}@${tarball.version}'` + + ` is not a valid URL: '${redactUrl(url)}'. Check the 'registry' configuration` + + ` in your .npmrc (it must be an absolute URL including the protocol).`, + ) + } + debug('%s@%s: downloading from %s', tarball.name, tarball.version, redactUrl(url)) + const content = await this.#download(tarball, url, npmrcConfig) + + if (!verifyIntegrity(content, tarball.integrity)) { + throw new EmbeddedPackageError( + `The tarball downloaded for embedded package '${tarball.name}@${tarball.version}'` + + ` from '${redactUrl(url)}' does not match the integrity hash recorded in the lockfile` + + ` ('${tarball.integrity}'). The registry may be serving a different artifact` + + ` than the one the lockfile was created against.`, + ) + } + + return await this.#cache.put(tarball.integrity, content) + } + + #deriveTarballUrl (tarball: PlannedTarball, npmrcConfig: NpmrcConfig): string { + const registryUrl = resolveRegistryUrl(npmrcConfig, tarball.name, this.#env) + const basename = tarball.name.split('/').pop() + return `${registryUrl}${tarball.name}/-/${basename}-${tarball.version}.tgz` + } + + async #download (tarball: PlannedTarball, url: string, npmrcConfig: NpmrcConfig): Promise { + const authHeader = resolveAuthHeader(npmrcConfig, url, this.#env) + + try { + const response = await axios.get(url, assignProxy(url, { + responseType: 'arraybuffer', + headers: { + // Ask for the raw artifact: a registry or proxy that labels the + // already-gzipped tarball with `Content-Encoding: gzip` would + // otherwise make axios gunzip it, breaking integrity verification + // with a misleading "different artifact" error. + 'accept-encoding': 'identity', + ...(authHeader !== undefined ? { authorization: authHeader } : {}), + }, + timeout: DOWNLOAD_TIMEOUT_MS, + maxContentLength: MAX_TARBALL_BYTES, + })) + return Buffer.from(response.data) + } catch (err: any) { + const status = err?.response?.status + const statusHint = status !== undefined ? ` (HTTP ${status})` : '' + const authHint = status === 401 || status === 403 + ? ` Check that your .npmrc contains valid credentials for this registry.` + : '' + throw new EmbeddedPackageError( + `Failed to download embedded package '${tarball.name}@${tarball.version}'` + + ` from '${redactUrl(url)}'${statusHint}.${authHint}`, + { cause: err }, + ) + } + } +} diff --git a/packages/cli/src/services/embedded-packages/npmrc.ts b/packages/cli/src/services/embedded-packages/npmrc.ts new file mode 100644 index 000000000..b74574d02 --- /dev/null +++ b/packages/cli/src/services/embedded-packages/npmrc.ts @@ -0,0 +1,220 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +export const DEFAULT_REGISTRY_URL = 'https://registry.npmjs.org/' + +/** + * Merged `.npmrc` configuration: a flat key → raw value map. Values keep + * any `${VAR}` references unexpanded until they're actually used, so an + * unset environment variable in an unrelated line never breaks anything. + */ +export type NpmrcConfig = Map + +export class NpmrcEnvVarError extends Error { + constructor (key: string, varName: string) { + super( + `The .npmrc value for '${key}' references the environment variable` + + ` '${varName}', which is not set`, + ) + this.name = 'NpmrcEnvVarError' + } +} + +/** + * Parses a single `.npmrc` file's content. Only the simple `key=value` + * subset of npm's ini format is supported (comments with `#`/`;`, + * whitespace trimming); ini sections do not occur in npm configs. + */ +export function parseNpmrc (content: string): NpmrcConfig { + const config: NpmrcConfig = new Map() + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim() + if (line === '' || line.startsWith('#') || line.startsWith(';')) { + continue + } + const separator = line.indexOf('=') + if (separator === -1) { + continue + } + const key = line.slice(0, separator).trim() + let value = line.slice(separator + 1).trim() + // npm's ini parser strips matching quotes around values. + if (value.length >= 2 && (value[0] === '"' || value[0] === '\'') && value.endsWith(value[0])) { + value = value.slice(1, -1) + } + if (key !== '') { + config.set(key, value) + } + } + + return config +} + +/** + * Extracts npm configuration from `npm_config_*` environment variables + * (e.g. `npm_config_registry`, commonly set in CI and by package managers + * running lifecycle scripts). In npm's precedence order these sit above + * every `.npmrc` file. The prefix is matched case-insensitively; the key + * is stored both verbatim and lowercased, because plain keys are written + * in any case (`NPM_CONFIG_REGISTRY`) while nerf-darted auth keys carry a + * case-sensitive spelling (`npm_config_//host/:_authToken`). + */ +export function npmrcConfigFromEnv (env: NodeJS.ProcessEnv): NpmrcConfig { + const config: NpmrcConfig = new Map() + + const prefix = 'npm_config_' + for (const [name, value] of Object.entries(env)) { + if (value === undefined || !name.toLowerCase().startsWith(prefix)) { + continue + } + const key = name.slice(prefix.length) + // npm drops env config entries with empty values rather than treating + // them as set-to-empty. + if (key === '' || value === '') { + continue + } + config.set(key, value) + if (!config.has(key.toLowerCase())) { + config.set(key.toLowerCase(), value) + } + } + + return config +} + +/** + * Loads and merges npm configuration in precedence order: `npm_config_*` + * environment variables first, then `.npmrc` files with entries from + * earlier paths winning over later ones (pass project first, then user). + * Missing files are skipped. + */ +export async function loadNpmrcConfig ( + filePaths: string[], + env: NodeJS.ProcessEnv = process.env, +): Promise { + const merged: NpmrcConfig = npmrcConfigFromEnv(env) + + for (const filePath of filePaths) { + let content: string + try { + content = await fs.readFile(filePath, 'utf8') + } catch (err: any) { + if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR' || err?.code === 'EISDIR') { + continue + } + // An unreadable .npmrc (e.g. bad permissions) must not silently drop + // registry credentials — that would surface later as a baffling 401. + throw new Error(`Unable to read npm configuration from '${filePath}'`, { cause: err }) + } + for (const [key, value] of parseNpmrc(content)) { + if (!merged.has(key)) { + merged.set(key, value) + } + } + } + + return merged +} + +/** + * The `.npmrc` locations relevant to a project, in npm's precedence order: + * the directory the Checkly project lives in (the nearest project config, + * which may be a workspace member), the workspace root, then the + * user-level file. (npm's global and builtin configs are not consulted.) + */ +export function defaultNpmrcPaths ( + workspaceRoot: string, + homedir = os.homedir(), + contextDir?: string, +): string[] { + const paths = [ + ...(contextDir !== undefined ? [path.join(contextDir, '.npmrc')] : []), + path.join(workspaceRoot, '.npmrc'), + path.join(homedir, '.npmrc'), + ] + return [...new Set(paths)] +} + +function expandValue (key: string, value: string, env: NodeJS.ProcessEnv): string { + return value.replace(/\$\{([^}]+)\}/g, (_, varName: string) => { + const envValue = env[varName] + if (envValue === undefined) { + throw new NpmrcEnvVarError(key, varName) + } + return envValue + }) +} + +function getExpanded (config: NpmrcConfig, key: string, env: NodeJS.ProcessEnv): string | undefined { + const value = config.get(key) ?? config.get(key.toLowerCase()) + if (value === undefined) { + return undefined + } + return expandValue(key, value, env) +} + +/** + * Resolves the registry URL for a package name: the `@scope:registry` entry + * if the package is scoped and one exists, the `registry` entry otherwise, + * falling back to the public npm registry. Always ends with a slash. + */ +export function resolveRegistryUrl ( + config: NpmrcConfig, + packageName: string, + env: NodeJS.ProcessEnv = process.env, +): string { + let registry: string | undefined + + if (packageName.startsWith('@')) { + const scope = packageName.slice(0, packageName.indexOf('/')) + registry = getExpanded(config, `${scope}:registry`, env) + } + + registry ??= getExpanded(config, 'registry', env) + registry ??= DEFAULT_REGISTRY_URL + + return registry.endsWith('/') ? registry : `${registry}/` +} + +/** + * Resolves the `Authorization` header value applicable to a URL, matching + * npm's "nerf dart" scheme: credentials are keyed by the registry URL minus + * its protocol (`//host/path/:_authToken=...`). The URL's path is walked + * upward so credentials configured for a registry root also apply to + * tarball URLs beneath it. Supports `_authToken` (Bearer), `_auth` + * (pre-encoded Basic), and `username` + `_password` (base64-encoded, per + * npm convention). Returns undefined when no credentials match. + */ +export function resolveAuthHeader ( + config: NpmrcConfig, + url: string, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const parsed = new URL(url) + + const segments = parsed.pathname.split('/').filter(segment => segment !== '') + for (let depth = segments.length; depth >= 0; depth--) { + const nerfDart = `//${parsed.host}/${segments.slice(0, depth).map(segment => `${segment}/`).join('')}` + + const authToken = getExpanded(config, `${nerfDart}:_authToken`, env) + if (authToken !== undefined) { + return `Bearer ${authToken}` + } + + const auth = getExpanded(config, `${nerfDart}:_auth`, env) + if (auth !== undefined) { + return `Basic ${auth}` + } + + const username = getExpanded(config, `${nerfDart}:username`, env) + const password = getExpanded(config, `${nerfDart}:_password`, env) + if (username !== undefined && password !== undefined) { + const decodedPassword = Buffer.from(password, 'base64').toString('utf8') + return `Basic ${Buffer.from(`${username}:${decodedPassword}`, 'utf8').toString('base64')}` + } + } + + return undefined +} diff --git a/packages/cli/src/services/embedded-packages/spec.ts b/packages/cli/src/services/embedded-packages/spec.ts new file mode 100644 index 000000000..a79fa080a --- /dev/null +++ b/packages/cli/src/services/embedded-packages/spec.ts @@ -0,0 +1,115 @@ +import semver from 'semver' + +/** + * A parsed `checks.embeddedPackages` entry: a package name — or a name + * pattern with `*` wildcards — with an optional exact version pin + * (`name` or `name@version`). + */ +export interface EmbeddedPackageSpec { + /** The raw config entry, kept for error messages. */ + raw: string + /** + * The package name, e.g. `@acme/private-utils` — or, when + * {@link namePattern} is set, the raw name pattern, e.g. `@acme/*`. + */ + name: string + /** The exact pinned version, if the entry included one. */ + version?: string + /** + * Present when the name contains `*` wildcards: the compiled matcher. + * Each `*` matches any run of characters except `/`, so a wildcard + * never crosses the scope separator (`@acme/*` matches only packages in + * that scope; a bare `*` matches only unscoped names). + */ + namePattern?: RegExp +} + +/** + * Whether a spec selects the given package name: exact comparison for + * plain specs, pattern match for wildcard specs. + */ +export function specMatchesPackageName (spec: EmbeddedPackageSpec, packageName: string): boolean { + if (spec.namePattern !== undefined) { + return spec.namePattern.test(packageName) + } + return spec.name === packageName +} + +function compileNamePattern (name: string): RegExp { + // Splitting on *runs* of `*` treats consecutive stars as one, keeping + // the compiled regex free of adjacent `[^/]*` runs, whose backtracking + // on a mismatch grows catastrophically with the number of stars. + const escaped = name + .split(/\*+/) + .map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('[^/]*') + return new RegExp(`^${escaped}$`) +} + +// npm's name rules for already-published packages: new publishes must be +// lowercase, but plenty of legitimate older packages (JSONStream) contain +// uppercase letters, so both cases are accepted. Leading `.` and `_` stay +// disallowed, as npm has never permitted them. +const PACKAGE_NAME_RE = /^(@[a-zA-Z0-9-~][a-zA-Z0-9-~._]*\/)?[a-zA-Z0-9-~][a-zA-Z0-9-._~]*$/ + +export class InvalidEmbeddedPackageSpecError extends Error { + constructor (spec: string, reason: string) { + super(`Invalid embedded package '${spec}': ${reason}`) + this.name = 'InvalidEmbeddedPackageSpecError' + } +} + +/** + * Parses a `checks.embeddedPackages` entry into a package name and an + * optional exact version pin. + * + * Accepts `name` (embed every lockfile version of the package) and + * `name@version` with an exact semver version. The name may contain `*` + * wildcards (`@acme/*`, `acme-*`, `@acme/*-utils`), each matching any run + * of characters except `/`. Version ranges are rejected: the embedded + * tarball must be the exact artifact the lockfile resolved, so a range has + * nothing meaningful to select against. A leading `v` is stripped, but the + * version is otherwise kept as written (including any build metadata) so + * it compares exactly against lockfile versions. + */ +export function parseEmbeddedPackageSpec (raw: string): EmbeddedPackageSpec { + if (typeof raw !== 'string' || raw === '') { + throw new InvalidEmbeddedPackageSpecError(String(raw), `must be a non-empty string`) + } + + // A version separator is any `@` past the first character, which keeps the + // scope marker of `@scope/name` intact. + const versionSeparator = raw.lastIndexOf('@') + const name = versionSeparator > 0 ? raw.slice(0, versionSeparator) : raw + const rawVersion = versionSeparator > 0 ? raw.slice(versionSeparator + 1) : undefined + + // A wildcard name must still be name-shaped once every `*` stands in for + // name characters. (`*` itself appears in npm's legacy name charset, but + // no real-world package uses it; here it always means a wildcard.) + const wildcard = name.includes('*') + if (!PACKAGE_NAME_RE.test(wildcard ? name.replace(/\*/g, 'a') : name)) { + throw new InvalidEmbeddedPackageSpecError( + raw, + `'${name}' is not a valid npm package name${wildcard ? ' pattern' : ''}`, + ) + } + const namePattern = wildcard ? compileNamePattern(name) : undefined + + if (rawVersion === undefined) { + return { raw, name, namePattern } + } + + // Trim before validating: semver.valid() tolerates surrounding whitespace, + // so an untrimmed version would pass validation yet never compare equal to + // a lockfile version. + const trimmedVersion = rawVersion.trim() + const version = trimmedVersion.startsWith('v') ? trimmedVersion.slice(1) : trimmedVersion + if (semver.valid(version) === null) { + throw new InvalidEmbeddedPackageSpecError( + raw, + `'${rawVersion}' is not an exact semver version (use 'name' or 'name@1.2.3')`, + ) + } + + return { raw, name, version, namePattern } +} diff --git a/packages/cli/src/services/playwright-project-bundler.ts b/packages/cli/src/services/playwright-project-bundler.ts index 16fb1a74b..23dbae483 100644 --- a/packages/cli/src/services/playwright-project-bundler.ts +++ b/packages/cli/src/services/playwright-project-bundler.ts @@ -142,6 +142,24 @@ export class PlaywrightProjectBundler { })) } + // Embedded package tarballs live in the CLI cache, whose on-disk + // location (node_modules/.cache, a per-user dir, or CHECKLY_CACHE_DIR) + // never corresponds to the contract path the runner expects, so they + // carry an explicit archive path instead of relying on the strip + // prefix. The materializer memoizes, so concurrent bundles share one + // download run, and the Bundler dedupes registrations by archive path + // across checks. + const materializer = Session.getEmbeddedPackagesMaterializer() + if (materializer !== undefined) { + for (const tarball of await materializer.materialize()) { + files.push({ + filePath: tarball.filePath, + physical: true, + archivePath: tarball.archivePath, + }) + } + } + return { browsers: pwConfigParsed.getBrowsers(), playwrightVersion, diff --git a/packages/cli/src/services/project-parser.ts b/packages/cli/src/services/project-parser.ts index c42e4e4f3..19620948e 100644 --- a/packages/cli/src/services/project-parser.ts +++ b/packages/cli/src/services/project-parser.ts @@ -45,6 +45,7 @@ type ProjectParseOpts = { checklyConfigConstructs?: Construct[] playwrightConfigPath?: string include?: string | string[] + embeddedPackages?: string[] playwrightChecks?: PlaywrightSlimmedProp[] loadPlaywrightChecksOnly?: boolean warnOnWebServerConfig?: boolean @@ -144,6 +145,7 @@ export async function parseProject (opts: ProjectParseOpts): Promise { checklyConfigConstructs, playwrightConfigPath, include, + embeddedPackages, playwrightChecks, loadPlaywrightChecksOnly, warnOnWebServerConfig, @@ -183,6 +185,10 @@ export async function parseProject (opts: ProjectParseOpts): Promise { Session.defaultRuntimeId = defaultRuntimeId Session.verifyRuntimeDependencies = verifyRuntimeDependencies ?? true Session.ignoreDirectoriesMatch = ignoreDirectoriesMatch + Session.embeddedPackages = embeddedPackages + // The materializer snapshots specs and workspace paths at first use, so a + // repeated in-process parse with different options must not reuse it. + Session.embeddedPackagesMaterializer = undefined Session.warnOnWebServerConfig = warnOnWebServerConfig Session.packageManager = packageManager Session.workspace = workspace