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..56c397b85 100644 --- a/packages/cli/src/ai-context/references/configure-playwright-checks.md +++ b/packages/cli/src/ai-context/references/configure-playwright-checks.md @@ -12,8 +12,9 @@ - For private packages or custom registries, `.npmrc` is bundled automatically — the workspace-root `.npmrc` and any `.npmrc` beside a package's `package.json` are included by default. You do not need to add `.npmrc` to `include`. - The `.npmrc` should reference a Checkly environment variable such as `${NPM_TOKEN}`. Tell the user that the token must exist in Checkly before `deploy` or `trigger`. Because `.npmrc` is uploaded automatically, warn users to reference credentials via environment variables (`${NPM_TOKEN}`) rather than embedding plaintext tokens. - 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. +- Checkly caches installed dependencies between runs, keyed off the lock file, `package.json` and `.npmrc` contents, and the resolved `bundle.packages.embed` tarball set. 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 `bundle.packages.embed` in `checkly.config.ts` — a top-level section: `bundle: { packages: { embed: ['@acme/private-utils', 'legacy-private-pkg@2.1.0'] } }`. 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. Changing the resolved set of embedded packages invalidates the runner's dependency cache, so the next run reinstalls with the new tarballs. 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..b14e03078 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.bundle?.packages?.embed, playwrightChecks: checklyConfig.checks?.playwrightChecks, loadPlaywrightChecksOnly: emulatePwTest, warnOnWebServerConfig: emulatePwTest && !(includeFlag.length > 0), @@ -167,6 +168,7 @@ export default class ParseProjectCommand extends Command { const bundler = await Bundler.createForWorkspace(Session.workspace.unwrap(), { dependencyCacheVersion: checklyConfig.caching?.dependencyCache?.version, + embeddedPackagesMaterializer: Session.getEmbeddedPackagesMaterializer(), }) const bundleStartedAt = performance.now() diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index ce630c908..d7da311c1 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.bundle?.packages?.embed, playwrightChecks: checklyConfig.checks?.playwrightChecks, }) const repoInfo = getGitInformation(project.repoUrl) @@ -209,6 +210,7 @@ export default class Deploy extends AuthCommand { const bundler = await Bundler.createForWorkspace(Session.workspace.unwrap(), { dependencyCacheVersion: checklyConfig.caching?.dependencyCache?.version, + embeddedPackagesMaterializer: Session.getEmbeddedPackagesMaterializer(), }) this.style.actionStart('Bundling project resources') diff --git a/packages/cli/src/commands/pw-test.ts b/packages/cli/src/commands/pw-test.ts index e7c2b1af5..f570512d1 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.bundle?.packages?.embed, playwrightChecks: [playwrightCheck], loadPlaywrightChecksOnly: true, warnOnWebServerConfig: !(includeFlag.length > 0), @@ -266,6 +267,7 @@ export default class PwTestCommand extends AuthCommand { const bundler = await Bundler.createForWorkspace(Session.workspace.unwrap(), { dependencyCacheVersion: checklyConfig.caching?.dependencyCache?.version, + embeddedPackagesMaterializer: Session.getEmbeddedPackagesMaterializer(), }) this.style.actionStart('Bundling project resources') diff --git a/packages/cli/src/commands/test.ts b/packages/cli/src/commands/test.ts index a7e284caa..7dcd4c1d9 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.bundle?.packages?.embed, playwrightChecks: checklyConfig.checks?.playwrightChecks, checkFilter: check => { if (check instanceof HeartbeatMonitor) { @@ -291,6 +292,7 @@ export default class Test extends AuthCommand { const bundler = await Bundler.createForWorkspace(Session.workspace.unwrap(), { dependencyCacheVersion: checklyConfig.caching?.dependencyCache?.version, + embeddedPackagesMaterializer: Session.getEmbeddedPackagesMaterializer(), }) this.style.actionStart('Bundling project resources') diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index b1a0aed72..b06c1ef4d 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.bundle?.packages?.embed, 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..8275a9c72 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/checkly.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'checkly' + +const config = defineConfig({ + projectName: 'Check Fixture', + logicalId: 'check-fixture', + checks: { + checkMatch: '**/*.check.ts', + ignoreDirectoriesMatch: [], + playwrightConfigPath: './playwright.config.ts', + playwrightChecks: [ + { + logicalId: 'playwright-check-suite', + name: 'Playwright Check Suite', + } + ], + }, + bundle: { + packages: { + embed: ['no-such-package'], + }, + }, +}) + +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..bda98b98a --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/checkly.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'checkly' + +const config = defineConfig({ + projectName: 'Check Fixture', + logicalId: 'check-fixture', + checks: { + checkMatch: '**/*.check.ts', + ignoreDirectoriesMatch: [], + playwrightConfigPath: './subdir/playwright.config.ts', + playwrightChecks: [ + { + logicalId: 'playwright-check-suite', + name: 'Playwright Check Suite', + } + ], + }, + bundle: { + packages: { + embed: ['@acme/private-utils'], + }, + }, +}) + +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..3659eb4ba --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/checkly.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'checkly' + +const config = defineConfig({ + projectName: 'Check Fixture', + logicalId: 'check-fixture', + checks: { + checkMatch: '**/*.check.ts', + ignoreDirectoriesMatch: [], + playwrightConfigPath: './playwright.config.ts', + playwrightChecks: [ + { + logicalId: 'playwright-check-suite', + name: 'Playwright Check Suite', + } + ], + }, + bundle: { + packages: { + embed: ['@acme/private-utils', 'legacy-private-pkg@2.1.0'], + }, + }, +}) + +export default config diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/checkly.one-package.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/checkly.one-package.config.ts new file mode 100644 index 000000000..d6a9602f7 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/checkly.one-package.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'checkly' + +// Same project as checkly.config.ts but with a smaller embed list, so tests +// can assert that the resolved embedded-packages set influences the payload +// cacheHash. +const config = defineConfig({ + projectName: 'Check Fixture', + logicalId: 'check-fixture', + checks: { + checkMatch: '**/*.check.ts', + ignoreDirectoriesMatch: [], + playwrightConfigPath: './playwright.config.ts', + playwrightChecks: [ + { + logicalId: 'playwright-check-suite', + name: 'Playwright Check Suite', + } + ], + }, + bundle: { + packages: { + embed: ['@acme/private-utils'], + }, + }, +}) + +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..e6e02de20 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,136 @@ 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) + + it('should change the payload cacheHash when the embed list changes', async () => { + const cacheHashFor = async (...args: string[]): Promise => { + const output = await parseProjectWithOptions(fixt, { env: { CHECKLY_CACHE_DIR: cacheDir } }, ...args) + expect(output.diagnostics.fatal).toBe(false) + const cacheHash = (output.payload.resources[0].payload as any).cacheHash + expect(cacheHash).toMatch(/^[0-9a-f]{64}$/) + return cacheHash + } + + const bothPackages = await cacheHashFor() + const onePackage = await cacheHashFor('--config', 'checkly.one-package.config.ts') + + expect(onePackage).not.toBe(bothPackages) + }, 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..ee9e299c9 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/project-embedded-packages.spec.ts @@ -0,0 +1,207 @@ +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' +import { wrap } from '../../helpers/wrap.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 === 'bundle.packages.embed')) + } + + 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 === 'bundle.packages.embed')).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 by problem type', async () => { + const project = setupProject() + Session.embeddedPackages = ['Bad Name!', 'missing-one', 'present-pkg@9.9.9', 'present-git'] + + const observations = await validateEmbeddedDiagnostics(project) + expect(observations).toHaveLength(1) + expect(observations[0]).toBeInstanceOf(InvalidPropertyValueDiagnostic) + const { message } = observations[0] + // Exact block: pins the group order, the per-entry shape of every + // group, and that the lockfile is named once in the header rather + // than once per entry. + expect(message).toContain([ + `4 entries have problems (lockfile: '${path.join(dir, 'pnpm-lock.yaml')}'):`, + ``, + ` Invalid entries:`, + ` - 'Bad Name!': 'Bad Name!' is not a valid npm package name`, + ``, + ` Not found in the lockfile — make sure the packages are installed`, + ` and the entries are spelled correctly:`, + ` - 'missing-one'`, + ``, + ` Found, but not at the pinned version:`, + ` - 'present-pkg@9.9.9' (lockfile has: 1.0.0)`, + ``, + ` Cannot be embedded as registry tarballs:`, + ` - 'present-git': 'present-git@https://codeload.github.com/user/present-git/tar.gz/abc'` + + ` resolves to a git, file or URL dependency, which cannot be embedded as a registry tarball`, + ].join('\n')) + expect(message.match(/pnpm-lock\.yaml/g)).toHaveLength(1) + + // The command renderer re-wraps messages at 78 columns with a 2-space + // prefix, dedenting overflow lines to that prefix. Group headers are + // pre-wrapped to survive it: rendered this way, the header keeps its + // indentation and its entries stay deeper than it. + const rendered = wrap(message, { prefix: ' ', length: 78 }) + expect(rendered).toContain([ + ` Not found in the lockfile — make sure the packages are installed`, + ` and the entries are spelled correctly:`, + ` - 'missing-one'`, + ].join('\n')) + }) + + it('does not credit the lockfile when no entry was resolved against it', async () => { + const project = setupProject() + Session.embeddedPackages = ['Bad Name!', 'Worse Name!'] + + const observations = await validateEmbeddedDiagnostics(project) + expect(observations).toHaveLength(1) + const { message } = observations[0] + expect(message).toContain('2 entries have problems:') + expect(message).not.toContain('pnpm-lock.yaml') + }) + + 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..bd95a00b8 100644 --- a/packages/cli/src/constructs/project.ts +++ b/packages/cli/src/constructs/project.ts @@ -6,12 +6,18 @@ 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 { EmbeddedPackagesIssue } from '../services/embedded-packages/materializer.js' import { Session } from './session.js' // Cap how many constructs bundle concurrently. Bundling parses and resolves @@ -110,6 +116,61 @@ export class Project extends Construct { ) diagnostics.extend(...constructDiagnostics) + + await this.#validateEmbeddedPackages(diagnostics) + } + + /** + * Validates the project-wide `bundle.packages.embed` 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, lockfilePath } = 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('bundle.packages.embed', new Error(specIssues[0].message))) + } else if (specIssues.length > 1) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'bundle.packages.embed', + new Error(formatEmbeddedPackagesSpecIssues(specIssues, lockfilePath)), + )) + } } allowTestOnly (enabled: boolean) { @@ -207,3 +268,67 @@ export class Project extends Construct { .map((construct: Check) => construct.logicalId) } } + +type EmbeddedPackagesSpecIssueType = Exclude< + EmbeddedPackagesIssue['type'], + 'missing-lockfile' | 'unsupported-lockfile' +> + +// Exhaustive over the per-entry issue types (and rendered in this order), +// so that adding a type to EmbeddedPackagesIssue without a header here is +// a compile error rather than an entry silently missing from the output. +// +// The command style renderer re-wraps at 78 columns and dedents overflow +// to its own base prefix, destroying the grouping indentation — so any +// header longer than that is pre-wrapped here, with the continuation +// carrying the same two-space indent the section builder adds. +const EMBEDDED_PACKAGES_SPEC_ISSUE_HEADERS: Record = { + 'invalid-spec': `Invalid entries:`, + 'spec-not-found': `Not found in the lockfile — make sure the packages are installed\n` + + ` and the entries are spelled correctly:`, + 'spec-version-not-found': `Found, but not at the pinned version:`, + 'spec-not-embeddable': `Cannot be embedded as registry tarballs:`, +} + +/** + * Renders multiple per-entry embedded-package issues as one message, + * grouping entries by problem so that shared explanations — and the + * lockfile path — appear once instead of once per entry. + */ +function formatEmbeddedPackagesSpecIssues (issues: EmbeddedPackagesIssue[], lockfilePath?: string): string { + const types = Object.keys(EMBEDDED_PACKAGES_SPEC_ISSUE_HEADERS) as EmbeddedPackagesSpecIssueType[] + + const sections = types.flatMap(type => { + const members = issues.filter(issue => issue.type === type) + if (members.length === 0) { + return [] + } + const entries = members.map(issue => { + const spec = `'${issue.spec}'` + if (issue.detail === undefined) { + return ` - ${spec}` + } + // A short parenthetical (available versions) reads best inline; the + // longer not-embeddable reasons and parse errors follow a colon. + return issue.type === 'spec-version-not-found' + ? ` - ${spec} (${issue.detail})` + : ` - ${spec}: ${issue.detail}` + }) + return [` ${EMBEDDED_PACKAGES_SPEC_ISSUE_HEADERS[type]}\n${entries.join('\n')}`] + }) + + // Never let the header count entries the body does not show: an issue + // type without a heading (e.g. a lockfile-level issue misrouted here) + // still renders, via its standalone message. + const leftovers = issues.filter(issue => !(issue.type in EMBEDDED_PACKAGES_SPEC_ISSUE_HEADERS)) + if (leftovers.length > 0) { + sections.push(` Other problems:\n${leftovers.map(issue => ` - ${issue.message}`).join('\n')}`) + } + + // Credit the lockfile only when an entry was actually resolved against + // it — invalid entries fail parsing before any lockfile lookup, so a + // group of only those must not imply a lockfile search came up empty. + const lockfileResolved = issues.some(issue => issue.type !== 'invalid-spec') + const lockfile = lockfileResolved && lockfilePath !== undefined ? ` (lockfile: '${lockfilePath}')` : '' + return `${issues.length} entries have problems${lockfile}:\n\n${sections.join('\n\n')}` +} diff --git a/packages/cli/src/constructs/session.ts b/packages/cli/src/constructs/session.ts index 1af9d900f..23bb52ec9 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 `bundle.packages.embed` 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/rest/__tests__/errors.spec.ts b/packages/cli/src/rest/__tests__/errors.spec.ts index f631bed29..59d58e429 100644 --- a/packages/cli/src/rest/__tests__/errors.spec.ts +++ b/packages/cli/src/rest/__tests__/errors.spec.ts @@ -1,7 +1,7 @@ import { AxiosError, type InternalAxiosRequestConfig } from 'axios' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { handleErrorResponse, MissingResponseError, ProxyConnectionError } from '../errors.js' +import { handleErrorResponse, MissingResponseError, PayloadTooLargeError, ProxyConnectionError } from '../errors.js' const proxyVars = ['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY', 'no_proxy', 'NO_PROXY', 'all_proxy', 'ALL_PROXY'] const savedEnv: Record = {} @@ -77,3 +77,32 @@ describe('handleErrorResponse without a proxy', () => { } }) }) + +function responseError (status: number, data: unknown): AxiosError { + const config = { baseURL: 'https://api.checklyhq.com', url: '/next/checkly-storage/upload-code-bundle' } as + InternalAxiosRequestConfig + return new AxiosError('failed', 'ERR_BAD_REQUEST', config, {}, { + status, + statusText: 'error', + headers: {}, + config, + data, + }) +} + +describe('handleErrorResponse for a 413 response', () => { + it('maps the response to a PayloadTooLargeError preserving the server message', () => { + try { + handleErrorResponse(responseError(413, { + statusCode: 413, + error: 'Request Entity Too Large', + message: 'Payload content length greater than maximum allowed: 31457280', + })) + expect.unreachable() + } catch (err) { + expect(err).toBeInstanceOf(PayloadTooLargeError) + expect((err as PayloadTooLargeError).data.message) + .toBe('Payload content length greater than maximum allowed: 31457280') + } + }) +}) diff --git a/packages/cli/src/rest/errors.ts b/packages/cli/src/rest/errors.ts index d0ba3a742..ecdf22027 100644 --- a/packages/cli/src/rest/errors.ts +++ b/packages/cli/src/rest/errors.ts @@ -79,6 +79,17 @@ export class ConflictError extends ApiError { } } +/** + * Error thrown when an API response indicates that the request payload + * exceeded the maximum size the endpoint accepts. + */ +export class PayloadTooLargeError extends ApiError { + constructor (data: ErrorData, options?: ErrorOptions) { + super(data, options) + this.name = 'PayloadTooLargeError' + } +} + /** * Error thrown when an API response indicates a server error. */ @@ -356,6 +367,10 @@ export function handleErrorResponse (err: Error): never { throw new ConflictError(errorData, { cause: err }) } + if (statusCode === 413) { + throw new PayloadTooLargeError(errorData, { cause: err }) + } + if (statusCode >= 500) { throw new ServerError(errorData, { cause: err }) } 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..c8d11edf4 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,44 @@ 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 bundle.packages.embed entries', async () => { + const { config } = await loadChecklyConfig( + path.join(__dirname, 'fixtures', 'configs'), + ['embedded-packages-valid.ts'], + ) + expect(config.bundle?.packages?.embed) + .toEqual(['@acme/private-utils', 'legacy-private-pkg@2.1.0', '@acme/*', 'acme-*']) + }) + it('rejects a bundle.packages.embed that is not an array', async () => { + await expect(loadChecklyConfig( + path.join(__dirname, 'fixtures', 'configs'), + ['embedded-packages-not-array.js'], + )).rejects.toThrow(`Config field 'bundle.packages.embed' must be an array of strings if set`) + }) + it('rejects a bundle that is not an object', async () => { + await expect(loadChecklyConfig( + path.join(__dirname, 'fixtures', 'configs'), + ['embedded-packages-bundle-not-object.js'], + )).rejects.toThrow(`Config field 'bundle' must be an object if set`) + }) + it('rejects a bundle.packages that is not an object', async () => { + await expect(loadChecklyConfig( + path.join(__dirname, 'fixtures', 'configs'), + ['embedded-packages-packages-not-object.js'], + )).rejects.toThrow(`Config field 'bundle.packages' must be an object if set`) + }) + it('rejects a bundle.packages.embed 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 bundle.packages.embed 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..d3db20984 --- /dev/null +++ b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-bad-name.js @@ -0,0 +1,13 @@ +// Plain JS on purpose: bypasses the TypeScript type of ChecklyConfig so the +// runtime validation of bundle.packages.embed is what rejects it. +const config = { + projectName: 'test-config-project', + logicalId: 'test-config-project', + bundle: { + packages: { + embed: ['Not A Valid Name'], + }, + }, +} + +export default config diff --git a/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-bundle-not-object.js b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-bundle-not-object.js new file mode 100644 index 000000000..39a6792e0 --- /dev/null +++ b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-bundle-not-object.js @@ -0,0 +1,9 @@ +// Plain JS on purpose: bypasses the TypeScript type of ChecklyConfig so the +// runtime validation of the bundle section is what rejects it. +const config = { + projectName: 'test-config-project', + logicalId: 'test-config-project', + bundle: ['@acme/private-utils'], +} + +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..7e08030a8 --- /dev/null +++ b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-not-array.js @@ -0,0 +1,13 @@ +// Plain JS on purpose: bypasses the TypeScript type of ChecklyConfig so the +// runtime validation of bundle.packages.embed is what rejects it. +const config = { + projectName: 'test-config-project', + logicalId: 'test-config-project', + bundle: { + packages: { + embed: '@acme/private-utils', + }, + }, +} + +export default config diff --git a/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-packages-not-object.js b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-packages-not-object.js new file mode 100644 index 000000000..dcef64bf1 --- /dev/null +++ b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-packages-not-object.js @@ -0,0 +1,13 @@ +// Plain JS on purpose: bypasses the TypeScript type of ChecklyConfig so the +// runtime validation of the bundle section is what rejects it. Putting the +// embed list directly under `packages` is a plausible mistake that would +// otherwise silently disable embedding. +const config = { + projectName: 'test-config-project', + logicalId: 'test-config-project', + bundle: { + packages: ['@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..b1ba4a9ef --- /dev/null +++ b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-range-version.js @@ -0,0 +1,13 @@ +// Plain JS on purpose: bypasses the TypeScript type of ChecklyConfig so the +// runtime validation of bundle.packages.embed is what rejects it. +const config = { + projectName: 'test-config-project', + logicalId: 'test-config-project', + bundle: { + packages: { + embed: ['@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..f54da367e --- /dev/null +++ b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-valid.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'checkly' + +const config = defineConfig({ + projectName: 'test-config-project', + logicalId: 'test-config-project', + bundle: { + packages: { + embed: ['@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/check-parser/__tests__/bundler.spec.ts b/packages/cli/src/services/check-parser/__tests__/bundler.spec.ts new file mode 100644 index 000000000..29ebab1e1 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/bundler.spec.ts @@ -0,0 +1,199 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { BundleArchive, BundleTooLargeError, Bundler, FinalizedBundleArchive } from '../bundler.js' +import { Package, Workspace } from '../package-files/workspace.js' +import { Err, Ok } from '../package-files/result.js' +import { EmbeddedPackagesMaterializer } from '../../embedded-packages/materializer.js' +import { PayloadTooLargeError } from '../../../rest/errors.js' + +const uploadCodeBundle = vi.hoisted(() => vi.fn()) + +vi.mock('../../../rest/api.js', () => ({ + checklyStorage: { + uploadCodeBundle, + }, +})) + +describe('BundleTooLargeError', () => { + it('names both sizes when the server reports its limit', () => { + const err = new BundleTooLargeError({ + sizeBytes: 44 * 1048576, + maxBytes: 30 * 1048576, + }) + expect(err.message).toContain('the compressed bundle is 44 MB') + expect(err.message).toContain('the Checkly API accepts at most 30 MB') + expect(err.sizeBytes).toBe(44 * 1048576) + expect(err.maxBytes).toBe(30 * 1048576) + }) + + it('cannot render two equal figures for a bundle just over the limit', () => { + const err = new BundleTooLargeError({ + sizeBytes: 30 * 1048576 + 1, + maxBytes: 30 * 1048576, + }) + expect(err.message).toContain('the compressed bundle is 30.1 MB') + expect(err.message).toContain('the Checkly API accepts at most 30 MB') + }) + + it('degrades gracefully when the limit is unknown', () => { + const err = new BundleTooLargeError({ + sizeBytes: 45613957, + }) + expect(err.message).toContain('the compressed bundle is 43.6 MB') + expect(err.message).toContain('which exceeds what the upload endpoint accepts') + }) + + it('does not attribute a limit that would render as 0 MB', () => { + const err = new BundleTooLargeError({ + sizeBytes: 1048576, + maxBytes: 65536, + }) + expect(err.message).not.toContain('0 MB') + expect(err.message).toContain('which exceeds what the upload endpoint accepts') + }) + + it('suggests embedding fewer packages only when the bundle embeds some', () => { + const without = new BundleTooLargeError({ sizeBytes: 1048576 }) + expect(without.message).not.toContain('bundle.packages.embed') + + const withPackages = new BundleTooLargeError({ sizeBytes: 1048576, containsEmbeddedPackages: true }) + expect(withPackages.message).toContain(`embedding fewer private packages ('bundle.packages.embed')`) + }) +}) + +describe('FinalizedBundleArchive.store()', () => { + let dir: string + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-bundler-')) + }) + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }) + uploadCodeBundle.mockReset() + }) + + function reject413 (message: string) { + uploadCodeBundle.mockRejectedValue(new PayloadTooLargeError({ + statusCode: 413, + error: 'Request Entity Too Large', + message, + })) + } + + it('turns a 413 rejection into a BundleTooLargeError naming both sizes', async () => { + const archiveFile = path.join(dir, 'playwright-project.tar.gz') + await fs.writeFile(archiveFile, Buffer.alloc(2 * 1048576)) + + reject413('Payload content length greater than maximum allowed: 1048576') + + const archive = await FinalizedBundleArchive.create({ archiveFile }) + const failure = await archive.store().catch(err => err) + expect(failure).toBeInstanceOf(BundleTooLargeError) + expect(failure.message).toMatch( + /code bundle is too large to upload: the compressed bundle is 2 MB, but the Checkly API accepts at most 1 MB/, + ) + expect(failure.message).not.toContain('bundle.packages.embed') + }) + + it('handles a 413 response that does not name the limit', async () => { + const archiveFile = path.join(dir, 'playwright-project.tar.gz') + await fs.writeFile(archiveFile, Buffer.alloc(1048576)) + + reject413('Request Entity Too Large') + + const archive = await FinalizedBundleArchive.create({ archiveFile }) + await expect(archive.store()).rejects.toThrow('which exceeds what the upload endpoint accepts') + }) + + it('rethrows other upload failures untouched', async () => { + const archiveFile = path.join(dir, 'playwright-project.tar.gz') + await fs.writeFile(archiveFile, 'data') + + uploadCodeBundle.mockRejectedValue(new Error('boom')) + + const archive = await FinalizedBundleArchive.create({ archiveFile }) + await expect(archive.store()).rejects.toThrow('boom') + }) +}) + +describe('BundleArchive embedded package detection', () => { + let dir: string + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-bundler-')) + }) + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }) + uploadCodeBundle.mockReset() + }) + + it('flags archives containing embedded package tarballs so a 413 mentions them', async () => { + const tarballFile = path.join(dir, 'acme+foo@1.0.0.tgz') + await fs.writeFile(tarballFile, 'tarball-bytes') + + const bundle = await BundleArchive.create({ tempDir: path.join(dir, 'archive') }) + await bundle.add({ + physical: true, + filePath: tarballFile, + // The shape the embedded-packages materializer produces: a physical + // file with an explicit archive path at the bundle contract location. + archivePath: '.checkly/embedded-packages/acme+foo@1.0.0.tgz', + }) + const archive = await bundle.finalize() + + uploadCodeBundle.mockRejectedValue(new PayloadTooLargeError({ + statusCode: 413, + error: 'Request Entity Too Large', + message: 'Payload content length greater than maximum allowed: 31457280', + })) + + await expect(archive.store()).rejects.toThrow(`'bundle.packages.embed'`) + }) +}) + +describe('Bundler.createForWorkspace', () => { + let dir: string + + beforeEach(async () => { + dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-bundler-'))) + await fs.writeFile(path.join(dir, 'package.json'), '{"name":"fixture-root"}\n') + }) + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }) + }) + + it('mixes the embedded packages materializer plan into the cache hash', async () => { + const lockfilePath = path.join(dir, 'pnpm-lock.yaml') + await fs.writeFile(lockfilePath, [ + `lockfileVersion: '9.0'`, + `packages:`, + ` '@acme/foo@1.2.3':`, + ` resolution: {integrity: sha512-aaa}`, + ``, + ].join('\n')) + const workspace = new Workspace({ + root: new Package({ name: 'fixture-root', path: dir }), + packages: [], + lockfile: Ok(lockfilePath), + configFile: Err(new Error('no config file')), + }) + + const without = await Bundler.createForWorkspace(workspace) + const withFoo = await Bundler.createForWorkspace(workspace, { + embeddedPackagesMaterializer: new EmbeddedPackagesMaterializer({ + specs: ['@acme/foo'], + lockfilePath, + workspaceRoot: dir, + }), + }) + + expect(without.cacheHash).not.toBe(withFoo.cacheHash) + }) +}) diff --git a/packages/cli/src/services/check-parser/__tests__/cache-hash.spec.ts b/packages/cli/src/services/check-parser/__tests__/cache-hash.spec.ts index 71b564a84..c788d86e5 100644 --- a/packages/cli/src/services/check-parser/__tests__/cache-hash.spec.ts +++ b/packages/cli/src/services/check-parser/__tests__/cache-hash.spec.ts @@ -323,6 +323,70 @@ describe('composeCacheHash', () => { expect(without).not.toBe(withVersion) }) + test('omitting embeddedPackages matches passing an empty list (no-op)', () => { + const root = buf('{"name":"root"}') + const lockfile = { name: 'package-lock.json', hash: sha256('lock') } + expect(composeCacheHash({ + lockfile, + packageJsons: [{ path: 'package.json', raw: root }], + excludedFields: ['version'], + })).toBe(composeCacheHash({ + lockfile, + packageJsons: [{ path: 'package.json', raw: root }], + embeddedPackages: [], + excludedFields: ['version'], + })) + }) + + test('adding an embedded package changes the hash', () => { + const root = buf('{"name":"root"}') + const lockfile = { name: 'package-lock.json', hash: sha256('lock') } + const without = composeCacheHash({ + lockfile, + packageJsons: [{ path: 'package.json', raw: root }], + excludedFields: ['version'], + }) + const withEmbedded = composeCacheHash({ + lockfile, + packageJsons: [{ path: 'package.json', raw: root }], + embeddedPackages: [{ name: '@acme/foo', version: '1.2.3', integrity: 'sha512-aaa' }], + excludedFields: ['version'], + }) + expect(without).not.toBe(withEmbedded) + }) + + test('embedded packages hash independently of their input order', () => { + const root = buf('{"name":"root"}') + const lockfile = { name: 'package-lock.json', hash: sha256('lock') } + const foo = { name: '@acme/foo', version: '1.2.3', integrity: 'sha512-aaa' } + const bar = { name: 'bar', version: '2.0.0', integrity: 'sha512-bbb' } + expect(composeCacheHash({ + lockfile, + packageJsons: [{ path: 'package.json', raw: root }], + embeddedPackages: [foo, bar], + excludedFields: ['version'], + })).toBe(composeCacheHash({ + lockfile, + packageJsons: [{ path: 'package.json', raw: root }], + embeddedPackages: [bar, foo], + excludedFields: ['version'], + })) + }) + + test('changing an embedded package version or integrity changes the hash', () => { + const root = buf('{"name":"root"}') + const lockfile = { name: 'package-lock.json', hash: sha256('lock') } + const compose = (version: string, integrity: string) => composeCacheHash({ + lockfile, + packageJsons: [{ path: 'package.json', raw: root }], + embeddedPackages: [{ name: '@acme/foo', version, integrity }], + excludedFields: ['version'], + }) + expect(compose('1.2.3', 'sha512-aaa')).toBe(compose('1.2.3', 'sha512-aaa')) + expect(compose('1.2.3', 'sha512-aaa')).not.toBe(compose('1.2.4', 'sha512-aaa')) + expect(compose('1.2.3', 'sha512-aaa')).not.toBe(compose('1.2.3', 'sha512-bbb')) + }) + test('changing the dependencyCacheVersion changes the hash, same value is stable', () => { const root = buf('{"name":"root"}') const lockfile = { name: 'package-lock.json', hash: sha256('lock') } @@ -342,11 +406,14 @@ describe('composeCacheHash', () => { // fixture, mirror the change in the TF provider's test suite. // // NOTE: composeCacheHash also hashes `npmrc:` records (added for .npmrc - // bundling) and a `dependency-cache-version` record (the user-provided - // caching.dependencyCache.version config value). This fixture uses - // neither so the digest is unchanged, but projects that DO have an - // .npmrc or set a dependency cache version will hash differently until - // the TF provider mirrors those record types. + // bundling), `embedded-package:` records (the resolved + // bundle.packages.embed tarball set), and a `dependency-cache-version` + // record (the user-provided caching.dependencyCache.version config + // value). This fixture uses none of them so the digest is unchanged, but + // projects that DO have an .npmrc, embed packages, or set a dependency + // cache version will hash differently until the TF provider mirrors + // those record types. The fixture two tests down pins all three optional + // record groups together, in order. test('matches the cross-language parity fixture digest', () => { const lockfileBytes = buf('{"lockfileVersion":3}\n') const rootPackageJson = buf([ @@ -423,6 +490,48 @@ describe('composeCacheHash', () => { expect(digest).toBe('344f037a55163ba59146d9cdb71ef702782e3690a9f666067c0ccdf091214eb6') }) + + // Same parity contract as above, but with all four optional record groups + // present, pinning the full record order (npmrc records, then + // embedded-package records, then the dependency-cache-version record) and + // the sort of the `name@version` record labels. The TF provider must + // produce this exact digest once it mirrors the embedded-package record + // type. + test('matches the cross-language parity fixture digest with embedded packages', () => { + const lockfileBytes = buf('{"lockfileVersion":3}\n') + const rootPackageJson = buf([ + '{', + ' "name": "fixture-root",', + ' "version": "0.0.0-SNAPSHOT",', + ' "private": true,', + ' "dependencies": {', + ' "@acme/foo": "1.2.3"', + ' }', + '}', + '', + ].join('\n')) + + const digest = composeCacheHash({ + lockfile: { + name: 'package-lock.json', + hash: createHash('sha256').update(lockfileBytes).digest(), + }, + packageJsons: [ + { path: 'package.json', raw: rootPackageJson }, + ], + npmrcs: [ + { path: '.npmrc', hash: sha256('registry=https://registry.example.com/\n') }, + ], + embeddedPackages: [ + { name: 'bar', version: '2.0.0', integrity: 'sha512-bbb' }, + { name: '@acme/foo', version: '1.2.3', integrity: 'sha512-aaa' }, + ], + excludedFields: ['version'], + dependencyCacheVersion: '2', + }) + + expect(digest).toBe('4d9ce4b49fe543b4ec303ae17b1e98c7c9d0e37d8e17c57e78b36555abdf5207') + }) }) describe('computeWorkspaceCacheHash', () => { @@ -457,6 +566,15 @@ describe('computeWorkspaceCacheHash', () => { expect(await computeWorkspaceCacheHash(workspace, { dependencyCacheVersion: 2 })) .toBe(await computeWorkspaceCacheHash(workspace, { dependencyCacheVersion: '2' })) }) + + test('embeddedPackages option flows into the hash', async () => { + const workspace = await makeWorkspace() + const base = await computeWorkspaceCacheHash(workspace) + expect(base).toBe(await computeWorkspaceCacheHash(workspace, { embeddedPackages: [] })) + expect(base).not.toBe(await computeWorkspaceCacheHash(workspace, { + embeddedPackages: [{ name: '@acme/foo', version: '1.2.3', integrity: 'sha512-aaa' }], + })) + }) }) describe('normalizeDependencyCacheVersion', () => { diff --git a/packages/cli/src/services/check-parser/bundler.ts b/packages/cli/src/services/check-parser/bundler.ts index e5f80f493..50edba193 100644 --- a/packages/cli/src/services/check-parser/bundler.ts +++ b/packages/cli/src/services/check-parser/bundler.ts @@ -1,3 +1,4 @@ +import { once } from 'node:events' import { createReadStream, createWriteStream, WriteStream } from 'node:fs' import fs from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -9,6 +10,8 @@ import Debug from 'debug' import * as uuid from 'uuid' import { checklyStorage } from '../../rest/api.js' +import { PayloadTooLargeError } from '../../rest/errors.js' +import { EMBEDDED_PACKAGES_ARCHIVE_DIR, EmbeddedPackagesMaterializer } from '../embedded-packages/materializer.js' import { computeWorkspaceCacheHash, ComputeWorkspaceCacheHashOptions } from './cache-hash.js' import { File } from './parser.js' import { Workspace } from './package-files/workspace.js' @@ -119,6 +122,7 @@ export class BundleArchive { #archiveFileWriteStream: WriteStream #stripPrefix?: string #archive: Archiver + #containsEmbeddedPackages = false private constructor (options: BundleArchiveOptions) { const { @@ -198,6 +202,10 @@ export class BundleArchive { for (const [index, file] of files.entries()) { const name = archivePath(file, this.#stripPrefix) + if (name.startsWith(`${EMBEDDED_PACKAGES_ARCHIVE_DIR}/`)) { + this.#containsEmbeddedPackages = true + } + const entry = { mode: 0o755, // Default mode for files in the archive name, @@ -232,6 +240,7 @@ export class BundleArchive { return await FinalizedBundleArchive.create({ archiveFile: this.#archiveFile, + containsEmbeddedPackages: this.#containsEmbeddedPackages, }) } @@ -245,23 +254,101 @@ export class BundleArchive { } } +export interface BundleTooLargeErrorOptions { + sizeBytes: number + maxBytes?: number + containsEmbeddedPackages?: boolean + cause?: unknown +} + +/** + * Error thrown when the Checkly API rejects the code bundle upload because + * the bundle exceeds the maximum size the API accepts (HTTP 413). The size + * limit is enforced server-side and is not known ahead of time; it is parsed + * from the response when the server names it. + */ +export class BundleTooLargeError extends Error { + readonly sizeBytes: number + readonly maxBytes?: number + readonly containsEmbeddedPackages: boolean + + constructor (options: BundleTooLargeErrorOptions) { + const { + sizeBytes, + maxBytes, + containsEmbeddedPackages, + cause, + } = options + + // Round the bundle size up and the limit down so that a bundle just + // barely over the limit cannot render as two equal figures ("the + // compressed bundle is 30 MB, but the Checkly API accepts at most + // 30 MB"). + const size = formatMegabytes(sizeBytes, Math.ceil) + + // Attribute the limit to the Checkly API only when a plausible one is + // known (given, and not so small that it floors to "0 MB"). A 413 can + // also come from an intermediary (e.g. a corporate proxy with its own + // upload cap), but such a response would not use the API's own message + // phrasing that maxBytes is parsed from, and ends up here undefined. + const formattedLimit = maxBytes !== undefined ? formatMegabytes(maxBytes, Math.floor) : undefined + const limit = formattedLimit !== undefined && formattedLimit !== '0 MB' + ? `but the Checkly API accepts at most ${formattedLimit}` + : `which exceeds what the upload endpoint accepts` + + const remedies = containsEmbeddedPackages + ? `removing large files from the Playwright project, narrowing any 'include' patterns, ` + + `or embedding fewer private packages ('bundle.packages.embed')` + : `removing large files from the Playwright project or narrowing any 'include' patterns` + + super( + `The code bundle is too large to upload: the compressed bundle is ${size}, ${limit}. ` + + `Reduce the bundle size by ${remedies}.`, + { cause }, + ) + this.name = 'BundleTooLargeError' + this.sizeBytes = sizeBytes + this.maxBytes = maxBytes + this.containsEmbeddedPackages = containsEmbeddedPackages ?? false + } +} + +function formatMegabytes (bytes: number, round: (value: number) => number): string { + return `${round(bytes / 1048576 * 10) / 10} MB` +} + +/** + * A 413 response names the size limit only inside hapi's message text + * ("Payload content length greater than maximum allowed: "); there is + * no structured field carrying it. + */ +function parseMaxBytes (message: string): number | undefined { + const match = /maximum allowed: (\d+)/.exec(message) + return match !== null ? Number(match[1]) : undefined +} + export interface CreateFinalizedBundleArchiveOptions { archiveFile: string + containsEmbeddedPackages?: boolean } interface FinalizedBundleArchiveOptions { archiveFile: string + containsEmbeddedPackages?: boolean } export class FinalizedBundleArchive { #archiveFile: string + #containsEmbeddedPackages: boolean private constructor (options: FinalizedBundleArchiveOptions) { const { archiveFile, + containsEmbeddedPackages, } = options this.#archiveFile = archiveFile + this.#containsEmbeddedPackages = containsEmbeddedPackages ?? false } // eslint-disable-next-line require-await @@ -274,24 +361,51 @@ export class FinalizedBundleArchive { } async store (): Promise { - const { - data: { + const { size } = await fs.stat(this.#archiveFile) + + try { + const { + data: { + key, + }, + } = await this.#uploadCodeBundle(this.#archiveFile, size) + + return await RemoteBundleArchive.create({ key, - }, - } = await this.#uploadCodeBundle(this.#archiveFile) + }) + } catch (err) { + if (err instanceof PayloadTooLargeError) { + throw new BundleTooLargeError({ + sizeBytes: size, + maxBytes: parseMaxBytes(err.data.message), + containsEmbeddedPackages: this.#containsEmbeddedPackages, + cause: err, + }) + } - return await RemoteBundleArchive.create({ - key, - }) + throw err + } } - async #uploadCodeBundle (filePath: string): Promise { - const { size } = await fs.stat(filePath) + async #uploadCodeBundle (filePath: string, size: number): Promise { const stream = createReadStream(filePath) stream.on('error', err => { throw new Error(`Failed to read Playwright project file: ${err.message}`) }) - return checklyStorage.uploadCodeBundle(stream, size) + try { + return await checklyStorage.uploadCodeBundle(stream, size) + } finally { + // A failed upload leaves the stream unconsumed and its file handle + // open; on Windows the open handle blocks deleting the archive's + // temp directory. destroy() only schedules the close, so wait for it + // to complete before continuing to any cleanup. (After a fully + // consumed upload the stream has already auto-closed and both calls + // are no-ops.) + stream.destroy() + if (!stream.closed) { + await once(stream, 'close') + } + } } } @@ -331,7 +445,18 @@ export interface CreateBundlerOptions { } export type CreateBundlerForWorkspaceOptions = - Omit & ComputeWorkspaceCacheHashOptions + Omit + & Omit + & { + /** + * The materializer for the project's `bundle.packages.embed` option, + * when set. Its resolved tarball set (name, version, integrity) is mixed + * into the cache hash: embedded tarballs change the runner's install-step + * inputs without necessarily touching the lockfile, so a changed embed + * set must invalidate the dependency cache. + */ + embeddedPackagesMaterializer?: EmbeddedPackagesMaterializer + } interface BundlerOptions { tempDir?: string @@ -376,9 +501,12 @@ export class Bundler { const { tempDir, dependencyCacheVersion, + embeddedPackagesMaterializer, } = options - const cacheHash = await computeWorkspaceCacheHash(workspace, { dependencyCacheVersion }) + const embeddedPackages = (await embeddedPackagesMaterializer?.plan())?.tarballs + + const cacheHash = await computeWorkspaceCacheHash(workspace, { dependencyCacheVersion, embeddedPackages }) return new Bundler({ tempDir, diff --git a/packages/cli/src/services/check-parser/cache-hash.ts b/packages/cli/src/services/check-parser/cache-hash.ts index a624d6a88..ee16caf08 100644 --- a/packages/cli/src/services/check-parser/cache-hash.ts +++ b/packages/cli/src/services/check-parser/cache-hash.ts @@ -37,10 +37,28 @@ export interface NpmrcInput { hash: Buffer } +export interface EmbeddedPackageInput { + /** Package name as recorded in the lockfile, e.g. `@acme/foo`. */ + name: string + /** Exact version, e.g. `1.2.3`. */ + version: string + /** The lockfile's recorded integrity for the artifact (SRI string). */ + integrity: string +} + export interface ComposeCacheHashInput { lockfile?: LockfileInput packageJsons: PackageJsonInput[] npmrcs?: NpmrcInput[] + /** + * The resolved set of embedded package tarballs shipped in the bundle + * (`bundle.packages.embed` after lockfile resolution). Embedded tarballs + * change the runner's install-step inputs without necessarily touching the + * lockfile, so they must contribute to the hash. An empty or absent list + * writes no records, leaving the digest identical to one computed before + * this input existed. + */ + embeddedPackages?: EmbeddedPackageInput[] excludedFields: string[] /** * Optional user-provided cache version, already normalized to a string. @@ -155,17 +173,28 @@ export function canonicalizePackageJson (raw: Buffer, excludedFields: string[]): * Records are written in the following order: * 1. The lockfile record (if present), labeled `lockfile:`, * whose content is the raw 32-byte SHA-256 digest of the lockfile. - * 2. One record per package.json sorted byte-wise by path, labeled + * 2. One record per package.json sorted by path, labeled * `package.json:`, whose content is the canonicalized * package.json bytes. - * 3. One record per .npmrc sorted byte-wise by path, labeled + * 3. One record per .npmrc sorted by path, labeled * `npmrc:`, whose content is the raw 32-byte SHA-256 * digest of the .npmrc contents. - * 4. The dependency cache version record (if set to a non-empty string), + * 4. One record per embedded package sorted by `name@version`, labeled + * `embedded-package:`, whose content is the raw UTF-8 + * bytes of the lockfile's integrity string for the artifact. Callers + * must pass at most one entry per `name@version` (the materializer + * already de-duplicates); the record order among duplicate keys is + * undefined. + * 5. The dependency cache version record (if set to a non-empty string), * labeled `dependency-cache-version`, whose content is the raw UTF-8 * bytes of the user-provided value. An empty string is treated as * absent so that e.g. an unset environment variable interpolated into * the config leaves the digest unchanged. + * + * All sorts compare strings by UTF-16 code unit (JavaScript's `<`/`>`), + * which coincides with byte-wise UTF-8 order for ASCII inputs — the only + * kind that occurs in practice. Mirror implementations in other languages + * must reproduce this order exactly. */ export function composeCacheHash (input: ComposeCacheHashInput): string { const hash = createHash('sha256') @@ -182,27 +211,27 @@ export function composeCacheHash (input: ComposeCacheHashInput): string { writeRecord(`lockfile:${input.lockfile.name}`, input.lockfile.hash) } - const sorted = [...input.packageJsons].sort((a, b) => { - if (a.path < b.path) return -1 - if (a.path > b.path) return 1 - return 0 - }) + const sorted = [...input.packageJsons].sort((a, b) => compareStrings(a.path, b.path)) for (const entry of sorted) { const canonical = canonicalizePackageJson(entry.raw, input.excludedFields) writeRecord(`package.json:${entry.path}`, canonical) } - const sortedNpmrcs = [...(input.npmrcs ?? [])].sort((a, b) => { - if (a.path < b.path) return -1 - if (a.path > b.path) return 1 - return 0 - }) + const sortedNpmrcs = [...(input.npmrcs ?? [])].sort((a, b) => compareStrings(a.path, b.path)) for (const entry of sortedNpmrcs) { writeRecord(`npmrc:${entry.path}`, entry.hash) } + const sortedEmbedded = (input.embeddedPackages ?? []) + .map(entry => ({ key: `${entry.name}@${entry.version}`, integrity: entry.integrity })) + .sort((a, b) => compareStrings(a.key, b.key)) + + for (const entry of sortedEmbedded) { + writeRecord(`embedded-package:${entry.key}`, Buffer.from(entry.integrity, 'utf8')) + } + if (input.dependencyCacheVersion) { writeRecord('dependency-cache-version', Buffer.from(input.dependencyCacheVersion, 'utf8')) } @@ -210,6 +239,12 @@ export function composeCacheHash (input: ComposeCacheHashInput): string { return hash.digest('hex') } +function compareStrings (a: string, b: string): number { + if (a < b) return -1 + if (a > b) return 1 + return 0 +} + function uint64BE (n: number): Buffer { const buf = Buffer.alloc(8) buf.writeBigUInt64BE(BigInt(n)) @@ -289,6 +324,11 @@ export interface ComputeWorkspaceCacheHashOptions { * hash. Undefined and the empty string leave the digest unchanged. */ dependencyCacheVersion?: string | number + /** + * The resolved set of embedded package tarballs shipped in the bundle. + * See {@link ComposeCacheHashInput.embeddedPackages}. + */ + embeddedPackages?: EmbeddedPackageInput[] } /** @@ -323,6 +363,7 @@ export async function computeWorkspaceCacheHash ( const inputs = await loadWorkspaceCacheHashInputs(workspace) return composeCacheHash({ ...inputs, + embeddedPackages: options?.embeddedPackages, excludedFields: PACKAGE_JSON_EXCLUDED_FIELDS, dependencyCacheVersion: normalizeDependencyCacheVersion(options?.dependencyCacheVersion), }) diff --git a/packages/cli/src/services/checkly-config-loader.ts b/packages/cli/src/services/checkly-config-loader.ts index d9bb91ea8..1ff48c7d7 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..991342744 --- /dev/null +++ b/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts @@ -0,0 +1,595 @@ +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 Debug from 'debug' +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 without writing to stderr', async () => { + // A bare * matches bar (registry, both versions) and git-dep (a git + // dependency the CLI cannot embed): the registry matches embed and + // the git dependency surfaces as a plan warning naming it. The + // wildcard's selection itself is debug-logged only — materializing + // writes nothing to stderr that would interrupt styled command + // output. + 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() + }) + // 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('debug-logs what a wildcard selected during planning', async () => { + // The wildcard selection has no user-facing output; the debug channel + // is the only place it is visible, so pin that it actually fires — + // and fires during plan(), covering validate-only and failing runs. + const previouslyEnabled = Debug.disable() + Debug.enable('checkly:cli:services:embedded-packages') + try { + const written = await captureStderr(async () => { + await makeMaterializer(['*']).plan() + }) + expect(written.join('')).toContain('pattern * matched 2 package(s)') + } finally { + Debug.enable(previouslyEnabled) + } + }) + + 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-version-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, naming the available versions', async () => { + const { issues } = await makeMaterializer(['bar@9.9.9']).plan() + expect(issues[0].type).toBe('spec-version-not-found') + expect(issues[0].message).toContain('none of them at version 9.9.9') + expect(issues[0].detail).toContain('lockfile has: ') + }) + + 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..46d0efa7b --- /dev/null +++ b/packages/cli/src/services/embedded-packages/materializer.ts @@ -0,0 +1,448 @@ +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, + InvalidEmbeddedPackageSpecError, + 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-version-not-found' | 'spec-not-embeddable' + /** The offending `bundle.packages.embed` entry, when tied to one. */ + spec?: string + /** Standalone sentence describing the issue, usable on its own. */ + message: string + /** + * Entry-scoped detail for grouped diagnostics, phrased to follow the + * entry under a per-type heading — e.g. the versions the lockfile does + * have, or the reasons the matches cannot be embedded. + */ + detail?: 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[] + /** + * The lockfile the specs were resolved against, for diagnostics that + * name it once instead of once per issue. Absent when resolution never + * happened — no lockfile found, or an unsupported/unparsable one (the + * corresponding lockfile issue explains it). + */ + lockfilePath?: 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 `bundle.packages.embed` 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 + +/** + * Joins up to 8 items, appending `N more` for the rest — the + * uniform truncation for user-facing lists of packages, versions and + * reasons. + */ +function capList (items: string[], separator: string, overflow: string): string { + const shown = items.slice(0, 8).join(separator) + return items.length > 8 ? `${shown}${overflow}${items.length - 8} more` : shown +} + +/** + * 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 `bundle.packages.embed` 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 + } + + materialize (): Promise { + this.#materialized ??= this.#materializeAll() + return this.#materialized + } + + async #createPlan (): Promise { + const issues: EmbeddedPackagesIssue[] = [] + const warnings: 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, + detail: err instanceof InvalidEmbeddedPackageSpecError ? err.reason : (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 } + } + + 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}` + // No lockfilePath in the result: the specs were never resolved + // against the lockfile, so diagnostics must not credit it. + issues.push({ type: 'unsupported-lockfile', message }) + return { tarballs: [], issues, warnings } + } + + 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 = capList([...new Set(excludedMatches.map(entry => entry.reason))], '; ', '; and ') + issues.push({ + type: 'spec-not-embeddable', + spec: spec.raw, + message: `Embedded package '${spec.raw}' cannot be embedded: ${reasons}.`, + detail: reasons, + }) + } else if (nameMatches.length > 0) { + const versions = capList([...new Set(nameMatches.map(entry => entry.version))], ', ', ' and ') + issues.push({ + type: 'spec-version-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}` + + ` (lockfile has: ${versions}).`, + detail: `lockfile has: ${versions}`, + }) + } 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))] + warnings.push( + `Embedded package '${spec.raw}' also matches ${names.length} package(s) that cannot` + + ` be embedded as registry tarballs and were skipped: ${capList(names, ', ', ' and ')}.` + + ` The runner must be able to fetch these itself.`, + ) + } + if (spec.namePattern !== undefined) { + // Wildcards select invisibly, but only the debug log says what they + // selected. Selections that need attention surface louder: a + // pattern matching nothing is a fatal validation issue, and matches + // that cannot be embedded produce a warning diagnostic. + debug('pattern %s matched %d package(s): %j', + spec.raw, candidates.length, 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, + lockfilePath, + } + } + + async #materializeAll (): Promise { + const { tarballs, issues } = 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'), + ) + } + + 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..1815a5fac --- /dev/null +++ b/packages/cli/src/services/embedded-packages/spec.ts @@ -0,0 +1,119 @@ +import semver from 'semver' + +/** + * A parsed `bundle.packages.embed` 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 { + /** The failure alone, without the `Invalid embedded package '':` prefix. */ + readonly reason: string + + constructor (spec: string, reason: string) { + super(`Invalid embedded package '${spec}': ${reason}`) + this.name = 'InvalidEmbeddedPackageSpecError' + this.reason = reason + } +} + +/** + * Parses a `bundle.packages.embed` 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