From df66c836ac41a3e9a90f06abb6a87ece3d96db8b Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Wed, 12 Aug 2026 19:39:01 +0900 Subject: [PATCH 01/11] feat(cli): add checks.embeddedPackages config option [RED-855] Adds the config surface for embedding private dependency tarballs into the Playwright Check Suite code bundle: a TSDoc'd checks.embeddedPackages option (package names or name@exact-version pins), runtime shape validation at config load, a reusable spec parser, and plumbing through ProjectParseOpts into Session for deploy, test, validate, pw-test and debug parse-project. Scaffolding only: resolution/fetch services and bundling wiring land in follow-up commits on this branch. Co-Authored-By: Claude Fable 5 --- .../cli/src/commands/debug/parse-project.ts | 1 + packages/cli/src/commands/deploy.ts | 1 + packages/cli/src/commands/pw-test.ts | 1 + packages/cli/src/commands/test.ts | 1 + packages/cli/src/commands/validate.ts | 1 + packages/cli/src/constructs/session.ts | 2 + .../__tests__/checkly-config-loader.spec.ts | 25 ++++++ .../configs/embedded-packages-bad-name.js | 11 +++ .../configs/embedded-packages-not-array.js | 11 +++ .../embedded-packages-range-version.js | 11 +++ .../configs/embedded-packages-valid.ts | 11 +++ .../__tests__/project-parser-session.spec.ts | 53 ++++++++++++ .../cli/src/services/checkly-config-loader.ts | 34 ++++++++ .../embedded-packages/__tests__/spec.spec.ts | 82 +++++++++++++++++++ .../src/services/embedded-packages/spec.ts | 72 ++++++++++++++++ packages/cli/src/services/project-parser.ts | 3 + 16 files changed, 320 insertions(+) create mode 100644 packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-bad-name.js create mode 100644 packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-not-array.js create mode 100644 packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-range-version.js create mode 100644 packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-valid.ts create mode 100644 packages/cli/src/services/__tests__/project-parser-session.spec.ts create mode 100644 packages/cli/src/services/embedded-packages/__tests__/spec.spec.ts create mode 100644 packages/cli/src/services/embedded-packages/spec.ts diff --git a/packages/cli/src/commands/debug/parse-project.ts b/packages/cli/src/commands/debug/parse-project.ts index e305c39b0..c357dfb8c 100644 --- a/packages/cli/src/commands/debug/parse-project.ts +++ b/packages/cli/src/commands/debug/parse-project.ts @@ -149,6 +149,7 @@ export default class ParseProjectCommand extends Command { checklyConfigConstructs, playwrightConfigPath: checklyConfig.checks?.playwrightConfigPath, include: includeFlag.length ? includeFlag : checklyConfig.checks?.include, + embeddedPackages: checklyConfig.checks?.embeddedPackages, playwrightChecks: checklyConfig.checks?.playwrightChecks, loadPlaywrightChecksOnly: emulatePwTest, warnOnWebServerConfig: emulatePwTest && !(includeFlag.length > 0), diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index ce630c908..9b5521d5d 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -178,6 +178,7 @@ export default class Deploy extends AuthCommand { checklyConfigConstructs, playwrightConfigPath: checklyConfig.checks?.playwrightConfigPath, include: checklyConfig.checks?.include, + embeddedPackages: checklyConfig.checks?.embeddedPackages, playwrightChecks: checklyConfig.checks?.playwrightChecks, }) const repoInfo = getGitInformation(project.repoUrl) diff --git a/packages/cli/src/commands/pw-test.ts b/packages/cli/src/commands/pw-test.ts index e7c2b1af5..96c88fccd 100644 --- a/packages/cli/src/commands/pw-test.ts +++ b/packages/cli/src/commands/pw-test.ts @@ -214,6 +214,7 @@ export default class PwTestCommand extends AuthCommand { checklyConfigConstructs, playwrightConfigPath, include: includeFlag.length ? includeFlag : checklyConfig.checks?.include, + embeddedPackages: checklyConfig.checks?.embeddedPackages, playwrightChecks: [playwrightCheck], loadPlaywrightChecksOnly: true, warnOnWebServerConfig: !(includeFlag.length > 0), diff --git a/packages/cli/src/commands/test.ts b/packages/cli/src/commands/test.ts index a7e284caa..5d5265419 100644 --- a/packages/cli/src/commands/test.ts +++ b/packages/cli/src/commands/test.ts @@ -206,6 +206,7 @@ export default class Test extends AuthCommand { checklyConfigConstructs, playwrightConfigPath: checklyConfig.checks?.playwrightConfigPath, include: checklyConfig.checks?.include, + embeddedPackages: checklyConfig.checks?.embeddedPackages, playwrightChecks: checklyConfig.checks?.playwrightChecks, checkFilter: check => { if (check instanceof HeartbeatMonitor) { diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index b1a0aed72..24a84adc0 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -62,6 +62,7 @@ export default class Validate extends AuthCommand { checklyConfigConstructs, playwrightConfigPath: checklyConfig.checks?.playwrightConfigPath, include: checklyConfig.checks?.include, + embeddedPackages: checklyConfig.checks?.embeddedPackages, playwrightChecks: checklyConfig.checks?.playwrightChecks, }) diff --git a/packages/cli/src/constructs/session.ts b/packages/cli/src/constructs/session.ts index 1af9d900f..efe8e3bc1 100644 --- a/packages/cli/src/constructs/session.ts +++ b/packages/cli/src/constructs/session.ts @@ -72,6 +72,7 @@ export class Session { static playwrightProjectBundler?: PlaywrightProjectBundler 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`)) @@ -98,6 +99,7 @@ export class Session { this.playwrightProjectBundler = undefined this.constructExports = [] this.ignoreDirectoriesMatch = [] + this.embeddedPackages = undefined this.warnOnWebServerConfig = false this.packageManager = npmPackageManager this.workspace = Err(new Error(`Workspace support not initialized`)) 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..d6d10d971 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,31 @@ describe('loadChecklyConfig()', () => { ['dependency-cache-version-bad-type.js'], )).rejects.toThrow(`Config field 'caching.dependencyCache.version' must be a string or a safe integer if set`) }) + it('accepts valid checks.embeddedPackages entries', async () => { + const { config } = await loadChecklyConfig( + path.join(__dirname, 'fixtures', 'configs'), + ['embedded-packages-valid.ts'], + ) + expect(config.checks?.embeddedPackages).toEqual(['@acme/private-utils', 'legacy-private-pkg@2.1.0']) + }) + it('rejects a checks.embeddedPackages that is not an array', async () => { + await expect(loadChecklyConfig( + path.join(__dirname, 'fixtures', 'configs'), + ['embedded-packages-not-array.js'], + )).rejects.toThrow(`Config field 'checks.embeddedPackages' must be an array of strings if set`) + }) + it('rejects a checks.embeddedPackages entry that is not a valid package name', async () => { + await expect(loadChecklyConfig( + path.join(__dirname, 'fixtures', 'configs'), + ['embedded-packages-bad-name.js'], + )).rejects.toThrow(`is not a valid npm package name`) + }) + it('rejects a checks.embeddedPackages entry with a version range', async () => { + await expect(loadChecklyConfig( + path.join(__dirname, 'fixtures', 'configs'), + ['embedded-packages-range-version.js'], + )).rejects.toThrow(`is not an exact semver version`) + }) it('config from absolute path', async () => { const filename = 'good-config.ts' const configFile = `./fixtures/configs/${filename}` diff --git a/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-bad-name.js b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-bad-name.js new file mode 100644 index 000000000..a315b6ec1 --- /dev/null +++ b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-bad-name.js @@ -0,0 +1,11 @@ +// Plain JS on purpose: bypasses the TypeScript type of ChecklyConfig so the +// runtime validation of checks.embeddedPackages is what rejects it. +const config = { + projectName: 'test-config-project', + logicalId: 'test-config-project', + checks: { + embeddedPackages: ['Not A Valid Name'], + }, +} + +export default config diff --git a/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-not-array.js b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-not-array.js new file mode 100644 index 000000000..464fc3a62 --- /dev/null +++ b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-not-array.js @@ -0,0 +1,11 @@ +// Plain JS on purpose: bypasses the TypeScript type of ChecklyConfig so the +// runtime validation of checks.embeddedPackages is what rejects it. +const config = { + projectName: 'test-config-project', + logicalId: 'test-config-project', + checks: { + embeddedPackages: '@acme/private-utils', + }, +} + +export default config diff --git a/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-range-version.js b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-range-version.js new file mode 100644 index 000000000..d184042d2 --- /dev/null +++ b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-range-version.js @@ -0,0 +1,11 @@ +// Plain JS on purpose: bypasses the TypeScript type of ChecklyConfig so the +// runtime validation of checks.embeddedPackages is what rejects it. +const config = { + projectName: 'test-config-project', + logicalId: 'test-config-project', + checks: { + embeddedPackages: ['@acme/private-utils@^2.0.0'], + }, +} + +export default config diff --git a/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-valid.ts b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-valid.ts new file mode 100644 index 000000000..0fc8c1216 --- /dev/null +++ b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-valid.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'checkly' + +const config = defineConfig({ + projectName: 'test-config-project', + logicalId: 'test-config-project', + checks: { + embeddedPackages: ['@acme/private-utils', 'legacy-private-pkg@2.1.0'], + }, +}) + +export default config diff --git a/packages/cli/src/services/__tests__/project-parser-session.spec.ts b/packages/cli/src/services/__tests__/project-parser-session.spec.ts new file mode 100644 index 000000000..51572ab75 --- /dev/null +++ b/packages/cli/src/services/__tests__/project-parser-session.spec.ts @@ -0,0 +1,53 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest' + +import { parseProject } from '../project-parser.js' +import { Session } from '../../constructs/session.js' + +describe('parseProject() Session plumbing', () => { + let dir: string + + beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-project-parser-')) + await fs.writeFile(path.join(dir, 'package.json'), JSON.stringify({ name: 'empty-project' })) + }) + + afterEach(() => { + Session.reset() + }) + + afterAll(async () => { + await fs.rm(dir, { recursive: true, force: true }) + }) + + it('threads embeddedPackages into Session and reset() clears it', async () => { + await parseProject({ + directory: dir, + projectLogicalId: 'test-project', + projectName: 'Test Project', + availableRuntimes: {}, + defaultRuntimeId: '2025.04', + embeddedPackages: ['@acme/private-utils', 'legacy-private-pkg@2.1.0'], + }) + + expect(Session.embeddedPackages).toEqual(['@acme/private-utils', 'legacy-private-pkg@2.1.0']) + + Session.reset() + expect(Session.embeddedPackages).toBeUndefined() + }) + + it('leaves Session.embeddedPackages undefined when not configured', async () => { + await parseProject({ + directory: dir, + projectLogicalId: 'test-project', + projectName: 'Test Project', + availableRuntimes: {}, + defaultRuntimeId: '2025.04', + }) + + expect(Session.embeddedPackages).toBeUndefined() + }) +}) diff --git a/packages/cli/src/services/checkly-config-loader.ts b/packages/cli/src/services/checkly-config-loader.ts index d9bb91ea8..a12955e0b 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 { + 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/) + }) +}) 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..757a68667 --- /dev/null +++ b/packages/cli/src/services/embedded-packages/spec.ts @@ -0,0 +1,72 @@ +import semver from 'semver' + +/** + * A parsed `checks.embeddedPackages` entry: a package name 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`. */ + name: string + /** The exact pinned version, if the entry included one. */ + version?: string +} + +// npm's name rules for already-published packages: new publishes must be +// lowercase, but plenty of legitimate older packages (JSONStream) contain +// uppercase letters, so both cases are accepted. Leading `.` and `_` stay +// disallowed, as npm has never permitted them. +const PACKAGE_NAME_RE = /^(@[a-zA-Z0-9-*~][a-zA-Z0-9-*~._]*\/)?[a-zA-Z0-9-~][a-zA-Z0-9-._~]*$/ + +export class InvalidEmbeddedPackageSpecError extends Error { + constructor (spec: string, reason: string) { + super(`Invalid embedded package '${spec}': ${reason}`) + this.name = 'InvalidEmbeddedPackageSpecError' + } +} + +/** + * Parses a `checks.embeddedPackages` entry into a package name and an + * optional exact version pin. + * + * Accepts `name` (embed every lockfile version of the package) and + * `name@version` with an exact semver version. 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 + + if (!PACKAGE_NAME_RE.test(name)) { + throw new InvalidEmbeddedPackageSpecError(raw, `'${name}' is not a valid npm package name`) + } + + if (rawVersion === undefined) { + return { raw, name } + } + + // 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 } +} diff --git a/packages/cli/src/services/project-parser.ts b/packages/cli/src/services/project-parser.ts index c42e4e4f3..635152678 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,7 @@ export async function parseProject (opts: ProjectParseOpts): Promise { Session.defaultRuntimeId = defaultRuntimeId Session.verifyRuntimeDependencies = verifyRuntimeDependencies ?? true Session.ignoreDirectoriesMatch = ignoreDirectoriesMatch + Session.embeddedPackages = embeddedPackages Session.warnOnWebServerConfig = warnOnWebServerConfig Session.packageManager = packageManager Session.workspace = workspace From c56e9beff75fc2213d0b585c271a67321228f112 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Wed, 12 Aug 2026 21:31:38 +0900 Subject: [PATCH 02/11] feat(cli): add embedded-packages resolution and fetch services [RED-855] Pure services that turn checks.embeddedPackages entries into verified registry tarballs: lockfile enumeration (pnpm-lock.yaml v6/v9, package-lock.json v2/v3, with precise reasons for git/file/workspace/ integrity-less entries), .npmrc parsing with scope-aware registry resolution, nerf-dart auth matching and npm_config_* env layering, SRI integrity helpers, a content-addressed per-user tarball cache (CHECKLY_CACHE_DIR override, atomic writes, self-healing corrupt entries) with a read-only npm cacache lookup tier, and a memoized materializer running the CLI cache -> npm cache -> registry download source chain with proxy-aware axios and credential-redacted errors. Consumed by the Playwright bundler in the next commit. Co-Authored-By: Claude Fable 5 --- .../embedded-packages/__tests__/cache.spec.ts | 132 +++++++ .../__tests__/integrity.spec.ts | 65 ++++ .../__tests__/lockfile-packages.spec.ts | 284 +++++++++++++++ .../__tests__/materializer.spec.ts | 331 +++++++++++++++++ .../embedded-packages/__tests__/npmrc.spec.ts | 205 +++++++++++ .../src/services/embedded-packages/cache.ts | 169 +++++++++ .../services/embedded-packages/integrity.ts | 73 ++++ .../embedded-packages/lockfile-packages.ts | 268 ++++++++++++++ .../embedded-packages/materializer.ts | 341 ++++++++++++++++++ .../src/services/embedded-packages/npmrc.ts | 220 +++++++++++ 10 files changed, 2088 insertions(+) create mode 100644 packages/cli/src/services/embedded-packages/__tests__/cache.spec.ts create mode 100644 packages/cli/src/services/embedded-packages/__tests__/integrity.spec.ts create mode 100644 packages/cli/src/services/embedded-packages/__tests__/lockfile-packages.spec.ts create mode 100644 packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts create mode 100644 packages/cli/src/services/embedded-packages/__tests__/npmrc.spec.ts create mode 100644 packages/cli/src/services/embedded-packages/cache.ts create mode 100644 packages/cli/src/services/embedded-packages/integrity.ts create mode 100644 packages/cli/src/services/embedded-packages/lockfile-packages.ts create mode 100644 packages/cli/src/services/embedded-packages/materializer.ts create mode 100644 packages/cli/src/services/embedded-packages/npmrc.ts diff --git a/packages/cli/src/services/embedded-packages/__tests__/cache.spec.ts b/packages/cli/src/services/embedded-packages/__tests__/cache.spec.ts new file mode 100644 index 000000000..118e62f06 --- /dev/null +++ b/packages/cli/src/services/embedded-packages/__tests__/cache.spec.ts @@ -0,0 +1,132 @@ +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, beforeEach, afterEach } from 'vitest' + +import { TarballCache, lookupNpmCacache, resolveCacheDir } from '../cache.js' + +const content = Buffer.from('fake tarball content') +const sha512Base64 = createHash('sha512').update(content).digest('base64') +const sha512Hex = createHash('sha512').update(content).digest('hex') +const integrity = `sha512-${sha512Base64}` + +describe('resolveCacheDir()', () => { + const home = path.sep === '/' ? '/home/user' : 'C:\\Users\\user' + + it('honors CHECKLY_CACHE_DIR', () => { + expect(resolveCacheDir({ CHECKLY_CACHE_DIR: '/tmp/custom-cache' }, 'linux', home)) + .toBe(path.resolve('/tmp/custom-cache')) + }) + + it('uses Library/Caches on macOS', () => { + expect(resolveCacheDir({}, 'darwin', home)).toBe(path.join(home, 'Library', 'Caches', 'checkly')) + }) + + it('uses XDG_CACHE_HOME when set', () => { + expect(resolveCacheDir({ XDG_CACHE_HOME: '/xdg-cache' }, 'linux', home)) + .toBe(path.join('/xdg-cache', 'checkly')) + }) + + it('falls back to ~/.cache elsewhere', () => { + expect(resolveCacheDir({}, 'linux', home)).toBe(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('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()', () => { + it('derives the cache location from the injected env, platform and homedir', async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-cache-home-')) + try { + const cache = TarballCache.default({}, 'linux', home) + const putPath = await cache.put(integrity, content) + expect(putPath.startsWith(path.join(home, '.cache', 'checkly', 'embedded-packages'))).toBe(true) + } finally { + await fs.rm(home, { recursive: true, force: true }) + } + }) +}) 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..d4b87c5e6 --- /dev/null +++ b/packages/cli/src/services/embedded-packages/__tests__/lockfile-packages.spec.ts @@ -0,0 +1,284 @@ +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`, + }, + ]) + }) +}) + +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..bc313a085 --- /dev/null +++ b/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts @@ -0,0 +1,331 @@ +import { createHash } from 'node:crypto' +import fs from 'node:fs/promises' +import http from 'node:http' +import os from 'node:os' +import path from 'node:path' +import { AddressInfo } from 'node:net' + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' + +import { EmbeddedPackageError, EmbeddedPackagesMaterializer } from '../materializer.js' + +const fooTarball = Buffer.from('fake tarball content for @acme/foo') +const fooIntegrity = `sha512-${createHash('sha512').update(fooTarball).digest('base64')}` +const barTarball = Buffer.from('fake tarball content for bar') +const barIntegrity = `sha512-${createHash('sha512').update(barTarball).digest('base64')}` + +function lockfileContent (): string { + return ` +lockfileVersion: '9.0' +packages: + '@acme/foo@1.2.3': + resolution: {integrity: ${fooIntegrity}} + bar@2.0.0: + resolution: {integrity: ${barIntegrity}} + bar@3.0.0: + resolution: {integrity: ${barIntegrity}} + 'git-dep@https://codeload.github.com/user/git-dep/tar.gz/abc123': + resolution: {tarball: https://codeload.github.com/user/git-dep/tar.gz/abc123} +` +} + +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('converts scope slashes for the archive filename', async () => { + const { tarballs } = await makeMaterializer(['@acme/foo']).plan() + expect(tarballs.map(t => t.archiveFilename)).toEqual(['@acme+foo@1.2.3.tgz']) + }) + + it('reports a spec that matches nothing in the lockfile', async () => { + const { issues } = await makeMaterializer(['no-such-package']).plan() + expect(issues).toHaveLength(1) + expect(issues[0].type).toBe('spec-not-found') + expect(issues[0].message).toContain('no-such-package') + }) + + it('reports a version pin that matches nothing in the lockfile', async () => { + const { issues } = await makeMaterializer(['bar@9.9.9']).plan() + expect(issues[0].type).toBe('spec-not-found') + }) + + it('reports a spec that only matches a git dependency', async () => { + const { issues } = await makeMaterializer(['git-dep']).plan() + expect(issues).toHaveLength(1) + expect(issues[0].type).toBe('spec-not-embeddable') + expect(issues[0].message).toContain('git, file or URL dependency') + }) + + it('reports an invalid spec as an issue', async () => { + const { issues } = await makeMaterializer(['Not A Valid Name']).plan() + expect(issues[0].type).toBe('invalid-spec') + expect(issues[0].message).toContain('not a valid npm package name') + }) + + it('reports a workspace package with a precise reason', async () => { + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +importers: + .: + dependencies: + '@acme/shared': + specifier: workspace:* + version: link:packages/shared +packages: {} +`) + const { issues } = await makeMaterializer(['@acme/shared']).plan() + expect(issues[0].type).toBe('spec-not-embeddable') + expect(issues[0].message).toContain('workspace package') + }) + + it('reports a missing lockfile', async () => { + const materializer = makeMaterializer(['bar'], { lockfilePath: undefined }) + const { issues } = await materializer.plan() + expect(issues[0].type).toBe('missing-lockfile') + }) + + it('reports an unsupported lockfile', async () => { + const yarnLockfilePath = path.join(workspaceRoot, 'yarn.lock') + await fs.writeFile(yarnLockfilePath, '') + const { issues } = await makeMaterializer(['bar'], { lockfilePath: yarnLockfilePath }).plan() + expect(issues[0].type).toBe('unsupported-lockfile') + expect(issues[0].message).toContain('yarn.lock') + }) + + it('reports an unparseable lockfile as an issue instead of throwing', async () => { + await fs.writeFile(lockfilePath, [ + 'lockfileVersion:', + '<<<<<<< HEAD', + ` '9.0'`, + '=======', + ` '6.0'`, + '>>>>>>> other-branch', + ].join('\n')) + const { issues } = await makeMaterializer(['bar']).plan() + expect(issues[0].type).toBe('unsupported-lockfile') + expect(issues[0].message).toContain('Failed to read or parse the lockfile') + expect(issues[0].message).toContain(lockfilePath) + }) + }) + + describe('materialize()', () => { + it('downloads tarballs from the registry and verifies them', async () => { + const tarballs = await makeMaterializer(['@acme/foo', 'bar@2.0.0']).materialize() + expect(tarballs.map(t => t.archivePath)).toEqual([ + '.checkly/embedded-packages/@acme+foo@1.2.3.tgz', + '.checkly/embedded-packages/bar@2.0.0.tgz', + ]) + await expect(fs.readFile(tarballs[0].filePath)).resolves.toEqual(fooTarball) + expect(requests.map(r => r.url).sort()).toEqual([ + '/@acme/foo/-/foo-1.2.3.tgz', + '/bar/-/bar-2.0.0.tgz', + ]) + // The raw artifact must be requested: a gzip-labelled response would + // be transparently decompressed and fail integrity verification. + expect(requests.every(r => r.acceptEncoding === 'identity')).toBe(true) + }) + + it('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 hex = createHash('sha512').update(barTarball).digest('hex') + const contentPath = path.join( + homedir, '.npm', '_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) + + const tarballs = await makeMaterializer(['bar@2.0.0']).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/cache.ts b/packages/cli/src/services/embedded-packages/cache.ts new file mode 100644 index 000000000..9c132eff5 --- /dev/null +++ b/packages/cli/src/services/embedded-packages/cache.ts @@ -0,0 +1,169 @@ +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 { IntegrityHash, integrityHashToHex, strongestIntegrityHash, verifyIntegrity } from './integrity.js' + +/** + * The Checkly CLI's per-user cache directory. `CHECKLY_CACHE_DIR` overrides + * the platform default (macOS: `~/Library/Caches/checkly`, Windows: + * `%LOCALAPPDATA%\checkly\Cache`, elsewhere: `$XDG_CACHE_HOME/checkly` or + * `~/.cache/checkly`). + */ +export function resolveCacheDir ( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + homedir = os.homedir(), +): string { + const override = env.CHECKLY_CACHE_DIR + if (override !== undefined && override !== '') { + return path.resolve(override) + } + + 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') + } + } +} + +/** + * A content-addressed store of package tarballs under the CLI cache + * directory, keyed by the lockfile's integrity hash. Every read verifies + * the content, so a corrupt entry degrades to a cache miss rather than a + * user-facing error. + */ +export class TarballCache { + #rootDir: string + + constructor (rootDir: string) { + this.#rootDir = rootDir + } + + static default ( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + homedir = os.homedir(), + ): TarballCache { + return new TarballCache(path.join(resolveCacheDir(env, platform, homedir), 'embedded-packages')) + } + + #pathFor (hash: IntegrityHash): string { + const hex = integrityHashToHex(hash) + return path.join(this.#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 + } + const filePath = this.#pathFor(hash) + + let content: Buffer + try { + content = await fs.readFile(filePath) + } catch { + return undefined + } + + if (!verifyIntegrity(content, integrity)) { + await fs.rm(filePath, { force: true }).catch(() => {}) + return undefined + } + + return filePath + } + + /** + * Stores verified tarball content 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}')`) + } + const filePath = this.#pathFor(hash) + + await fs.mkdir(path.dirname(filePath), { recursive: true }) + const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp` + try { + await fs.writeFile(tempPath, content) + await fs.rename(tempPath, filePath) + } finally { + await fs.rm(tempPath, { force: true }).catch(() => {}) + } + + return filePath + } +} + +/** + * 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..e5a689bab --- /dev/null +++ b/packages/cli/src/services/embedded-packages/lockfile-packages.ts @@ -0,0 +1,268 @@ +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 +} + +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) + result.excluded.push({ + name, + reason: `'${name}' is a workspace package, which cannot be embedded as a registry tarball`, + }) + } + } + } + } + } + + 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`, + }) + 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`, + }) + 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) { + result.excluded.push({ + name: key.slice(lastNodeModules + 'node_modules/'.length), + reason: `'${key}' is a workspace link, which cannot be embedded as a registry tarball`, + }) + 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`, + }) + 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`, + }) + 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..a714a8939 --- /dev/null +++ b/packages/cli/src/services/embedded-packages/materializer.ts @@ -0,0 +1,341 @@ +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +import axios from 'axios' +import Debug from 'debug' +import PQueue from 'p-queue' + +import { assignProxy } from '../proxy.js' +import { TarballCache, lookupNpmCacache } from './cache.js' +import { verifyIntegrity } from './integrity.js' +import { + LockfileRegistryPackage, + UnsupportedLockfileError, + loadLockfilePackages, +} from './lockfile-packages.js' +import { NpmrcConfig, defaultNpmrcPaths, loadNpmrcConfig, resolveAuthHeader, resolveRegistryUrl } from './npmrc.js' +import { EmbeddedPackageSpec, parseEmbeddedPackageSpec } from './spec.js' + +const debug = Debug('checkly:cli:services:embedded-packages') + +/** + * The directory inside the code bundle where embedded package tarballs + * live. This path is a contract with Checkly runners: tarballs found there + * are served through a local registry during the bundle's install step. + */ +export const EMBEDDED_PACKAGES_ARCHIVE_DIR = '.checkly/embedded-packages' + +export interface EmbeddedPackagesIssue { + type: 'invalid-spec' | 'missing-lockfile' | 'unsupported-lockfile' | 'spec-not-found' | 'spec-not-embeddable' + /** The offending `checks.embeddedPackages` entry, when tied to one. */ + spec?: string + message: string +} + +/** + * One tarball selected for embedding, resolved from the lockfile. + */ +export interface PlannedTarball extends LockfileRegistryPackage { + /** Archive filename, e.g. `@acme+foo@1.2.3.tgz` (scope slash → `+`). */ + archiveFilename: string +} + +export interface EmbeddedPackagesPlan { + tarballs: PlannedTarball[] + issues: EmbeddedPackagesIssue[] +} + +/** + * A planned tarball that has been sourced into the CLI cache and is ready + * to be added to the code bundle. + */ +export interface MaterializedTarball extends PlannedTarball { + /** Absolute path of the verified tarball in the CLI cache. */ + filePath: string + /** Bundle-root-relative archive path (POSIX). */ + archivePath: string +} + +export class EmbeddedPackageError extends Error { + constructor (message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'EmbeddedPackageError' + } +} + +export interface EmbeddedPackagesMaterializerOptions { + /** Raw `checks.embeddedPackages` entries. */ + specs: string[] + /** Absolute path of the workspace root lockfile, when one exists. */ + lockfilePath?: string + /** Workspace root directory, used to locate the root `.npmrc`. */ + workspaceRoot?: string + /** + * The directory the Checkly project lives in (a workspace member in a + * monorepo), whose `.npmrc` takes precedence over the workspace root's. + */ + contextDir?: string + env?: NodeJS.ProcessEnv + homedir?: string +} + +const DOWNLOAD_CONCURRENCY = 5 +const DOWNLOAD_TIMEOUT_MS = 120_000 +const MAX_TARBALL_BYTES = 1024 * 1024 * 1024 + +/** + * Removes userinfo credentials from a URL so it can be safely included in + * error messages and logs (a registry URL may embed a token). + */ +function redactUrl (url: string): string { + try { + const parsed = new URL(url) + parsed.username = '' + parsed.password = '' + return parsed.toString() + } catch { + // Not parseable as a URL (e.g. a scheme-less registry entry) — strip + // anything that looks like a userinfo segment before displaying it. + return url.replace(/(^|\/\/)[^/@\s]+@/, '$1') + } +} + +/** + * Resolves the configured `checks.embeddedPackages` specs against the + * workspace lockfile (plan) and sources the selected tarballs into the CLI + * cache (materialize), through a chain of CLI cache → npm cacache → + * registry download, always verified against the lockfile integrity. + * + * Both stages memoize their in-flight promise: multiple Playwright checks + * bundle concurrently, and validation and bundling share one instance per + * parsed project, so the work runs exactly once. + */ +export class EmbeddedPackagesMaterializer { + #options: EmbeddedPackagesMaterializerOptions + #cache: TarballCache + #env: NodeJS.ProcessEnv + #homedir: string + + #plan?: Promise + #materialized?: Promise + + constructor (options: EmbeddedPackagesMaterializerOptions) { + this.#options = options + this.#env = options.env ?? process.env + this.#homedir = options.homedir ?? os.homedir() + this.#cache = TarballCache.default(this.#env, process.platform, this.#homedir) + } + + 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 specs: EmbeddedPackageSpec[] = [] + for (const raw of this.#options.specs) { + try { + specs.push(parseEmbeddedPackageSpec(raw)) + } catch (err) { + issues.push({ type: 'invalid-spec', spec: String(raw), message: (err as Error).message }) + } + } + + const { lockfilePath } = this.#options + if (lockfilePath === undefined) { + issues.push({ + type: 'missing-lockfile', + message: `Embedded packages require a lockfile to resolve package versions and` + + ` integrity hashes, but no lockfile was found for the project.`, + }) + return { tarballs: [], issues } + } + + let packages + try { + packages = await loadLockfilePackages(lockfilePath) + } catch (err) { + // Any failure to read or parse the lockfile (missing file, merge + // conflict markers, unknown format) becomes a diagnostic naming the + // lockfile instead of an unhandled exception aborting the command. + const message = err instanceof UnsupportedLockfileError + ? err.message + : `Failed to read or parse the lockfile ('${lockfilePath}'): ${(err as Error).message}` + issues.push({ type: 'unsupported-lockfile', message }) + return { tarballs: [], issues } + } + + debug( + 'lockfile %s: %d registry entries, %d excluded entries', + lockfilePath, packages.registry.length, packages.excluded.length, + ) + + const registryByName = new Map() + for (const entry of packages.registry) { + const entries = registryByName.get(entry.name) ?? [] + entries.push(entry) + registryByName.set(entry.name, entries) + } + + const tarballs = new Map() + for (const spec of specs) { + const candidates = (registryByName.get(spec.name) ?? []) + .filter(entry => spec.version === undefined || entry.version === spec.version) + + if (candidates.length === 0) { + const excludedMatches = packages.excluded.filter(entry => { + return entry.name === spec.name + && (spec.version === undefined || entry.version === undefined || entry.version === spec.version) + }) + if (excludedMatches.length > 0) { + const reasons = [...new Set(excludedMatches.map(entry => entry.reason))] + issues.push({ + type: 'spec-not-embeddable', + spec: spec.raw, + message: `Embedded package '${spec.raw}' cannot be embedded: ${reasons.join('; ')}.`, + }) + } else { + 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 name` + + ` ${spec.version !== undefined ? 'and version are' : 'is'} spelled correctly.`, + }) + } + continue + } + + 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', tarballs.size, issues.length) + + return { + tarballs: [...tarballs.values()].sort((a, b) => a.archiveFilename.localeCompare(b.archiveFilename)), + issues, + } + } + + 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 [] + } + + const npmrcConfig = await loadNpmrcConfig(defaultNpmrcPaths( + this.#options.workspaceRoot ?? path.dirname(this.#options.lockfilePath!), + 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 +} From dcd2f38fc99e70db2aad5e49dc8d2a27859592aa Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Wed, 12 Aug 2026 22:10:07 +0900 Subject: [PATCH 03/11] feat(cli): embed configured dependency tarballs into Playwright bundles [RED-855] Wires the embedded-packages services into the CLI: a memoized session-level materializer shared by validation and bundling, project validation that resolves checks.embeddedPackages against the lockfile before any bundling (grouped, readable diagnostics; skipped when the project has no Playwright checks), and Playwright bundling that appends the verified tarballs at the runner contract path .checkly/embedded-packages/@.tgz via explicit archive paths, independent of workspace layout. Includes offline integration tests driven by a pre-seeded CHECKLY_CACHE_DIR with committed deterministic tarball fixtures, plus TSDoc and AI-context documentation. The runner half that serves the embedded tarballs during install is RED-856; CLI releases containing this feature must wait for it. Co-Authored-By: Claude Fable 5 --- .../references/configure-playwright-checks.md | 1 + .../embedded-tarballs/.gitignore | 3 + .../@acme+private-utils@1.2.3.tgz | Bin 0 -> 219 bytes .../embedded-tarballs/README.md | 34 ++++ .../legacy-private-pkg@2.1.0.tgz | Bin 0 -> 220 bytes .../checkly.config.ts | 20 +++ .../package.json | 7 + .../playwright.config.ts | 6 + .../pnpm-lock.yaml | 52 +++++++ .../tests/example.spec.ts | 6 + .../checkly.config.ts | 20 +++ .../package.json | 7 + .../pnpm-lock.yaml | 57 +++++++ .../subdir/playwright.config.ts | 6 + .../subdir/tests/example.spec.ts | 6 + .../test-embedded-packages/checkly.config.ts | 20 +++ .../test-embedded-packages/package.json | 7 + .../playwright.config.ts | 6 + .../test-embedded-packages/pnpm-lock.yaml | 67 ++++++++ .../tests/example.spec.ts | 6 + .../__tests__/playwright-check.spec.ts | 130 +++++++++++++++- .../project-embedded-packages.spec.ts | 147 ++++++++++++++++++ packages/cli/src/constructs/project.ts | 60 ++++++- packages/cli/src/constructs/session.ts | 24 +++ .../cli/src/services/checkly-config-loader.ts | 16 +- .../services/playwright-project-bundler.ts | 16 ++ packages/cli/src/services/project-parser.ts | 3 + 27 files changed, 722 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/embedded-tarballs/.gitignore create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/embedded-tarballs/@acme+private-utils@1.2.3.tgz create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/embedded-tarballs/README.md create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/embedded-tarballs/legacy-private-pkg@2.1.0.tgz create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/checkly.config.ts create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/package.json create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/playwright.config.ts create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/pnpm-lock.yaml create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/tests/example.spec.ts create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/checkly.config.ts create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/package.json create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/pnpm-lock.yaml create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/subdir/playwright.config.ts create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/subdir/tests/example.spec.ts create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/checkly.config.ts create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/package.json create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/playwright.config.ts create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/pnpm-lock.yaml create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/tests/example.spec.ts create mode 100644 packages/cli/src/constructs/__tests__/project-embedded-packages.spec.ts 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..d90d48990 100644 --- a/packages/cli/src/ai-context/references/configure-playwright-checks.md +++ b/packages/cli/src/ai-context/references/configure-playwright-checks.md @@ -14,6 +14,7 @@ - Use `installCommand` only when the default package-manager install command is not enough. - Checkly caches installed dependencies between runs, keyed off the lock file, `package.json` and `.npmrc` contents. To force a reinstall declaratively, set `caching.dependencyCache.version` (a string or a safe integer) at the top level of `checkly.config.ts` (not per check — one code bundle serves all Playwright Check Suites) and change its value whenever the cache should be invalidated; scheduled checks pick up the change on the next `checkly deploy`. Unset or empty-string values leave the cache key unchanged, so a dynamic value such as `version: process.env.DEPENDENCY_CACHE_VERSION` is safe when the variable is not always set. For a one-off reinstall during an ad-hoc run, use the `--refresh-cache` flag available on the run/test commands (`checkly test`, `checkly pw-test`, `checkly trigger`, `checkly checks run`) instead; the config value is the persistent knob that also applies to deployed, scheduled checks. - In Checkly CLI v8.0.0 and later, `include` patterns resolve relative to the Playwright config directory, not the project root. If `playwrightConfigPath` points to a subdirectory, adjust `include` globs. Example: `playwrightConfigPath: "./e2e/playwright.config.ts"` with a root fixture at `fixtures/data.json` needs `include: ["../fixtures/data.json"]`. +- If dependencies come from a private registry that Checkly's infrastructure cannot reach (for example an intranet-only Nexus mirror), list them in `checks.embeddedPackages` in `checkly.config.ts`. Each entry is a package name (embeds every version found in the lockfile) or an exact `name@version` pin. 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. The project tree is never written to; downloads land in a per-user cache directory (macOS: `~/Library/Caches/checkly`; Linux: `$XDG_CACHE_HOME/checkly` or `~/.cache/checkly`; override with `CHECKLY_CACHE_DIR` — persist it in CI to avoid re-downloading). The machine running `checkly deploy`/`test` needs registry access on a cold cache. Applies to Playwright Check Suites only, not browser or multistep checks. ## Install troubleshooting diff --git a/packages/cli/src/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 0000000000000000000000000000000000000000..e5b74bc11785e1b549041a8c360b90754fd378ca GIT binary patch literal 219 zcmb2|=3oE;rvGoRob5VfAky}5O|__E_HwmbS{zN=g-kqTemh_8Qnz8x>{NJ%A!ZccZcWq=y>4eYi^A9ZIuXTw5 TdjLx8JI7hnD4EBg!N33jZsKW9 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4b33c67eda7c92cc6a336519a9081ec2579e9aa6 GIT binary patch literal 220 zcmV<203-h&iwFP!00002|LxS@YQiuS$MIbEDMGHbO(a$j278sDp>(xv=pS*6z58mb zyC{R*sBHNC+?;R{$i>Mo!R-o{`6Ns=erxMW+?XDu){b>BuiBXOdp-7zS*KP=Egqn6 zJJ(1lp43MqrK()%)mEy5&)n{P8Jhg)I=>7>rWWV@qi@>0uFgkRv5EE6EnPmg@@nr- z!^2V0r@%jR$$fGi;yv#8E&qCLXZhC~Oa33CtoQxF$Nm)RrfcQPPoKc+6#9s?00000 W0000000000{5@aQZLz}uC;$NYsc@D6 literal 0 HcmV?d00001 diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/checkly.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/checkly.config.ts new file mode 100644 index 000000000..d087efedc --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/checkly.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'checkly' + +const config = defineConfig({ + projectName: 'Check Fixture', + logicalId: 'check-fixture', + checks: { + checkMatch: '**/*.check.ts', + ignoreDirectoriesMatch: [], + playwrightConfigPath: './playwright.config.ts', + embeddedPackages: ['no-such-package'], + playwrightChecks: [ + { + logicalId: 'playwright-check-suite', + name: 'Playwright Check Suite', + } + ], + }, +}) + +export default config diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/package.json b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/package.json new file mode 100644 index 000000000..b12adbc29 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/package.json @@ -0,0 +1,7 @@ +{ + "name": "playwright-bundle-test", + "version": "1.0.0", + "dependencies": { + "@playwright/test": "^1.55.1" + } +} diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/playwright.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/playwright.config.ts new file mode 100644 index 000000000..eed093e29 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/playwright.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './tests', + timeout: 30000, +}) diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/pnpm-lock.yaml b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/pnpm-lock.yaml new file mode 100644 index 000000000..9c3c4c244 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/pnpm-lock.yaml @@ -0,0 +1,52 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@playwright/test': + specifier: ^1.55.1 + version: 1.59.1 + +packages: + + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + +snapshots: + + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + + fsevents@2.3.2: + optional: true + + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/tests/example.spec.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/tests/example.spec.ts new file mode 100644 index 000000000..4cbbbc71e --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-not-found/tests/example.spec.ts @@ -0,0 +1,6 @@ +import { test, expect } from '@playwright/test' + +test('basic test', async ({ page }) => { + await page.goto('https://playwright.dev/') + expect(await page.title()).toContain('Playwright') +}) diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/checkly.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/checkly.config.ts new file mode 100644 index 000000000..588a047eb --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/checkly.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'checkly' + +const config = defineConfig({ + projectName: 'Check Fixture', + logicalId: 'check-fixture', + checks: { + checkMatch: '**/*.check.ts', + ignoreDirectoriesMatch: [], + playwrightConfigPath: './subdir/playwright.config.ts', + embeddedPackages: ['@acme/private-utils'], + playwrightChecks: [ + { + logicalId: 'playwright-check-suite', + name: 'Playwright Check Suite', + } + ], + }, +}) + +export default config diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/package.json b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/package.json new file mode 100644 index 000000000..b12adbc29 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/package.json @@ -0,0 +1,7 @@ +{ + "name": "playwright-bundle-test", + "version": "1.0.0", + "dependencies": { + "@playwright/test": "^1.55.1" + } +} diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/pnpm-lock.yaml b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/pnpm-lock.yaml new file mode 100644 index 000000000..f59fa575e --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/pnpm-lock.yaml @@ -0,0 +1,57 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@playwright/test': + specifier: ^1.55.1 + version: 1.59.1 + +packages: + + '@acme/private-utils@1.2.3': + resolution: {integrity: sha512-dnkm3WedrIfH8+nRoHESfj0/DDeZdBTCpP2B5ZUSR/6YsMiOtYmauw1FRb2hDNC00ZLWu8Ya8sZfR2D/s1VhTQ==} + + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + +snapshots: + + '@acme/private-utils@1.2.3': {} + + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + + fsevents@2.3.2: + optional: true + + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/subdir/playwright.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/subdir/playwright.config.ts new file mode 100644 index 000000000..eed093e29 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/subdir/playwright.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './tests', + timeout: 30000, +}) diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/subdir/tests/example.spec.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/subdir/tests/example.spec.ts new file mode 100644 index 000000000..4cbbbc71e --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/subdir/tests/example.spec.ts @@ -0,0 +1,6 @@ +import { test, expect } from '@playwright/test' + +test('basic test', async ({ page }) => { + await page.goto('https://playwright.dev/') + expect(await page.title()).toContain('Playwright') +}) diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/checkly.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/checkly.config.ts new file mode 100644 index 000000000..8cab2e358 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/checkly.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'checkly' + +const config = defineConfig({ + projectName: 'Check Fixture', + logicalId: 'check-fixture', + checks: { + checkMatch: '**/*.check.ts', + ignoreDirectoriesMatch: [], + playwrightConfigPath: './playwright.config.ts', + embeddedPackages: ['@acme/private-utils', 'legacy-private-pkg@2.1.0'], + playwrightChecks: [ + { + logicalId: 'playwright-check-suite', + name: 'Playwright Check Suite', + } + ], + }, +}) + +export default config diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/package.json b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/package.json new file mode 100644 index 000000000..b12adbc29 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/package.json @@ -0,0 +1,7 @@ +{ + "name": "playwright-bundle-test", + "version": "1.0.0", + "dependencies": { + "@playwright/test": "^1.55.1" + } +} diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/playwright.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/playwright.config.ts new file mode 100644 index 000000000..eed093e29 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/playwright.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './tests', + timeout: 30000, +}) diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/pnpm-lock.yaml b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/pnpm-lock.yaml new file mode 100644 index 000000000..dab310790 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/pnpm-lock.yaml @@ -0,0 +1,67 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@playwright/test': + specifier: ^1.55.1 + version: 1.59.1 + +packages: + + '@acme/private-utils@1.2.3': + resolution: {integrity: sha512-dnkm3WedrIfH8+nRoHESfj0/DDeZdBTCpP2B5ZUSR/6YsMiOtYmauw1FRb2hDNC00ZLWu8Ya8sZfR2D/s1VhTQ==} + + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + legacy-private-pkg@2.1.0: + resolution: {integrity: sha512-lyOrTMMajW/F3ryAPbDHLv3ZhJVoV+3W2cff/313EifQN/51nKtgyGxQMERcb/RZ8OahAl/8tPbK76Rsov7GyQ==} + + legacy-private-pkg@3.0.0: + resolution: {integrity: sha512-0000000000000000000000000000000000000000000000000000000000000000000000000000000000ABCDEF==} + + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + +snapshots: + + '@acme/private-utils@1.2.3': {} + + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + + fsevents@2.3.2: + optional: true + + legacy-private-pkg@2.1.0: {} + + legacy-private-pkg@3.0.0: {} + + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 diff --git a/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/tests/example.spec.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/tests/example.spec.ts new file mode 100644 index 000000000..4cbbbc71e --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/tests/example.spec.ts @@ -0,0 +1,6 @@ +import { test, expect } from '@playwright/test' + +test('basic test', async ({ page }) => { + await page.goto('https://playwright.dev/') + expect(await page.title()).toContain('Playwright') +}) diff --git a/packages/cli/src/constructs/__tests__/playwright-check.spec.ts b/packages/cli/src/constructs/__tests__/playwright-check.spec.ts index 45eb01235..9dd5cc8f5 100644 --- a/packages/cli/src/constructs/__tests__/playwright-check.spec.ts +++ b/packages/cli/src/constructs/__tests__/playwright-check.spec.ts @@ -1,19 +1,30 @@ +import { createHash } from 'node:crypto' import fs from 'node:fs/promises' +import os from 'node:os' import path from 'node:path' import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { list } from 'tar' -import { FixtureSandbox } from '../../testing/fixture-sandbox.js' +import { FixtureSandbox, RunOptions } from '../../testing/fixture-sandbox.js' import { ParseProjectOutput } from '../../commands/debug/parse-project.js' +import { TarballCache } from '../../services/embedded-packages/cache.js' async function parseProject (fixt: FixtureSandbox, ...args: string[]): Promise { + return await parseProjectWithOptions(fixt, {}, ...args) +} + +async function parseProjectWithOptions ( + fixt: FixtureSandbox, + options: RunOptions, + ...args: string[] +): Promise { const result = await fixt.run('pnpm', [ 'checkly', 'debug', 'parse-project', ...args, - ]) + ], options) if (result.exitCode !== 0) { // eslint-disable-next-line no-console @@ -1494,6 +1505,121 @@ describe('PlaywrightCheck', () => { }, DEFAULT_TEST_TIMEOUT) }) + /** + * Creates a temp CLI cache dir seeded with committed tarball fixtures so + * that embedded-packages tests run offline: with CHECKLY_CACHE_DIR set to + * the returned dir, the materializer finds every tarball in the CLI cache + * and never contacts a registry. + */ + async function seedTarballCache (...tarballFilenames: string[]): Promise { + const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-embed-cache-')) + const cache = TarballCache.default({ CHECKLY_CACHE_DIR: cacheDir }) + for (const filename of tarballFilenames) { + const content = await fs.readFile( + path.join(__dirname, 'fixtures', 'playwright-check', 'embedded-tarballs', filename), + ) + const integrity = `sha512-${createHash('sha512').update(content).digest('base64')}` + await cache.put(integrity, content) + } + return cacheDir + } + + describe('bundling with embedded packages', () => { + let fixt: FixtureSandbox + let cacheDir: string + + beforeAll(async () => { + fixt = await FixtureSandbox.create({ + source: path.join(__dirname, 'fixtures', 'playwright-check', 'test-cases', 'test-embedded-packages'), + }) + cacheDir = await seedTarballCache('@acme+private-utils@1.2.3.tgz', 'legacy-private-pkg@2.1.0.tgz') + }, DEFAULT_TEST_TIMEOUT) + + afterAll(async () => { + await fixt?.destroy() + if (cacheDir) { + await fs.rm(cacheDir, { recursive: true, force: true }) + } + }) + + it('should embed configured tarballs at the contract path', async () => { + const output = await parseProjectWithOptions(fixt, { env: { CHECKLY_CACHE_DIR: cacheDir } }) + + expect(output.diagnostics.fatal).toBe(false) + + const { + codeBundlePath, + } = output.payload.resources[0].payload as any + + const files = await listTarFiles(codeBundlePath) + + expect(files).toContain('.checkly/embedded-packages/@acme+private-utils@1.2.3.tgz') + expect(files).toContain('.checkly/embedded-packages/legacy-private-pkg@2.1.0.tgz') + // The lockfile also contains legacy-private-pkg@3.0.0; the exact + // version pin must exclude it. + expect(files).not.toContain('.checkly/embedded-packages/legacy-private-pkg@3.0.0.tgz') + }, DEFAULT_TEST_TIMEOUT) + }) + + describe('bundling with embedded packages and subdirectory playwright config', () => { + let fixt: FixtureSandbox + let cacheDir: string + + beforeAll(async () => { + fixt = await FixtureSandbox.create({ + source: path.join(__dirname, 'fixtures', 'playwright-check', 'test-cases', 'test-embedded-packages-subdir'), + }) + cacheDir = await seedTarballCache('@acme+private-utils@1.2.3.tgz') + }, DEFAULT_TEST_TIMEOUT) + + afterAll(async () => { + await fixt?.destroy() + if (cacheDir) { + await fs.rm(cacheDir, { recursive: true, force: true }) + } + }) + + it('should embed tarballs at the contract path when playwright config is in a subdirectory', async () => { + const output = await parseProjectWithOptions(fixt, { env: { CHECKLY_CACHE_DIR: cacheDir } }) + + expect(output.diagnostics.fatal).toBe(false) + + const { + codeBundlePath, + } = output.payload.resources[0].payload as any + + const files = await listTarFiles(codeBundlePath) + + expect(files).toContain('.checkly/embedded-packages/@acme+private-utils@1.2.3.tgz') + }, DEFAULT_TEST_TIMEOUT) + }) + + describe('embedded packages validation', () => { + let fixt: FixtureSandbox + + beforeAll(async () => { + fixt = await FixtureSandbox.create({ + source: path.join(__dirname, 'fixtures', 'playwright-check', 'test-cases', 'test-embedded-packages-not-found'), + }) + }, DEFAULT_TEST_TIMEOUT) + + afterAll(async () => { + await fixt?.destroy() + }) + + it('should fail validation for a package that is not in the lockfile', async () => { + const output = await parseProject(fixt) + + expect(output.diagnostics.fatal).toBe(true) + expect(output.payload).toBeNull() + + const observation = output.diagnostics.observations.find(obs => obs.message.includes('no-such-package')) + expect(observation).toBeDefined() + expect(observation?.fatal).toBe(true) + expect(observation?.message).toContain('does not match any package in the lockfile') + }, DEFAULT_TEST_TIMEOUT) + }) + describe('bundling with absolute include path', () => { let fixt: FixtureSandbox diff --git a/packages/cli/src/constructs/__tests__/project-embedded-packages.spec.ts b/packages/cli/src/constructs/__tests__/project-embedded-packages.spec.ts new file mode 100644 index 000000000..ffa0bae50 --- /dev/null +++ b/packages/cli/src/constructs/__tests__/project-embedded-packages.spec.ts @@ -0,0 +1,147 @@ +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 } from '../diagnostics.js' +import { InvalidPropertyValueDiagnostic, UnsatisfiedLocalPrerequisitesDiagnostic } from '../construct-diagnostics.js' +import { Package, Workspace } from '../../services/check-parser/package-files/workspace.js' +import { Ok, Err } from '../../services/check-parser/package-files/result.js' + +describe('Project embedded packages validation', () => { + let dir: string + + beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-embed-validate-')) + await fs.writeFile(path.join(dir, 'playwright.config.ts'), 'export default {}\n') + await fs.writeFile(path.join(dir, 'pnpm-lock.yaml'), [ + `lockfileVersion: '9.0'`, + `packages:`, + ` present-pkg@1.0.0:`, + ` resolution: {integrity: sha512-aaa}`, + ].join('\n')) + await fs.writeFile(path.join(dir, 'yarn.lock'), '') + }) + + afterAll(async () => { + await fs.rm(dir, { recursive: true, force: true }) + }) + + beforeEach(() => { + Session.reset() + }) + + afterEach(() => { + Session.reset() + }) + + // `lockfile: null` sets up a workspace without a lockfile. + const setupProject = ({ withPlaywrightCheck = true, lockfile = 'pnpm-lock.yaml' as string | null } = {}) => { + const project = new Project('embed-validate', { name: 'Embed Validate' }) + Session.project = project + Session.basePath = dir + Session.contextPath = dir + Session.checkDefaults = {} + Session.workspace = Ok(new Workspace({ + root: new Package({ name: 'embed-validate', path: dir }), + packages: [], + lockfile: lockfile !== null + ? Ok(path.join(dir, lockfile)) + : Err(new Error('no lockfile')), + configFile: Err(new Error('no config file')), + })) + + if (withPlaywrightCheck) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const check = new PlaywrightCheck('pw-suite', { + name: 'PW Suite', + playwrightConfigPath: path.join(dir, 'playwright.config.ts'), + }) + } + + return project + } + + const validateEmbeddedDiagnostics = async (project: Project) => { + const diagnostics = new Diagnostics() + await project.validate(diagnostics) + // Ignore diagnostics produced by the checks themselves; only the + // project-level embedded-packages ones are under test here. + return diagnostics.observations.filter(diag => + diag instanceof UnsatisfiedLocalPrerequisitesDiagnostic + || (diag instanceof InvalidPropertyValueDiagnostic && diag.property === 'checks.embeddedPackages')) + } + + it('maps a missing lockfile to an unsatisfied-prerequisites diagnostic', async () => { + const project = setupProject({ lockfile: null }) + Session.embeddedPackages = ['present-pkg'] + + const observations = await validateEmbeddedDiagnostics(project) + expect(observations).toHaveLength(1) + expect(observations[0]).toBeInstanceOf(UnsatisfiedLocalPrerequisitesDiagnostic) + expect(observations[0].message).toContain('require a lockfile') + }) + + it('maps an unsupported lockfile to an unsatisfied-prerequisites diagnostic', async () => { + const project = setupProject({ lockfile: 'yarn.lock' }) + Session.embeddedPackages = ['present-pkg'] + + const observations = await validateEmbeddedDiagnostics(project) + expect(observations).toHaveLength(1) + expect(observations[0]).toBeInstanceOf(UnsatisfiedLocalPrerequisitesDiagnostic) + expect(observations[0].message).toContain('yarn.lock') + }) + + it('groups multiple spec issues into a single diagnostic', async () => { + const project = setupProject() + Session.embeddedPackages = ['missing-one', 'missing-two'] + + const observations = await validateEmbeddedDiagnostics(project) + expect(observations).toHaveLength(1) + expect(observations[0]).toBeInstanceOf(InvalidPropertyValueDiagnostic) + expect(observations[0].message).toContain('missing-one') + expect(observations[0].message).toContain('missing-two') + }) + + it('accepts specs that resolve against the lockfile', async () => { + const project = setupProject() + Session.embeddedPackages = ['present-pkg'] + + const observations = await validateEmbeddedDiagnostics(project) + expect(observations).toHaveLength(0) + }) + + it('skips validation when the project has no Playwright checks', async () => { + const project = setupProject({ withPlaywrightCheck: false }) + Session.embeddedPackages = ['missing-one'] + + const observations = await validateEmbeddedDiagnostics(project) + expect(observations).toHaveLength(0) + }) +}) + +describe('Session.getEmbeddedPackagesMaterializer()', () => { + afterEach(() => { + Session.reset() + }) + + it('returns undefined without configuration', () => { + expect(Session.getEmbeddedPackagesMaterializer()).toBeUndefined() + Session.embeddedPackages = [] + expect(Session.getEmbeddedPackagesMaterializer()).toBeUndefined() + }) + + it('memoizes the instance and reset() clears it', () => { + Session.embeddedPackages = ['some-pkg'] + const first = Session.getEmbeddedPackagesMaterializer() + expect(first).toBeDefined() + expect(Session.getEmbeddedPackagesMaterializer()).toBe(first) + + Session.reset() + expect(Session.embeddedPackagesMaterializer).toBeUndefined() + }) +}) diff --git a/packages/cli/src/constructs/project.ts b/packages/cli/src/constructs/project.ts index 271db5335..f4ad067da 100644 --- a/packages/cli/src/constructs/project.ts +++ b/packages/cli/src/constructs/project.ts @@ -6,10 +6,15 @@ import { Construct } from './construct.js' import { Check, AlertChannelSubscription, AlertChannel, CheckGroup, MaintenanceWindow, Dashboard, PrivateLocation, HeartbeatMonitor, PrivateLocationCheckAssignment, PrivateLocationGroupAssignment, - StatusPage, StatusPageService, + StatusPage, StatusPageService, PlaywrightCheck, } from './/index.js' import { Diagnostics } from './diagnostics.js' -import { ConstructDiagnostic, ConstructDiagnostics, InvalidPropertyValueDiagnostic } from './construct-diagnostics.js' +import { + ConstructDiagnostic, + ConstructDiagnostics, + InvalidPropertyValueDiagnostic, + UnsatisfiedLocalPrerequisitesDiagnostic, +} from './construct-diagnostics.js' import { ProjectBundle, ProjectDataBundle } from './project-bundle.js' import { Bundler } from '../services/check-parser/bundler.js' import { Session } from './session.js' @@ -110,6 +115,57 @@ export class Project extends Construct { ) diagnostics.extend(...constructDiagnostics) + + await this.#validateEmbeddedPackages(diagnostics) + } + + /** + * Validates the project-wide `checks.embeddedPackages` option once per + * project (individual checks share the session-level materializer). Only + * local checks run here — resolving the configured specs against the + * lockfile — no tarballs are fetched until bundling. Skipped when the + * project has no Playwright checks: the option only affects Playwright + * code bundles, and no bundling (or materialization) happens without one. + * Deliberately ignores testOnly flags and the session check filter — a + * configuration problem should surface even on a run that happens to + * filter out every Playwright check. + */ + async #validateEmbeddedPackages (diagnostics: Diagnostics): Promise { + const materializer = Session.getEmbeddedPackagesMaterializer() + if (materializer === undefined) { + return + } + + const hasPlaywrightChecks = Object.values(this.data.check) + .some(check => check instanceof PlaywrightCheck) + if (!hasPlaywrightChecks) { + return + } + + const { issues } = await materializer.plan() + + // A large monorepo can legitimately embed dozens of packages, so a + // stale config could produce dozens of issues; keep the output + // readable by grouping the per-entry issues into one diagnostic. + const lockfileIssues = issues.filter(issue => + issue.type === 'missing-lockfile' || issue.type === 'unsupported-lockfile') + const specIssues = issues.filter(issue => !lockfileIssues.includes(issue)) + + for (const issue of lockfileIssues) { + diagnostics.add(new UnsatisfiedLocalPrerequisitesDiagnostic(new Error(issue.message))) + } + + if (specIssues.length === 1) { + diagnostics.add(new InvalidPropertyValueDiagnostic('checks.embeddedPackages', new Error(specIssues[0].message))) + } else if (specIssues.length > 1) { + diagnostics.add(new InvalidPropertyValueDiagnostic( + 'checks.embeddedPackages', + new Error( + `${specIssues.length} entries have problems:\n\n` + + specIssues.map(issue => ` - ${issue.message}`).join('\n'), + ), + )) + } } allowTestOnly (enabled: boolean) { diff --git a/packages/cli/src/constructs/session.ts b/packages/cli/src/constructs/session.ts index efe8e3bc1..742042409 100644 --- a/packages/cli/src/constructs/session.ts +++ b/packages/cli/src/constructs/session.ts @@ -18,6 +18,7 @@ import { Workspace } from '../services/check-parser/package-files/workspace.js' import { npmPackageManager, PackageManager } from '../services/check-parser/package-files/package-manager.js' import { Err, Result } from '../services/check-parser/package-files/result.js' import { Runtime } from '../runtimes/index.js' +import { EmbeddedPackagesMaterializer } from '../services/embedded-packages/materializer.js' import { PlaywrightProjectBundler } from '../services/playwright-project-bundler.js' import { PROJECT_CONSTRUCT_TYPE } from '../constants.js' @@ -70,6 +71,7 @@ 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[] @@ -97,6 +99,7 @@ export class Session { this.privateLocations = [] this.parsers = new Map() this.playwrightProjectBundler = undefined + this.embeddedPackagesMaterializer = undefined this.constructExports = [] this.ignoreDirectoriesMatch = [] this.embeddedPackages = undefined @@ -230,6 +233,27 @@ export class Session { return this.playwrightProjectBundler } + /** + * The materializer for the project's `checks.embeddedPackages` option, or + * undefined when the option is not set. Memoized so that validation and + * every concurrently bundling check share one plan and one download run. + */ + static getEmbeddedPackagesMaterializer (): EmbeddedPackagesMaterializer | undefined { + const specs = this.embeddedPackages + if (specs === undefined || specs.length === 0) { + return undefined + } + if (this.embeddedPackagesMaterializer === undefined) { + this.embeddedPackagesMaterializer = new EmbeddedPackagesMaterializer({ + specs, + lockfilePath: this.workspace.ok()?.lockfile.ok(), + workspaceRoot: this.basePath, + contextDir: this.contextPath, + }) + } + return this.embeddedPackagesMaterializer + } + static relativePosixPath (filePath: string): string { return pathToPosix(path.relative(Session.basePath!, filePath)) } diff --git a/packages/cli/src/services/checkly-config-loader.ts b/packages/cli/src/services/checkly-config-loader.ts index a12955e0b..c605f6cf1 100644 --- a/packages/cli/src/services/checkly-config-loader.ts +++ b/packages/cli/src/services/checkly-config-loader.ts @@ -104,7 +104,21 @@ export type ChecklyConfig = { * Each entry is a package name (`'@acme/private-utils'`), which embeds * every version of that package found in the workspace lockfile, or a * `name@version` pin (`'legacy-private-pkg@2.1.0'`) with an exact semver - * version. + * version. List every package the runner cannot fetch, including + * private packages that only appear as (transitive) dependencies of + * other private packages — dependencies of listed packages are not + * embedded automatically. + * + * Tarballs are resolved against the workspace root lockfile + * (`pnpm-lock.yaml` or `package-lock.json`), reused from local caches + * (the CLI's own, then npm's) when possible and otherwise downloaded + * from the registry configured in `.npmrc` (including scoped registries + * and auth tokens), and always verified against the lockfile's recorded + * integrity. The project tree is never written to; downloads are kept + * in a per-user cache directory (override with `CHECKLY_CACHE_DIR`). + * In the code bundle the tarballs land at + * `.checkly/embedded-packages/*.tgz`, where Checkly runners serve them + * through a local registry during dependency installation. */ embeddedPackages?: string[] /** diff --git a/packages/cli/src/services/playwright-project-bundler.ts b/packages/cli/src/services/playwright-project-bundler.ts index 16fb1a74b..e950d4987 100644 --- a/packages/cli/src/services/playwright-project-bundler.ts +++ b/packages/cli/src/services/playwright-project-bundler.ts @@ -142,6 +142,22 @@ export class PlaywrightProjectBundler { })) } + // Embedded package tarballs live in the CLI cache, outside the bundle + // root, 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 635152678..19620948e 100644 --- a/packages/cli/src/services/project-parser.ts +++ b/packages/cli/src/services/project-parser.ts @@ -186,6 +186,9 @@ export async function parseProject (opts: ProjectParseOpts): Promise { 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 From 3eda56161423d84bf4d2582f25537abbc45c882a Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Thu, 13 Aug 2026 00:25:33 +0900 Subject: [PATCH 04/11] test(cli): make embedded-packages npm cache tests platform-independent [RED-855] The materializer cacache test seeded a fake npm cache at ~/.npm, but on Windows npm caches under %LOCALAPPDATA%\npm-cache, so the lookup missed and the test fell through to a recorded network request. Pin the location via npm_config_cache, which production honors on every platform, and add direct coverage for the win32 LOCALAPPDATA lookup branch. Co-Authored-By: Claude Fable 5 --- .../embedded-packages/__tests__/cache.spec.ts | 12 ++++++++++++ .../embedded-packages/__tests__/materializer.spec.ts | 11 +++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/services/embedded-packages/__tests__/cache.spec.ts b/packages/cli/src/services/embedded-packages/__tests__/cache.spec.ts index 118e62f06..ad0fa77fd 100644 --- a/packages/cli/src/services/embedded-packages/__tests__/cache.spec.ts +++ b/packages/cli/src/services/embedded-packages/__tests__/cache.spec.ts @@ -108,6 +108,18 @@ describe('lookupNpmCacache()', () => { 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', diff --git a/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts b/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts index bc313a085..3f89acebb 100644 --- a/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts +++ b/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts @@ -209,15 +209,22 @@ packages: {} }) 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( - homedir, '.npm', '_cacache', 'content-v2', 'sha512', + 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) - const tarballs = await makeMaterializer(['bar@2.0.0']).materialize() + // 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) }) From 937c7173a762e81b9b770fb8b8fa6741bfef8e99 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Thu, 13 Aug 2026 15:41:14 +0900 Subject: [PATCH 05/11] feat(cli): cache embedded tarballs under node_modules/.cache/checkly [RED-855] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The embedded-packages cache now defaults to the workspace root's node_modules/.cache/checkly — the conventional tool-cache location that incremental installs leave alone and node_modules-caching CI setups persist automatically — so warm caches travel with the project instead of living in a per-user directory. The cache is multi-root: reads also consult the per-user platform directory, and writes fall back to it when the project location is not writable (e.g. a read-only checkout), with CHECKLY_CACHE_DIR remaining the single-location override. Co-Authored-By: Claude Fable 5 --- .../references/configure-playwright-checks.md | 2 +- .../cli/src/services/checkly-config-loader.ts | 9 +- .../embedded-packages/__tests__/cache.spec.ts | 93 ++++++++--- .../__tests__/materializer.spec.ts | 14 ++ .../src/services/embedded-packages/cache.ts | 150 ++++++++++++------ .../embedded-packages/materializer.ts | 11 +- .../services/playwright-project-bundler.ts | 12 +- 7 files changed, 209 insertions(+), 82 deletions(-) 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 d90d48990..2955d96d1 100644 --- a/packages/cli/src/ai-context/references/configure-playwright-checks.md +++ b/packages/cli/src/ai-context/references/configure-playwright-checks.md @@ -14,7 +14,7 @@ - Use `installCommand` only when the default package-manager install command is not enough. - Checkly caches installed dependencies between runs, keyed off the lock file, `package.json` and `.npmrc` contents. To force a reinstall declaratively, set `caching.dependencyCache.version` (a string or a safe integer) at the top level of `checkly.config.ts` (not per check — one code bundle serves all Playwright Check Suites) and change its value whenever the cache should be invalidated; scheduled checks pick up the change on the next `checkly deploy`. Unset or empty-string values leave the cache key unchanged, so a dynamic value such as `version: process.env.DEPENDENCY_CACHE_VERSION` is safe when the variable is not always set. For a one-off reinstall during an ad-hoc run, use the `--refresh-cache` flag available on the run/test commands (`checkly test`, `checkly pw-test`, `checkly trigger`, `checkly checks run`) instead; the config value is the persistent knob that also applies to deployed, scheduled checks. - In Checkly CLI v8.0.0 and later, `include` patterns resolve relative to the Playwright config directory, not the project root. If `playwrightConfigPath` points to a subdirectory, adjust `include` globs. Example: `playwrightConfigPath: "./e2e/playwright.config.ts"` with a root fixture at `fixtures/data.json` needs `include: ["../fixtures/data.json"]`. -- If dependencies come from a private registry that Checkly's infrastructure cannot reach (for example an intranet-only Nexus mirror), list them in `checks.embeddedPackages` in `checkly.config.ts`. Each entry is a package name (embeds every version found in the lockfile) or an exact `name@version` pin. 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. The project tree is never written to; downloads land in a per-user cache directory (macOS: `~/Library/Caches/checkly`; Linux: `$XDG_CACHE_HOME/checkly` or `~/.cache/checkly`; override with `CHECKLY_CACHE_DIR` — persist it in CI to avoid re-downloading). The machine running `checkly deploy`/`test` needs registry access on a cold cache. Applies to Playwright Check Suites only, not browser or multistep checks. +- If dependencies come from a private registry that Checkly's infrastructure cannot reach (for example an intranet-only Nexus mirror), list them in `checks.embeddedPackages` in `checkly.config.ts`. Each entry is a package name (embeds every version found in the lockfile) or an exact `name@version` pin. List every unreachable package by name, including private packages that only appear as transitive dependencies of other private packages — dependencies of listed packages are not embedded automatically. The CLI resolves entries against the workspace-root lockfile (`pnpm-lock.yaml` or `package-lock.json`), reuses tarballs from local caches (its own, then npm's) or downloads them from the registry configured in `.npmrc`, verifies each against the lockfile's integrity hash, and ships them inside the code bundle at `.checkly/embedded-packages/*.tgz`, where the runner serves them through a local registry during install. Downloads are cached under the workspace root's `node_modules/.cache/checkly` (in a monorepo that is the repo root, not the member package; override with `CHECKLY_CACHE_DIR`; a per-user cache dir is the fallback when the project location isn't writable), so nothing lands in the project outside `node_modules`. CI setups that cache `node_modules` — or platforms that preserve `node_modules/.cache` — persist the tarballs automatically; otherwise persist `CHECKLY_CACHE_DIR` in CI to avoid re-downloading (note `npm ci` deletes `node_modules` wholesale, unlike incremental pnpm installs). The machine running `checkly deploy`/`test` needs registry access on a cold cache. Applies to Playwright Check Suites only, not browser or multistep checks. ## Install troubleshooting diff --git a/packages/cli/src/services/checkly-config-loader.ts b/packages/cli/src/services/checkly-config-loader.ts index c605f6cf1..9cc165458 100644 --- a/packages/cli/src/services/checkly-config-loader.ts +++ b/packages/cli/src/services/checkly-config-loader.ts @@ -114,9 +114,12 @@ export type ChecklyConfig = { * (the CLI's own, then npm's) when possible and otherwise downloaded * from the registry configured in `.npmrc` (including scoped registries * and auth tokens), and always verified against the lockfile's recorded - * integrity. The project tree is never written to; downloads are kept - * in a per-user cache directory (override with `CHECKLY_CACHE_DIR`). - * In the code bundle the tarballs land at + * integrity. Downloads are cached under the workspace root's + * `node_modules/.cache/checkly` + * (override with `CHECKLY_CACHE_DIR`; a per-user cache directory + * serves as the fallback if the project location isn't writable), so + * nothing lands in the project outside `node_modules`. In the code + * bundle the tarballs land at * `.checkly/embedded-packages/*.tgz`, where Checkly runners serve them * through a local registry during dependency installation. */ diff --git a/packages/cli/src/services/embedded-packages/__tests__/cache.spec.ts b/packages/cli/src/services/embedded-packages/__tests__/cache.spec.ts index ad0fa77fd..5ed4e22eb 100644 --- a/packages/cli/src/services/embedded-packages/__tests__/cache.spec.ts +++ b/packages/cli/src/services/embedded-packages/__tests__/cache.spec.ts @@ -5,32 +5,40 @@ import path from 'node:path' import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { TarballCache, lookupNpmCacache, resolveCacheDir } from '../cache.js' +import { TarballCache, lookupNpmCacache, resolveCacheDirs } from '../cache.js' const content = Buffer.from('fake tarball content') const sha512Base64 = createHash('sha512').update(content).digest('base64') const sha512Hex = createHash('sha512').update(content).digest('hex') const integrity = `sha512-${sha512Base64}` -describe('resolveCacheDir()', () => { +describe('resolveCacheDirs()', () => { const home = path.sep === '/' ? '/home/user' : 'C:\\Users\\user' - it('honors CHECKLY_CACHE_DIR', () => { - expect(resolveCacheDir({ CHECKLY_CACHE_DIR: '/tmp/custom-cache' }, 'linux', home)) - .toBe(path.resolve('/tmp/custom-cache')) + 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('uses Library/Caches on macOS', () => { - expect(resolveCacheDir({}, 'darwin', home)).toBe(path.join(home, 'Library', 'Caches', 'checkly')) + 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 XDG_CACHE_HOME when set', () => { - expect(resolveCacheDir({ XDG_CACHE_HOME: '/xdg-cache' }, 'linux', home)) - .toBe(path.join('/xdg-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('falls back to ~/.cache elsewhere', () => { - expect(resolveCacheDir({}, 'linux', home)).toBe(path.join(home, '.cache', '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')]) }) }) @@ -131,14 +139,57 @@ describe('lookupNpmCacache()', () => { }) describe('TarballCache.default()', () => { - it('derives the cache location from the injected env, platform and homedir', async () => { - const home = await fs.mkdtemp(path.join(os.tmpdir(), 'checkly-cache-home-')) - try { - const cache = TarballCache.default({}, 'linux', home) - const putPath = await cache.put(integrity, content) - expect(putPath.startsWith(path.join(home, '.cache', 'checkly', 'embedded-packages'))).toBe(true) - } finally { - await fs.rm(home, { recursive: true, force: true }) - } + 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__/materializer.spec.ts b/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts index 3f89acebb..25480f669 100644 --- a/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts +++ b/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts @@ -201,6 +201,20 @@ packages: {} 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) diff --git a/packages/cli/src/services/embedded-packages/cache.ts b/packages/cli/src/services/embedded-packages/cache.ts index 9c132eff5..f12fb0bc8 100644 --- a/packages/cli/src/services/embedded-packages/cache.ts +++ b/packages/cli/src/services/embedded-packages/cache.ts @@ -4,24 +4,17 @@ 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' -/** - * The Checkly CLI's per-user cache directory. `CHECKLY_CACHE_DIR` overrides - * the platform default (macOS: `~/Library/Caches/checkly`, Windows: - * `%LOCALAPPDATA%\checkly\Cache`, elsewhere: `$XDG_CACHE_HOME/checkly` or - * `~/.cache/checkly`). - */ -export function resolveCacheDir ( - env: NodeJS.ProcessEnv = process.env, - platform: NodeJS.Platform = process.platform, - homedir = os.homedir(), -): string { - const override = env.CHECKLY_CACHE_DIR - if (override !== undefined && override !== '') { - return path.resolve(override) - } +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') @@ -42,29 +35,65 @@ export function resolveCacheDir ( } /** - * A content-addressed store of package tarballs under the CLI cache - * directory, keyed by the lockfile's integrity hash. Every read verifies - * the content, so a corrupt entry degrades to a cache miss rather than a - * user-facing error. + * 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 { - #rootDir: string + #rootDirs: string[] - constructor (rootDir: string) { - this.#rootDir = rootDir + 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(path.join(resolveCacheDir(env, platform, homedir), 'embedded-packages')) + return new TarballCache(resolveCacheDirs(env, projectRoot, platform, homedir) + .map(dir => path.join(dir, 'embedded-packages'))) } - #pathFor (hash: IntegrityHash): string { + #pathFor (rootDir: string, hash: IntegrityHash): string { const hex = integrityHashToHex(hash) - return path.join(this.#rootDir, hash.algorithm, hex.slice(0, 2), `${hex.slice(2)}.tgz`) + return path.join(rootDir, hash.algorithm, hex.slice(0, 2), `${hex.slice(2)}.tgz`) } /** @@ -76,46 +105,67 @@ export class TarballCache { if (hash === undefined) { return undefined } - const filePath = this.#pathFor(hash) - let content: Buffer - try { - content = await fs.readFile(filePath) - } catch { - return undefined - } + for (const rootDir of this.#rootDirs) { + const filePath = this.#pathFor(rootDir, hash) - if (!verifyIntegrity(content, integrity)) { - await fs.rm(filePath, { force: true }).catch(() => {}) - return undefined + 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 filePath + return undefined } /** - * Stores verified tarball content 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. + * 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}')`) } - const filePath = this.#pathFor(hash) - - await fs.mkdir(path.dirname(filePath), { recursive: true }) - const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp` - try { - await fs.writeFile(tempPath, content) - await fs.rename(tempPath, filePath) - } finally { - await fs.rm(tempPath, { force: true }).catch(() => {}) + + 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(() => {}) + } } - return filePath + 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 }, + ) } } diff --git a/packages/cli/src/services/embedded-packages/materializer.ts b/packages/cli/src/services/embedded-packages/materializer.ts index a714a8939..cbcbe91a4 100644 --- a/packages/cli/src/services/embedded-packages/materializer.ts +++ b/packages/cli/src/services/embedded-packages/materializer.ts @@ -124,7 +124,12 @@ export class EmbeddedPackagesMaterializer { this.#options = options this.#env = options.env ?? process.env this.#homedir = options.homedir ?? os.homedir() - this.#cache = TarballCache.default(this.#env, process.platform, this.#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 { @@ -246,8 +251,10 @@ export class EmbeddedPackagesMaterializer { return [] } + // Safe to assert: a missing lockfile is a plan issue, and issues abort + // above. const npmrcConfig = await loadNpmrcConfig(defaultNpmrcPaths( - this.#options.workspaceRoot ?? path.dirname(this.#options.lockfilePath!), + this.#projectRoot!, this.#homedir, this.#options.contextDir, ), this.#env) diff --git a/packages/cli/src/services/playwright-project-bundler.ts b/packages/cli/src/services/playwright-project-bundler.ts index e950d4987..23dbae483 100644 --- a/packages/cli/src/services/playwright-project-bundler.ts +++ b/packages/cli/src/services/playwright-project-bundler.ts @@ -142,11 +142,13 @@ export class PlaywrightProjectBundler { })) } - // Embedded package tarballs live in the CLI cache, outside the bundle - // root, 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. + // 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()) { From 9768fb9e78fdbc35f21ae2e515f063b9c7236d3d Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Fri, 14 Aug 2026 15:52:02 +0900 Subject: [PATCH 06/11] feat(cli): support wildcards in checks.embeddedPackages [RED-855] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entries may now contain * wildcards (@acme/*, acme-*, @acme/*-utils), each matching any run of characters except /, so a pattern never crosses the scope separator. Wildcards resolve against the workspace lockfile only and combine with exact version pins. Matches that cannot be embedded are skipped — workspace members silently, git/file/URL and integrity-less dependencies via a warning diagnostic — while a spec whose only matches cannot be embedded, or that matches nothing at all, remains an error. Each wildcard announces what it selected during bundling. Co-Authored-By: Claude Fable 5 --- .../references/configure-playwright-checks.md | 2 +- .../project-embedded-packages.spec.ts | 19 +- packages/cli/src/constructs/project.ts | 11 +- .../__tests__/checkly-config-loader.spec.ts | 3 +- .../configs/embedded-packages-valid.ts | 2 +- .../cli/src/services/checkly-config-loader.ts | 18 +- .../__tests__/lockfile-packages.spec.ts | 39 +++ .../__tests__/materializer.spec.ts | 222 ++++++++++++++++++ .../embedded-packages/__tests__/spec.spec.ts | 67 +++++- .../embedded-packages/lockfile-packages.ts | 35 ++- .../embedded-packages/materializer.ts | 115 +++++++-- .../src/services/embedded-packages/spec.ts | 69 +++++- 12 files changed, 557 insertions(+), 45 deletions(-) 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 2955d96d1..2430d5577 100644 --- a/packages/cli/src/ai-context/references/configure-playwright-checks.md +++ b/packages/cli/src/ai-context/references/configure-playwright-checks.md @@ -14,7 +14,7 @@ - Use `installCommand` only when the default package-manager install command is not enough. - Checkly caches installed dependencies between runs, keyed off the lock file, `package.json` and `.npmrc` contents. To force a reinstall declaratively, set `caching.dependencyCache.version` (a string or a safe integer) at the top level of `checkly.config.ts` (not per check — one code bundle serves all Playwright Check Suites) and change its value whenever the cache should be invalidated; scheduled checks pick up the change on the next `checkly deploy`. Unset or empty-string values leave the cache key unchanged, so a dynamic value such as `version: process.env.DEPENDENCY_CACHE_VERSION` is safe when the variable is not always set. For a one-off reinstall during an ad-hoc run, use the `--refresh-cache` flag available on the run/test commands (`checkly test`, `checkly pw-test`, `checkly trigger`, `checkly checks run`) instead; the config value is the persistent knob that also applies to deployed, scheduled checks. - In Checkly CLI v8.0.0 and later, `include` patterns resolve relative to the Playwright config directory, not the project root. If `playwrightConfigPath` points to a subdirectory, adjust `include` globs. Example: `playwrightConfigPath: "./e2e/playwright.config.ts"` with a root fixture at `fixtures/data.json` needs `include: ["../fixtures/data.json"]`. -- If dependencies come from a private registry that Checkly's infrastructure cannot reach (for example an intranet-only Nexus mirror), list them in `checks.embeddedPackages` in `checkly.config.ts`. Each entry is a package name (embeds every version found in the lockfile) or an exact `name@version` pin. List every unreachable package by name, including private packages that only appear as transitive dependencies of other private packages — dependencies of listed packages are not embedded automatically. The CLI resolves entries against the workspace-root lockfile (`pnpm-lock.yaml` or `package-lock.json`), reuses tarballs from local caches (its own, then npm's) or downloads them from the registry configured in `.npmrc`, verifies each against the lockfile's integrity hash, and ships them inside the code bundle at `.checkly/embedded-packages/*.tgz`, where the runner serves them through a local registry during install. Downloads are cached under the workspace root's `node_modules/.cache/checkly` (in a monorepo that is the repo root, not the member package; override with `CHECKLY_CACHE_DIR`; a per-user cache dir is the fallback when the project location isn't writable), so nothing lands in the project outside `node_modules`. CI setups that cache `node_modules` — or platforms that preserve `node_modules/.cache` — persist the tarballs automatically; otherwise persist `CHECKLY_CACHE_DIR` in CI to avoid re-downloading (note `npm ci` deletes `node_modules` wholesale, unlike incremental pnpm installs). The machine running `checkly deploy`/`test` needs registry access on a cold cache. Applies to Playwright Check Suites only, not browser or multistep checks. +- If dependencies come from a private registry that Checkly's infrastructure cannot reach (for example an intranet-only Nexus mirror), list them in `checks.embeddedPackages` in `checkly.config.ts`. Each entry is a package name (embeds every version found in the lockfile) or an exact `name@version` pin; names may contain `*` wildcards (`@acme/*`, `acme-*`, `@acme/*-utils`) where each `*` matches any run of characters except `/` (never crossing the scope separator); as long as a spec matches at least one registry package, matches that cannot be embedded are skipped (workspace members silently, git/file/URL dependencies and integrity-less entries with a warning since the runner must fetch those itself), while a spec whose only matches cannot be embedded — or that matches nothing at all — is an error; a pattern embeds every lockfile version of every package it matches, so scope it to the packages the runner genuinely cannot fetch. List every unreachable package by name, including private packages that only appear as transitive dependencies of other private packages — dependencies of listed packages are not embedded automatically. The CLI resolves entries against the workspace-root lockfile (`pnpm-lock.yaml` or `package-lock.json`), reuses tarballs from local caches (its own, then npm's) or downloads them from the registry configured in `.npmrc`, verifies each against the lockfile's integrity hash, and ships them inside the code bundle at `.checkly/embedded-packages/*.tgz`, where the runner serves them through a local registry during install. Downloads are cached under the workspace root's `node_modules/.cache/checkly` (in a monorepo that is the repo root, not the member package; override with `CHECKLY_CACHE_DIR`; a per-user cache dir is the fallback when the project location isn't writable), so nothing lands in the project outside `node_modules`. CI setups that cache `node_modules` — or platforms that preserve `node_modules/.cache` — persist the tarballs automatically; otherwise persist `CHECKLY_CACHE_DIR` in CI to avoid re-downloading (note `npm ci` deletes `node_modules` wholesale, unlike incremental pnpm installs). The machine running `checkly deploy`/`test` needs registry access on a cold cache. Applies to Playwright Check Suites only, not browser or multistep checks. ## Install troubleshooting diff --git a/packages/cli/src/constructs/__tests__/project-embedded-packages.spec.ts b/packages/cli/src/constructs/__tests__/project-embedded-packages.spec.ts index ffa0bae50..d859f217c 100644 --- a/packages/cli/src/constructs/__tests__/project-embedded-packages.spec.ts +++ b/packages/cli/src/constructs/__tests__/project-embedded-packages.spec.ts @@ -7,7 +7,7 @@ import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from import { Project } from '../project.js' import { PlaywrightCheck } from '../playwright-check.js' import { Session } from '../session.js' -import { Diagnostics } from '../diagnostics.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' @@ -23,6 +23,8 @@ describe('Project embedded packages validation', () => { `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'), '') }) @@ -76,6 +78,21 @@ describe('Project embedded packages validation', () => { || (diag instanceof InvalidPropertyValueDiagnostic && diag.property === 'checks.embeddedPackages')) } + it('surfaces plan warnings as non-fatal warning diagnostics', async () => { + const project = setupProject() + Session.embeddedPackages = ['present-*'] + const diagnostics = new Diagnostics() + await project.validate(diagnostics) + const warning = diagnostics.observations.find((diag): diag is WarningDiagnostic => + diag instanceof WarningDiagnostic && diag.title === 'Embedded packages') + expect(warning).toBeDefined() + expect(warning?.message).toContain('present-git') + expect(warning?.isFatal()).toBe(false) + // The wildcard resolves present-pkg, so no fatal issue accompanies it. + expect(diagnostics.observations.filter(diag => + diag instanceof InvalidPropertyValueDiagnostic && diag.property === 'checks.embeddedPackages')).toEqual([]) + }) + it('maps a missing lockfile to an unsatisfied-prerequisites diagnostic', async () => { const project = setupProject({ lockfile: null }) Session.embeddedPackages = ['present-pkg'] diff --git a/packages/cli/src/constructs/project.ts b/packages/cli/src/constructs/project.ts index f4ad067da..9ed341f9f 100644 --- a/packages/cli/src/constructs/project.ts +++ b/packages/cli/src/constructs/project.ts @@ -8,7 +8,7 @@ import { PrivateLocation, HeartbeatMonitor, PrivateLocationCheckAssignment, PrivateLocationGroupAssignment, StatusPage, StatusPageService, PlaywrightCheck, } from './/index.js' -import { Diagnostics } from './diagnostics.js' +import { Diagnostics, WarningDiagnostic } from './diagnostics.js' import { ConstructDiagnostic, ConstructDiagnostics, @@ -142,7 +142,14 @@ export class Project extends Construct { return } - const { issues } = await materializer.plan() + const { issues, warnings } = await materializer.plan() + + for (const warning of warnings) { + diagnostics.add(new WarningDiagnostic({ + title: 'Embedded packages', + message: warning, + })) + } // A large monorepo can legitimately embed dozens of packages, so a // stale config could produce dozens of issues; keep the output 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 d6d10d971..c7b84ab63 100644 --- a/packages/cli/src/services/__tests__/checkly-config-loader.spec.ts +++ b/packages/cli/src/services/__tests__/checkly-config-loader.spec.ts @@ -110,7 +110,8 @@ describe('loadChecklyConfig()', () => { path.join(__dirname, 'fixtures', 'configs'), ['embedded-packages-valid.ts'], ) - expect(config.checks?.embeddedPackages).toEqual(['@acme/private-utils', 'legacy-private-pkg@2.1.0']) + expect(config.checks?.embeddedPackages) + .toEqual(['@acme/private-utils', 'legacy-private-pkg@2.1.0', '@acme/*', 'acme-*']) }) it('rejects a checks.embeddedPackages that is not an array', async () => { await expect(loadChecklyConfig( 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 index 0fc8c1216..a127b77a2 100644 --- a/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-valid.ts +++ b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-valid.ts @@ -4,7 +4,7 @@ const config = defineConfig({ projectName: 'test-config-project', logicalId: 'test-config-project', checks: { - embeddedPackages: ['@acme/private-utils', 'legacy-private-pkg@2.1.0'], + embeddedPackages: ['@acme/private-utils', 'legacy-private-pkg@2.1.0', '@acme/*', 'acme-*'], }, }) diff --git a/packages/cli/src/services/checkly-config-loader.ts b/packages/cli/src/services/checkly-config-loader.ts index 9cc165458..d25144321 100644 --- a/packages/cli/src/services/checkly-config-loader.ts +++ b/packages/cli/src/services/checkly-config-loader.ts @@ -104,10 +104,20 @@ export type ChecklyConfig = { * Each entry is a package name (`'@acme/private-utils'`), which embeds * every version of that package found in the workspace lockfile, or a * `name@version` pin (`'legacy-private-pkg@2.1.0'`) with an exact semver - * version. List every package the runner cannot fetch, including - * private packages that only appear as (transitive) dependencies of - * other private packages — dependencies of listed packages are not - * embedded automatically. + * version. Names may contain `*` wildcards (`'@acme/*'`, `'acme-*'`, + * `'@acme/*-utils'`); each `*` matches any run of characters except + * `/`, so a wildcard never crosses the scope separator. As long as a + * wildcard matches at least one registry package, matches that are not + * registry packages are skipped — workspace members silently, git/file/ + * URL dependencies with a warning (the runner must fetch those + * itself); a wildcard 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 package the runner + * cannot fetch, + * including private packages that only appear as (transitive) + * dependencies of other private packages — dependencies of listed + * packages are not embedded automatically. * * Tarballs are resolved against the workspace root lockfile * (`pnpm-lock.yaml` or `package-lock.json`), reused from local caches 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 index d4b87c5e6..6a27a43c9 100644 --- a/packages/cli/src/services/embedded-packages/__tests__/lockfile-packages.spec.ts +++ b/packages/cli/src/services/embedded-packages/__tests__/lockfile-packages.spec.ts @@ -230,11 +230,50 @@ packages: { 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(` diff --git a/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts b/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts index 25480f669..c4b5bd174 100644 --- a/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts +++ b/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts @@ -29,6 +29,21 @@ packages: ` } +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 @@ -108,6 +123,213 @@ describe('EmbeddedPackagesMaterializer', () => { expect(tarballs.map(t => t.archiveFilename)).toEqual(['bar@2.0.0.tgz', 'bar@3.0.0.tgz']) }) + it('resolves a scope wildcard to every matching package and version', async () => { + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +packages: + '@acme/foo@1.2.3': + resolution: {integrity: ${fooIntegrity}} + '@acme/foo-utils@2.0.0': + resolution: {integrity: ${barIntegrity}} + '@other/pkg@1.0.0': + resolution: {integrity: ${barIntegrity}} + bar@2.0.0: + resolution: {integrity: ${barIntegrity}} +`) + const { tarballs, issues } = await makeMaterializer(['@acme/*']).plan() + expect(issues).toEqual([]) + expect(tarballs.map(t => t.archiveFilename).sort()).toEqual([ + '@acme+foo-utils@2.0.0.tgz', + '@acme+foo@1.2.3.tgz', + ]) + }) + + it('resolves prefix and suffix wildcards against unscoped names only', async () => { + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +packages: + acme-utils@1.0.0: + resolution: {integrity: ${barIntegrity}} + acme-core@1.0.0: + resolution: {integrity: ${barIntegrity}} + '@acme/acme-extra@1.0.0': + resolution: {integrity: ${barIntegrity}} +`) + const { tarballs, issues } = await makeMaterializer(['acme-*']).plan() + expect(issues).toEqual([]) + // The wildcard does not cross the scope separator, so the scoped + // package stays out even though its name part matches. + expect(tarballs.map(t => t.archiveFilename).sort()).toEqual([ + 'acme-core@1.0.0.tgz', + 'acme-utils@1.0.0.tgz', + ]) + }) + + it('filters wildcard matches by an exact version pin', async () => { + const { tarballs, issues } = await makeMaterializer(['ba*@2.0.0']).plan() + expect(issues).toEqual([]) + expect(tarballs.map(t => t.archiveFilename)).toEqual(['bar@2.0.0.tgz']) + }) + + it('reports a wildcard that matches nothing in the lockfile', async () => { + const { issues } = await makeMaterializer(['@nomatch/*']).plan() + expect(issues).toHaveLength(1) + expect(issues[0].type).toBe('spec-not-found') + expect(issues[0].message).toContain('pattern matches') + }) + + it('reports a wildcard that only matches workspace packages', async () => { + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +importers: + .: + dependencies: + '@acme/shared': + specifier: workspace:* + version: link:packages/shared +packages: {} +`) + const { issues } = await makeMaterializer(['@acme/*']).plan() + expect(issues).toHaveLength(1) + expect(issues[0].type).toBe('spec-not-embeddable') + expect(issues[0].message).toContain('workspace package') + }) + + it('silently skips workspace packages a wildcard also matches', async () => { + // The monorepo case: the scope holds both registry packages and + // workspace members. The wildcard embeds the former and skips the + // latter without erroring. + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +importers: + .: + dependencies: + '@acme/shared': + specifier: workspace:* + version: link:packages/shared +packages: + '@acme/foo@1.2.3': + resolution: {integrity: ${fooIntegrity}} +`) + const { tarballs, issues } = await makeMaterializer(['@acme/*']).plan() + expect(issues).toEqual([]) + expect(tarballs.map(t => t.archiveFilename)).toEqual(['@acme+foo@1.2.3.tgz']) + }) + + it('reports unfetchable wildcard matches as plan warnings and announces matches when materializing', async () => { + // A bare * matches bar (registry, both versions) and git-dep (a git + // dependency the CLI cannot embed): the registry matches embed, the + // git dependency surfaces as a plan warning naming it, and the + // wildcard's selection is announced during materialization. + const materializer = makeMaterializer(['*']) + const { tarballs, issues, warnings } = await materializer.plan() + expect(issues).toEqual([]) + expect(tarballs.map(t => t.archiveFilename)).toEqual(['bar@2.0.0.tgz', 'bar@3.0.0.tgz']) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain('git-dep') + expect(warnings[0]).toContain('cannot be embedded') + const written = await captureStderr(async () => { + await materializer.materialize() + }) + const announcement = written.find(line => line.includes('matched 2 package(s)')) + expect(announcement).toContain(`'*'`) + expect(announcement).toContain('bar@2.0.0') + }) + + it('does not warn about unfetchable matches a version pin already excludes', async () => { + const npmLockfilePath = path.join(workspaceRoot, 'package-lock.json') + await fs.writeFile(npmLockfilePath, JSON.stringify({ + lockfileVersion: 3, + packages: { + 'node_modules/@acme/foo': { + version: '1.2.3', + resolved: 'https://registry.npmjs.org/@acme/foo/-/foo-1.2.3.tgz', + integrity: fooIntegrity, + }, + 'node_modules/@acme/legacy': { + version: '2.0.0', + resolved: 'git+ssh://git@github.com/acme/legacy.git#abc123', + }, + }, + })) + const { warnings, issues } = await makeMaterializer(['@acme/*@1.2.3'], { lockfilePath: npmLockfilePath }).plan() + expect(issues).toEqual([]) + // @acme/legacy@2.0.0 was excluded by the pin, not by embeddability — + // warning about it would send the user chasing a non-issue. + expect(warnings).toEqual([]) + }) + + it('does not warn about integrity-less duplicates of embedded registry entries', async () => { + // npm nests integrity-less bundled copies of packages that also + // exist as proper registry entries; the artifact IS embedded, so + // the duplicate must not surface as a skipped unfetchable match. + const npmLockfilePath = path.join(workspaceRoot, 'package-lock.json') + await fs.writeFile(npmLockfilePath, JSON.stringify({ + lockfileVersion: 3, + packages: { + 'node_modules/dup': { + version: '1.0.0', + resolved: 'https://registry.npmjs.org/dup/-/dup-1.0.0.tgz', + integrity: barIntegrity, + }, + 'node_modules/a/node_modules/dup': { version: '1.0.0', inBundle: true }, + }, + })) + const { tarballs, warnings, issues } = await makeMaterializer(['du*'], { lockfilePath: npmLockfilePath }).plan() + expect(issues).toEqual([]) + expect(warnings).toEqual([]) + expect(tarballs.map(t => t.archiveFilename)).toEqual(['dup@1.0.0.tgz']) + }) + + it('prefers the actionable excluded reason over version blame for an exact pinned spec', async () => { + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +packages: + foo@2.0.0: + resolution: {integrity: ${barIntegrity}} + foo@1.0.0: + resolution: {} +`) + const { issues } = await makeMaterializer(['foo@1.0.0']).plan() + expect(issues).toHaveLength(1) + expect(issues[0].type).toBe('spec-not-embeddable') + expect(issues[0].message).toContain('integrity') + }) + + it('stays silent on stderr for plain specs', async () => { + const materializer = makeMaterializer(['bar@2.0.0']) + const written = await captureStderr(async () => { + const { warnings } = await materializer.plan() + expect(warnings).toEqual([]) + await materializer.materialize() + }) + // Filtered rather than asserting total silence: the debug package + // also writes to stderr when DEBUG is enabled. + expect(written.filter(line => line.includes('Embedded package'))).toEqual([]) + }) + + it('blames the version pin when a wildcard matches names but no version', async () => { + // The workspace link sharing the scope must not be blamed: the + // pattern matched registry names, the pin filtered them out. + await fs.writeFile(lockfilePath, ` +lockfileVersion: '9.0' +importers: + .: + dependencies: + '@acme/shared': + specifier: workspace:* + version: link:packages/shared +packages: + '@acme/foo@1.2.3': + resolution: {integrity: ${fooIntegrity}} +`) + const { issues } = await makeMaterializer(['@acme/*@9.9.9']).plan() + expect(issues).toHaveLength(1) + expect(issues[0].type).toBe('spec-not-found') + expect(issues[0].message).toContain('9.9.9') + expect(issues[0].message).not.toContain('workspace') + }) + it('converts scope slashes for the archive filename', async () => { const { tarballs } = await makeMaterializer(['@acme/foo']).plan() expect(tarballs.map(t => t.archiveFilename)).toEqual(['@acme+foo@1.2.3.tgz']) diff --git a/packages/cli/src/services/embedded-packages/__tests__/spec.spec.ts b/packages/cli/src/services/embedded-packages/__tests__/spec.spec.ts index 579e37ef6..fa9c5a8e3 100644 --- a/packages/cli/src/services/embedded-packages/__tests__/spec.spec.ts +++ b/packages/cli/src/services/embedded-packages/__tests__/spec.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' -import { parseEmbeddedPackageSpec, InvalidEmbeddedPackageSpecError } from '../spec.js' +import { parseEmbeddedPackageSpec, InvalidEmbeddedPackageSpecError, specMatchesPackageName } from '../spec.js' describe('parseEmbeddedPackageSpec()', () => { it('parses a bare package name', () => { @@ -80,3 +80,68 @@ describe('parseEmbeddedPackageSpec()', () => { 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/lockfile-packages.ts b/packages/cli/src/services/embedded-packages/lockfile-packages.ts index e5a689bab..6208076dd 100644 --- a/packages/cli/src/services/embedded-packages/lockfile-packages.ts +++ b/packages/cli/src/services/embedded-packages/lockfile-packages.ts @@ -30,6 +30,14 @@ 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 { @@ -107,9 +115,18 @@ export function parsePnpmLockfilePackages (content: string): LockfilePackages { 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: `'${name}' is a workspace package, which cannot be embedded as a registry tarball`, + 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', }) } } @@ -152,6 +169,7 @@ export function parsePnpmLockfilePackages (content: string): LockfilePackages { name, reason: `'${name}@${ref}' resolves to a git, file or URL dependency,` + ` which cannot be embedded as a registry tarball`, + kind: 'unfetchable', }) continue } @@ -164,6 +182,7 @@ export function parsePnpmLockfilePackages (content: string): LockfilePackages { version, reason: `the lockfile records no integrity hash for '${name}@${version}',` + ` which is required to embed it`, + kind: 'unfetchable', }) continue } @@ -214,9 +233,19 @@ export function parseNpmLockfilePackages (content: string): LockfilePackages { : 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: `'${key}' is a workspace link, which cannot be embedded as a registry tarball`, + 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 } @@ -233,6 +262,7 @@ export function parseNpmLockfilePackages (content: string): LockfilePackages { version: version ?? undefined, reason: `'${key}' resolves to a git, file or URL dependency,` + ` which cannot be embedded as a registry tarball`, + kind: 'unfetchable', }) continue } @@ -251,6 +281,7 @@ export function parseNpmLockfilePackages (content: string): LockfilePackages { version, reason: `the lockfile records no integrity hash for '${name}@${version}'` + ` (typically a bundled dependency), which is required to embed it`, + kind: 'unfetchable', }) continue } diff --git a/packages/cli/src/services/embedded-packages/materializer.ts b/packages/cli/src/services/embedded-packages/materializer.ts index cbcbe91a4..930954e81 100644 --- a/packages/cli/src/services/embedded-packages/materializer.ts +++ b/packages/cli/src/services/embedded-packages/materializer.ts @@ -15,7 +15,7 @@ import { loadLockfilePackages, } from './lockfile-packages.js' import { NpmrcConfig, defaultNpmrcPaths, loadNpmrcConfig, resolveAuthHeader, resolveRegistryUrl } from './npmrc.js' -import { EmbeddedPackageSpec, parseEmbeddedPackageSpec } from './spec.js' +import { EmbeddedPackageSpec, parseEmbeddedPackageSpec, specMatchesPackageName } from './spec.js' const debug = Debug('checkly:cli:services:embedded-packages') @@ -44,6 +44,14 @@ export interface PlannedTarball extends LockfileRegistryPackage { export interface EmbeddedPackagesPlan { tarballs: PlannedTarball[] issues: EmbeddedPackagesIssue[] + /** + * Non-fatal problems worth surfacing (e.g. a spec also matching + * dependencies that cannot be embedded). Reported through the + * diagnostics channel during project validation. + */ + warnings: string[] + /** What each wildcard spec resolved to, announced during bundling. */ + wildcardMatches: Array<{ spec: string, packages: string[] }> } /** @@ -137,6 +145,10 @@ export class EmbeddedPackagesMaterializer { return this.#plan } + #info (message: string): void { + process.stderr.write(`${message}\n`) + } + materialize (): Promise { this.#materialized ??= this.#materializeAll() return this.#materialized @@ -144,6 +156,8 @@ export class EmbeddedPackagesMaterializer { async #createPlan (): Promise { const issues: EmbeddedPackagesIssue[] = [] + const warnings: string[] = [] + const wildcardMatches: Array<{ spec: string, packages: string[] }> = [] const specs: EmbeddedPackageSpec[] = [] for (const raw of this.#options.specs) { @@ -161,7 +175,7 @@ export class EmbeddedPackagesMaterializer { message: `Embedded packages require a lockfile to resolve package versions and` + ` integrity hashes, but no lockfile was found for the project.`, }) - return { tarballs: [], issues } + return { tarballs: [], issues, warnings, wildcardMatches } } let packages @@ -175,7 +189,7 @@ export class EmbeddedPackagesMaterializer { ? err.message : `Failed to read or parse the lockfile ('${lockfilePath}'): ${(err as Error).message}` issues.push({ type: 'unsupported-lockfile', message }) - return { tarballs: [], issues } + return { tarballs: [], issues, warnings, wildcardMatches } } debug( @@ -183,42 +197,94 @@ export class EmbeddedPackagesMaterializer { lockfilePath, packages.registry.length, packages.excluded.length, ) - const registryByName = new Map() - for (const entry of packages.registry) { - const entries = registryByName.get(entry.name) ?? [] - entries.push(entry) - registryByName.set(entry.name, entries) - } + // 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 candidates = (registryByName.get(spec.name) ?? []) + 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) { - const excludedMatches = packages.excluded.filter(entry => { - return entry.name === spec.name - && (spec.version === undefined || entry.version === undefined || entry.version === spec.version) - }) + // Excluded entries matching the exact pin (or any entry, when + // unpinned) carry the most actionable reason and win; a version + // pin that filtered out real registry matches is blamed next. + // Version-less excluded entries (e.g. workspace links) are a last + // resort, so a pinned spec is never blamed on one while a better + // explanation exists. + const strictExcluded = nameExcluded.filter(entry => + spec.version === undefined || entry.version === spec.version) + const excludedMatches = strictExcluded.length > 0 + ? strictExcluded + : nameMatches.length === 0 ? looseExcluded : [] if (excludedMatches.length > 0) { const reasons = [...new Set(excludedMatches.map(entry => entry.reason))] + const shownReasons = reasons.slice(0, 8).join('; ') + const moreReasons = reasons.length > 8 ? `; and ${reasons.length - 8} more` : '' issues.push({ type: 'spec-not-embeddable', spec: spec.raw, - message: `Embedded package '${spec.raw}' cannot be embedded: ${reasons.join('; ')}.`, + message: `Embedded package '${spec.raw}' cannot be embedded: ${shownReasons}${moreReasons}.`, + }) + } else if (nameMatches.length > 0) { + issues.push({ + type: 'spec-not-found', + spec: spec.raw, + message: `Embedded package '${spec.raw}' matches package name(s) in the lockfile` + + ` ('${lockfilePath}'), but none of them at version ${spec.version}.`, }) } else { + const hint = spec.namePattern !== undefined + ? `pattern matches its name${spec.version !== undefined ? ' and the version is spelled correctly' : ''}` + : `name ${spec.version !== undefined ? 'and version are' : 'is'} spelled correctly` issues.push({ type: 'spec-not-found', spec: spec.raw, message: `Embedded package '${spec.raw}' does not match any package in the lockfile` - + ` ('${lockfilePath}'). Make sure the package is installed and the name` - + ` ${spec.version !== undefined ? 'and version are' : 'is'} spelled correctly.`, + + ` ('${lockfilePath}'). Make sure the package is installed and the ${hint}.`, }) } continue } + // When the spec also reaches entries it cannot embed, that is not + // the hard error a fully-unresolvable spec gets. Workspace members + // (part of the project itself) are skipped silently; git/file/URL + // and integrity-less matches cannot be embedded but may still be + // needed at install time, so skipping them is said out loud. + const workspace = looseExcluded.filter(entry => entry.kind === 'workspace') + if (workspace.length > 0) { + debug('spec %s: %d workspace matches skipped: %j', + spec.raw, workspace.length, workspace.map(entry => entry.name)) + } + const unfetchable = looseExcluded.filter(entry => entry.kind === 'unfetchable') + if (unfetchable.length > 0) { + const names = [...new Set(unfetchable.map(entry => entry.name))] + const shown = names.slice(0, 8).join(', ') + const more = names.length > 8 ? ` and ${names.length - 8} more` : '' + warnings.push( + `Embedded package '${spec.raw}' also matches ${names.length} package(s) that cannot` + + ` be embedded as registry tarballs and were skipped: ${shown}${more}.` + + ` The runner must be able to fetch these itself.`, + ) + } + if (spec.namePattern !== undefined) { + wildcardMatches.push({ + spec: spec.raw, + packages: candidates.map(entry => `${entry.name}@${entry.version}`), + }) + } + for (const entry of candidates) { tarballs.set(`${entry.name}@${entry.version}`, { ...entry, @@ -227,16 +293,18 @@ export class EmbeddedPackagesMaterializer { } } - debug('plan: %d tarballs, %d issues', tarballs.size, issues.length) + debug('plan: %d tarballs, %d issues, %d warnings', tarballs.size, issues.length, warnings.length) return { tarballs: [...tarballs.values()].sort((a, b) => a.archiveFilename.localeCompare(b.archiveFilename)), issues, + warnings, + wildcardMatches, } } async #materializeAll (): Promise { - const { tarballs, issues } = await this.plan() + const { tarballs, issues, wildcardMatches } = await this.plan() // Commands validate before bundling and exit on fatal diagnostics, so // this is a defensive backstop for direct/programmatic use. @@ -247,6 +315,15 @@ export class EmbeddedPackagesMaterializer { ) } + // Wildcards select invisibly, so say what they selected. + for (const match of wildcardMatches) { + const shown = match.packages.slice(0, 8).join(', ') + const more = match.packages.length > 8 ? ` and ${match.packages.length - 8} more` : '' + this.#info( + `Embedded package pattern '${match.spec}' matched ${match.packages.length} package(s): ${shown}${more}.`, + ) + } + if (tarballs.length === 0) { return [] } diff --git a/packages/cli/src/services/embedded-packages/spec.ts b/packages/cli/src/services/embedded-packages/spec.ts index 757a68667..a79fa080a 100644 --- a/packages/cli/src/services/embedded-packages/spec.ts +++ b/packages/cli/src/services/embedded-packages/spec.ts @@ -1,23 +1,56 @@ import semver from 'semver' /** - * A parsed `checks.embeddedPackages` entry: a package name with an optional - * exact version pin (`name` or `name@version`). + * A parsed `checks.embeddedPackages` entry: a package name — or a name + * pattern with `*` wildcards — with an optional exact version pin + * (`name` or `name@version`). */ export interface EmbeddedPackageSpec { /** The raw config entry, kept for error messages. */ raw: string - /** The package name, e.g. `@acme/private-utils`. */ + /** + * 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-._~]*$/ +const PACKAGE_NAME_RE = /^(@[a-zA-Z0-9-~][a-zA-Z0-9-~._]*\/)?[a-zA-Z0-9-~][a-zA-Z0-9-._~]*$/ export class InvalidEmbeddedPackageSpecError extends Error { constructor (spec: string, reason: string) { @@ -31,11 +64,13 @@ export class InvalidEmbeddedPackageSpecError extends Error { * optional exact version pin. * * Accepts `name` (embed every lockfile version of the package) and - * `name@version` with an exact semver version. 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. + * `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 === '') { @@ -48,12 +83,20 @@ export function parseEmbeddedPackageSpec (raw: string): EmbeddedPackageSpec { const name = versionSeparator > 0 ? raw.slice(0, versionSeparator) : raw const rawVersion = versionSeparator > 0 ? raw.slice(versionSeparator + 1) : undefined - if (!PACKAGE_NAME_RE.test(name)) { - throw new InvalidEmbeddedPackageSpecError(raw, `'${name}' is not a valid npm package name`) + // 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 } + return { raw, name, namePattern } } // Trim before validating: semver.valid() tolerates surrounding whitespace, @@ -68,5 +111,5 @@ export function parseEmbeddedPackageSpec (raw: string): EmbeddedPackageSpec { ) } - return { raw, name, version } + return { raw, name, version, namePattern } } From 5401953aa56bcda3702e60b07d45f43ea1c973f3 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Tue, 18 Aug 2026 16:10:51 +0900 Subject: [PATCH 07/11] feat(cli): report an actionable error when the code bundle exceeds the upload size limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API rejects an oversized code bundle upload with HTTP 413, which previously surfaced as a raw "Payload content length greater than maximum allowed: " message. Map 413 responses to a typed PayloadTooLargeError and convert it at the upload site into a BundleTooLargeError that names the bundle size, the server-reported limit (parsed from the response rather than hard-coded), and how to reduce the bundle — mentioning embedded packages only when the bundle actually contains them. Co-Authored-By: Claude Fable 5 --- .../cli/src/rest/__tests__/errors.spec.ts | 31 +++- packages/cli/src/rest/errors.ts | 15 ++ .../check-parser/__tests__/bundler.spec.ts | 155 ++++++++++++++++++ .../cli/src/services/check-parser/bundler.ts | 134 +++++++++++++-- 4 files changed, 324 insertions(+), 11 deletions(-) create mode 100644 packages/cli/src/services/check-parser/__tests__/bundler.spec.ts 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/check-parser/__tests__/bundler.spec.ts b/packages/cli/src/services/check-parser/__tests__/bundler.spec.ts new file mode 100644 index 000000000..f46157c1b --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/bundler.spec.ts @@ -0,0 +1,155 @@ +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, FinalizedBundleArchive } from '../bundler.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('embeddedPackages') + + const withPackages = new BundleTooLargeError({ sizeBytes: 1048576, containsEmbeddedPackages: true }) + expect(withPackages.message).toContain(`embedding fewer private packages ('checks.embeddedPackages')`) + }) +}) + +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('embeddedPackages') + }) + + 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(`'checks.embeddedPackages'`) + }) +}) diff --git a/packages/cli/src/services/check-parser/bundler.ts b/packages/cli/src/services/check-parser/bundler.ts index e5f80f493..076d57aa7 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 } 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 ('checks.embeddedPackages')` + : `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') + } + } } } From 4c359ea5d28369a303ff8b5f99d5f65296ac73a4 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Thu, 20 Aug 2026 23:48:42 +0900 Subject: [PATCH 08/11] feat(cli): include the embedded-packages set in the dependency cache hash [RED-855] Changing checks.embeddedPackages with an unchanged lockfile previously left the code bundle's cacheHash identical, so runners reused a stale cached install and the new embedded tarball set was never exercised. The resolved embed set (name@version + lockfile integrity) now contributes embedded-package records to the hash; projects without embedded packages keep byte-identical digests. Co-Authored-By: Claude Fable 5 --- .../references/configure-playwright-checks.md | 4 +- .../cli/src/commands/debug/parse-project.ts | 1 + packages/cli/src/commands/deploy.ts | 1 + packages/cli/src/commands/pw-test.ts | 1 + packages/cli/src/commands/test.ts | 1 + .../checkly.one-package.config.ts | 23 ++++ .../__tests__/playwright-check.spec.ts | 15 ++ .../check-parser/__tests__/bundler.spec.ts | 46 ++++++- .../check-parser/__tests__/cache-hash.spec.ts | 128 +++++++++++++++++- .../cli/src/services/check-parser/bundler.ts | 20 ++- .../src/services/check-parser/cache-hash.ts | 67 +++++++-- .../cli/src/services/checkly-config-loader.ts | 7 +- 12 files changed, 288 insertions(+), 26 deletions(-) create mode 100644 packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/checkly.one-package.config.ts 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 2430d5577..43bf73dd3 100644 --- a/packages/cli/src/ai-context/references/configure-playwright-checks.md +++ b/packages/cli/src/ai-context/references/configure-playwright-checks.md @@ -12,9 +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 `checks.embeddedPackages` 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 `checks.embeddedPackages` in `checkly.config.ts`. Each entry is a package name (embeds every version found in the lockfile) or an exact `name@version` pin; names may contain `*` wildcards (`@acme/*`, `acme-*`, `@acme/*-utils`) where each `*` matches any run of characters except `/` (never crossing the scope separator); as long as a spec matches at least one registry package, matches that cannot be embedded are skipped (workspace members silently, git/file/URL dependencies and integrity-less entries with a warning since the runner must fetch those itself), while a spec whose only matches cannot be embedded — or that matches nothing at all — is an error; a pattern embeds every lockfile version of every package it matches, so scope it to the packages the runner genuinely cannot fetch. List every unreachable package by name, including private packages that only appear as transitive dependencies of other private packages — dependencies of listed packages are not embedded automatically. The CLI resolves entries against the workspace-root lockfile (`pnpm-lock.yaml` or `package-lock.json`), reuses tarballs from local caches (its own, then npm's) or downloads them from the registry configured in `.npmrc`, verifies each against the lockfile's integrity hash, and ships them inside the code bundle at `.checkly/embedded-packages/*.tgz`, where the runner serves them through a local registry during install. Downloads are cached under the workspace root's `node_modules/.cache/checkly` (in a monorepo that is the repo root, not the member package; override with `CHECKLY_CACHE_DIR`; a per-user cache dir is the fallback when the project location isn't writable), so nothing lands in the project outside `node_modules`. CI setups that cache `node_modules` — or platforms that preserve `node_modules/.cache` — persist the tarballs automatically; otherwise persist `CHECKLY_CACHE_DIR` in CI to avoid re-downloading (note `npm ci` deletes `node_modules` wholesale, unlike incremental pnpm installs). The machine running `checkly deploy`/`test` needs registry access on a cold cache. Applies to Playwright Check Suites only, not browser or multistep checks. +- If dependencies come from a private registry that Checkly's infrastructure cannot reach (for example an intranet-only Nexus mirror), list them in `checks.embeddedPackages` in `checkly.config.ts`. Each entry is a package name (embeds every version found in the lockfile) or an exact `name@version` pin; names may contain `*` wildcards (`@acme/*`, `acme-*`, `@acme/*-utils`) where each `*` matches any run of characters except `/` (never crossing the scope separator); as long as a spec matches at least one registry package, matches that cannot be embedded are skipped (workspace members silently, git/file/URL dependencies and integrity-less entries with a warning since the runner must fetch those itself), while a spec whose only matches cannot be embedded — or that matches nothing at all — is an error; a pattern embeds every lockfile version of every package it matches, so scope it to the packages the runner genuinely cannot fetch. List every unreachable package by name, including private packages that only appear as transitive dependencies of other private packages — dependencies of listed packages are not embedded automatically. The CLI resolves entries against the workspace-root lockfile (`pnpm-lock.yaml` or `package-lock.json`), reuses tarballs from local caches (its own, then npm's) or downloads them from the registry configured in `.npmrc`, verifies each against the lockfile's integrity hash, and ships them inside the code bundle at `.checkly/embedded-packages/*.tgz`, where the runner serves them through a local registry during install. Downloads are cached under the workspace root's `node_modules/.cache/checkly` (in a monorepo that is the repo root, not the member package; override with `CHECKLY_CACHE_DIR`; a per-user cache dir is the fallback when the project location isn't writable), so nothing lands in the project outside `node_modules`. CI setups that cache `node_modules` — or platforms that preserve `node_modules/.cache` — persist the tarballs automatically; otherwise persist `CHECKLY_CACHE_DIR` in CI to avoid re-downloading (note `npm ci` deletes `node_modules` wholesale, unlike incremental pnpm installs). The machine running `checkly deploy`/`test` needs registry access on a cold cache. 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 c357dfb8c..73ec533fa 100644 --- a/packages/cli/src/commands/debug/parse-project.ts +++ b/packages/cli/src/commands/debug/parse-project.ts @@ -168,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 9b5521d5d..138c90df0 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -210,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 96c88fccd..a8eb0652f 100644 --- a/packages/cli/src/commands/pw-test.ts +++ b/packages/cli/src/commands/pw-test.ts @@ -267,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 5d5265419..79005b8ec 100644 --- a/packages/cli/src/commands/test.ts +++ b/packages/cli/src/commands/test.ts @@ -292,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/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..48f4039df --- /dev/null +++ b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/checkly.one-package.config.ts @@ -0,0 +1,23 @@ +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', + embeddedPackages: ['@acme/private-utils'], + playwrightChecks: [ + { + logicalId: 'playwright-check-suite', + name: 'Playwright Check Suite', + } + ], + }, +}) + +export default config diff --git a/packages/cli/src/constructs/__tests__/playwright-check.spec.ts b/packages/cli/src/constructs/__tests__/playwright-check.spec.ts index 9dd5cc8f5..e6e02de20 100644 --- a/packages/cli/src/constructs/__tests__/playwright-check.spec.ts +++ b/packages/cli/src/constructs/__tests__/playwright-check.spec.ts @@ -1559,6 +1559,21 @@ describe('PlaywrightCheck', () => { // 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', () => { diff --git a/packages/cli/src/services/check-parser/__tests__/bundler.spec.ts b/packages/cli/src/services/check-parser/__tests__/bundler.spec.ts index f46157c1b..ef646e1ac 100644 --- a/packages/cli/src/services/check-parser/__tests__/bundler.spec.ts +++ b/packages/cli/src/services/check-parser/__tests__/bundler.spec.ts @@ -4,7 +4,10 @@ import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { BundleArchive, BundleTooLargeError, FinalizedBundleArchive } from '../bundler.js' +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()) @@ -153,3 +156,44 @@ describe('BundleArchive embedded package detection', () => { await expect(archive.store()).rejects.toThrow(`'checks.embeddedPackages'`) }) }) + +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..f98e016ff 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 + // checks.embeddedPackages 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 076d57aa7..dc9e67ad4 100644 --- a/packages/cli/src/services/check-parser/bundler.ts +++ b/packages/cli/src/services/check-parser/bundler.ts @@ -11,7 +11,7 @@ import * as uuid from 'uuid' import { checklyStorage } from '../../rest/api.js' import { PayloadTooLargeError } from '../../rest/errors.js' -import { EMBEDDED_PACKAGES_ARCHIVE_DIR } from '../embedded-packages/materializer.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' @@ -445,7 +445,18 @@ export interface CreateBundlerOptions { } export type CreateBundlerForWorkspaceOptions = - Omit & ComputeWorkspaceCacheHashOptions + Omit + & Omit + & { + /** + * The materializer for the project's `checks.embeddedPackages` 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 @@ -490,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..46f683395 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 + * (`checks.embeddedPackages` 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 d25144321..666cd8595 100644 --- a/packages/cli/src/services/checkly-config-loader.ts +++ b/packages/cli/src/services/checkly-config-loader.ts @@ -131,7 +131,9 @@ export type ChecklyConfig = { * nothing lands in the project outside `node_modules`. In the code * bundle the tarballs land at * `.checkly/embedded-packages/*.tgz`, where Checkly runners serve them - * through a local registry during dependency installation. + * through a local registry during dependency installation. Changing + * the resolved set of embedded packages invalidates the runner's + * dependency cache, so the next run reinstalls with the new tarballs. */ embeddedPackages?: string[] /** @@ -151,7 +153,8 @@ export type ChecklyConfig = { dependencyCache?: { /** * Optional value mixed into the code bundle's cache hash in addition - * to its usual inputs (lockfile, package.json and .npmrc files). + * to its usual inputs (lockfile, package.json and .npmrc files, and + * the resolved `checks.embeddedPackages` tarball set). * Change the value to force runners to reinstall the bundle's * dependencies. Setting it for the first time invalidates the cache * once. Numbers must be safe integers; unset and empty string leave From abb29af476c8eff1ba9bb3b81656351418fa4b35 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Fri, 21 Aug 2026 05:47:31 +0900 Subject: [PATCH 09/11] refactor(cli)!: move checks.embeddedPackages to bundle.packages.embed [RED-855] The embedded-packages option moves out of 'checks' into a new top-level 'bundle.packages' config section, which upcoming auto-detection options (RED-862) will expand. The option has not shipped in any release, so there is no back-compat alias. A misshapen 'bundle' or 'bundle.packages' value is rejected at config load instead of silently disabling embedding. Co-Authored-By: Claude Fable 5 --- .../references/configure-playwright-checks.md | 4 +- .../cli/src/commands/debug/parse-project.ts | 2 +- packages/cli/src/commands/deploy.ts | 2 +- packages/cli/src/commands/pw-test.ts | 2 +- packages/cli/src/commands/test.ts | 2 +- packages/cli/src/commands/validate.ts | 2 +- .../checkly.config.ts | 6 +- .../checkly.config.ts | 6 +- .../test-embedded-packages/checkly.config.ts | 6 +- .../checkly.one-package.config.ts | 6 +- .../project-embedded-packages.spec.ts | 4 +- packages/cli/src/constructs/project.ts | 6 +- packages/cli/src/constructs/session.ts | 2 +- .../__tests__/checkly-config-loader.spec.ts | 24 +++- .../configs/embedded-packages-bad-name.js | 8 +- .../embedded-packages-bundle-not-object.js | 9 ++ .../configs/embedded-packages-not-array.js | 8 +- .../embedded-packages-packages-not-object.js | 13 ++ .../embedded-packages-range-version.js | 8 +- .../configs/embedded-packages-valid.ts | 6 +- .../check-parser/__tests__/bundler.spec.ts | 8 +- .../check-parser/__tests__/cache-hash.spec.ts | 2 +- .../cli/src/services/check-parser/bundler.ts | 4 +- .../src/services/check-parser/cache-hash.ts | 2 +- .../cli/src/services/checkly-config-loader.ts | 132 +++++++++++------- .../embedded-packages/materializer.ts | 6 +- .../src/services/embedded-packages/spec.ts | 4 +- 27 files changed, 189 insertions(+), 95 deletions(-) create mode 100644 packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-bundle-not-object.js create mode 100644 packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-packages-not-object.js 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 43bf73dd3..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,9 +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, and the resolved `checks.embeddedPackages` 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. +- 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 `checks.embeddedPackages` in `checkly.config.ts`. Each entry is a package name (embeds every version found in the lockfile) or an exact `name@version` pin; names may contain `*` wildcards (`@acme/*`, `acme-*`, `@acme/*-utils`) where each `*` matches any run of characters except `/` (never crossing the scope separator); as long as a spec matches at least one registry package, matches that cannot be embedded are skipped (workspace members silently, git/file/URL dependencies and integrity-less entries with a warning since the runner must fetch those itself), while a spec whose only matches cannot be embedded — or that matches nothing at all — is an error; a pattern embeds every lockfile version of every package it matches, so scope it to the packages the runner genuinely cannot fetch. List every unreachable package by name, including private packages that only appear as transitive dependencies of other private packages — dependencies of listed packages are not embedded automatically. The CLI resolves entries against the workspace-root lockfile (`pnpm-lock.yaml` or `package-lock.json`), reuses tarballs from local caches (its own, then npm's) or downloads them from the registry configured in `.npmrc`, verifies each against the lockfile's integrity hash, and ships them inside the code bundle at `.checkly/embedded-packages/*.tgz`, where the runner serves them through a local registry during install. Downloads are cached under the workspace root's `node_modules/.cache/checkly` (in a monorepo that is the repo root, not the member package; override with `CHECKLY_CACHE_DIR`; a per-user cache dir is the fallback when the project location isn't writable), so nothing lands in the project outside `node_modules`. CI setups that cache `node_modules` — or platforms that preserve `node_modules/.cache` — persist the tarballs automatically; otherwise persist `CHECKLY_CACHE_DIR` in CI to avoid re-downloading (note `npm ci` deletes `node_modules` wholesale, unlike incremental pnpm installs). The machine running `checkly deploy`/`test` needs registry access on a cold cache. 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. +- 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 73ec533fa..b14e03078 100644 --- a/packages/cli/src/commands/debug/parse-project.ts +++ b/packages/cli/src/commands/debug/parse-project.ts @@ -149,7 +149,7 @@ export default class ParseProjectCommand extends Command { checklyConfigConstructs, playwrightConfigPath: checklyConfig.checks?.playwrightConfigPath, include: includeFlag.length ? includeFlag : checklyConfig.checks?.include, - embeddedPackages: checklyConfig.checks?.embeddedPackages, + embeddedPackages: checklyConfig.bundle?.packages?.embed, playwrightChecks: checklyConfig.checks?.playwrightChecks, loadPlaywrightChecksOnly: emulatePwTest, warnOnWebServerConfig: emulatePwTest && !(includeFlag.length > 0), diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index 138c90df0..d7da311c1 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -178,7 +178,7 @@ export default class Deploy extends AuthCommand { checklyConfigConstructs, playwrightConfigPath: checklyConfig.checks?.playwrightConfigPath, include: checklyConfig.checks?.include, - embeddedPackages: checklyConfig.checks?.embeddedPackages, + embeddedPackages: checklyConfig.bundle?.packages?.embed, playwrightChecks: checklyConfig.checks?.playwrightChecks, }) const repoInfo = getGitInformation(project.repoUrl) diff --git a/packages/cli/src/commands/pw-test.ts b/packages/cli/src/commands/pw-test.ts index a8eb0652f..f570512d1 100644 --- a/packages/cli/src/commands/pw-test.ts +++ b/packages/cli/src/commands/pw-test.ts @@ -214,7 +214,7 @@ export default class PwTestCommand extends AuthCommand { checklyConfigConstructs, playwrightConfigPath, include: includeFlag.length ? includeFlag : checklyConfig.checks?.include, - embeddedPackages: checklyConfig.checks?.embeddedPackages, + embeddedPackages: checklyConfig.bundle?.packages?.embed, playwrightChecks: [playwrightCheck], loadPlaywrightChecksOnly: true, warnOnWebServerConfig: !(includeFlag.length > 0), diff --git a/packages/cli/src/commands/test.ts b/packages/cli/src/commands/test.ts index 79005b8ec..7dcd4c1d9 100644 --- a/packages/cli/src/commands/test.ts +++ b/packages/cli/src/commands/test.ts @@ -206,7 +206,7 @@ export default class Test extends AuthCommand { checklyConfigConstructs, playwrightConfigPath: checklyConfig.checks?.playwrightConfigPath, include: checklyConfig.checks?.include, - embeddedPackages: checklyConfig.checks?.embeddedPackages, + embeddedPackages: checklyConfig.bundle?.packages?.embed, playwrightChecks: checklyConfig.checks?.playwrightChecks, checkFilter: check => { if (check instanceof HeartbeatMonitor) { diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 24a84adc0..b06c1ef4d 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -62,7 +62,7 @@ export default class Validate extends AuthCommand { checklyConfigConstructs, playwrightConfigPath: checklyConfig.checks?.playwrightConfigPath, include: checklyConfig.checks?.include, - embeddedPackages: checklyConfig.checks?.embeddedPackages, + embeddedPackages: checklyConfig.bundle?.packages?.embed, playwrightChecks: checklyConfig.checks?.playwrightChecks, }) 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 index d087efedc..8275a9c72 100644 --- 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 @@ -7,7 +7,6 @@ const config = defineConfig({ checkMatch: '**/*.check.ts', ignoreDirectoriesMatch: [], playwrightConfigPath: './playwright.config.ts', - embeddedPackages: ['no-such-package'], playwrightChecks: [ { logicalId: 'playwright-check-suite', @@ -15,6 +14,11 @@ const config = defineConfig({ } ], }, + 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-subdir/checkly.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages-subdir/checkly.config.ts index 588a047eb..bda98b98a 100644 --- 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 @@ -7,7 +7,6 @@ const config = defineConfig({ checkMatch: '**/*.check.ts', ignoreDirectoriesMatch: [], playwrightConfigPath: './subdir/playwright.config.ts', - embeddedPackages: ['@acme/private-utils'], playwrightChecks: [ { logicalId: 'playwright-check-suite', @@ -15,6 +14,11 @@ const config = defineConfig({ } ], }, + 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/checkly.config.ts b/packages/cli/src/constructs/__tests__/fixtures/playwright-check/test-cases/test-embedded-packages/checkly.config.ts index 8cab2e358..3659eb4ba 100644 --- 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 @@ -7,7 +7,6 @@ const config = defineConfig({ checkMatch: '**/*.check.ts', ignoreDirectoriesMatch: [], playwrightConfigPath: './playwright.config.ts', - embeddedPackages: ['@acme/private-utils', 'legacy-private-pkg@2.1.0'], playwrightChecks: [ { logicalId: 'playwright-check-suite', @@ -15,6 +14,11 @@ const config = defineConfig({ } ], }, + 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 index 48f4039df..d6a9602f7 100644 --- 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 @@ -10,7 +10,6 @@ const config = defineConfig({ checkMatch: '**/*.check.ts', ignoreDirectoriesMatch: [], playwrightConfigPath: './playwright.config.ts', - embeddedPackages: ['@acme/private-utils'], playwrightChecks: [ { logicalId: 'playwright-check-suite', @@ -18,6 +17,11 @@ const config = defineConfig({ } ], }, + bundle: { + packages: { + embed: ['@acme/private-utils'], + }, + }, }) export default config diff --git a/packages/cli/src/constructs/__tests__/project-embedded-packages.spec.ts b/packages/cli/src/constructs/__tests__/project-embedded-packages.spec.ts index d859f217c..f5958ad1a 100644 --- a/packages/cli/src/constructs/__tests__/project-embedded-packages.spec.ts +++ b/packages/cli/src/constructs/__tests__/project-embedded-packages.spec.ts @@ -75,7 +75,7 @@ describe('Project embedded packages validation', () => { // project-level embedded-packages ones are under test here. return diagnostics.observations.filter(diag => diag instanceof UnsatisfiedLocalPrerequisitesDiagnostic - || (diag instanceof InvalidPropertyValueDiagnostic && diag.property === 'checks.embeddedPackages')) + || (diag instanceof InvalidPropertyValueDiagnostic && diag.property === 'bundle.packages.embed')) } it('surfaces plan warnings as non-fatal warning diagnostics', async () => { @@ -90,7 +90,7 @@ describe('Project embedded packages validation', () => { expect(warning?.isFatal()).toBe(false) // The wildcard resolves present-pkg, so no fatal issue accompanies it. expect(diagnostics.observations.filter(diag => - diag instanceof InvalidPropertyValueDiagnostic && diag.property === 'checks.embeddedPackages')).toEqual([]) + diag instanceof InvalidPropertyValueDiagnostic && diag.property === 'bundle.packages.embed')).toEqual([]) }) it('maps a missing lockfile to an unsatisfied-prerequisites diagnostic', async () => { diff --git a/packages/cli/src/constructs/project.ts b/packages/cli/src/constructs/project.ts index 9ed341f9f..a88c3e8cf 100644 --- a/packages/cli/src/constructs/project.ts +++ b/packages/cli/src/constructs/project.ts @@ -120,7 +120,7 @@ export class Project extends Construct { } /** - * Validates the project-wide `checks.embeddedPackages` option once per + * 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 @@ -163,10 +163,10 @@ export class Project extends Construct { } if (specIssues.length === 1) { - diagnostics.add(new InvalidPropertyValueDiagnostic('checks.embeddedPackages', new Error(specIssues[0].message))) + diagnostics.add(new InvalidPropertyValueDiagnostic('bundle.packages.embed', new Error(specIssues[0].message))) } else if (specIssues.length > 1) { diagnostics.add(new InvalidPropertyValueDiagnostic( - 'checks.embeddedPackages', + 'bundle.packages.embed', new Error( `${specIssues.length} entries have problems:\n\n` + specIssues.map(issue => ` - ${issue.message}`).join('\n'), diff --git a/packages/cli/src/constructs/session.ts b/packages/cli/src/constructs/session.ts index 742042409..23bb52ec9 100644 --- a/packages/cli/src/constructs/session.ts +++ b/packages/cli/src/constructs/session.ts @@ -234,7 +234,7 @@ export class Session { } /** - * The materializer for the project's `checks.embeddedPackages` option, or + * 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. */ 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 c7b84ab63..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,27 +105,39 @@ describe('loadChecklyConfig()', () => { ['dependency-cache-version-bad-type.js'], )).rejects.toThrow(`Config field 'caching.dependencyCache.version' must be a string or a safe integer if set`) }) - it('accepts valid checks.embeddedPackages entries', async () => { + it('accepts valid bundle.packages.embed entries', async () => { const { config } = await loadChecklyConfig( path.join(__dirname, 'fixtures', 'configs'), ['embedded-packages-valid.ts'], ) - expect(config.checks?.embeddedPackages) + expect(config.bundle?.packages?.embed) .toEqual(['@acme/private-utils', 'legacy-private-pkg@2.1.0', '@acme/*', 'acme-*']) }) - it('rejects a checks.embeddedPackages that is not an array', async () => { + 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 'checks.embeddedPackages' must be an array of strings if set`) + )).rejects.toThrow(`Config field 'bundle.packages.embed' must be an array of strings if set`) }) - it('rejects a checks.embeddedPackages entry that is not a valid package name', async () => { + 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 checks.embeddedPackages entry with a version range', async () => { + 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'], 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 index a315b6ec1..d3db20984 100644 --- 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 @@ -1,10 +1,12 @@ // Plain JS on purpose: bypasses the TypeScript type of ChecklyConfig so the -// runtime validation of checks.embeddedPackages is what rejects it. +// runtime validation of bundle.packages.embed is what rejects it. const config = { projectName: 'test-config-project', logicalId: 'test-config-project', - checks: { - embeddedPackages: ['Not A Valid Name'], + bundle: { + packages: { + embed: ['Not A Valid Name'], + }, }, } 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 index 464fc3a62..7e08030a8 100644 --- 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 @@ -1,10 +1,12 @@ // Plain JS on purpose: bypasses the TypeScript type of ChecklyConfig so the -// runtime validation of checks.embeddedPackages is what rejects it. +// runtime validation of bundle.packages.embed is what rejects it. const config = { projectName: 'test-config-project', logicalId: 'test-config-project', - checks: { - embeddedPackages: '@acme/private-utils', + bundle: { + packages: { + embed: '@acme/private-utils', + }, }, } 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 index d184042d2..b1ba4a9ef 100644 --- 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 @@ -1,10 +1,12 @@ // Plain JS on purpose: bypasses the TypeScript type of ChecklyConfig so the -// runtime validation of checks.embeddedPackages is what rejects it. +// runtime validation of bundle.packages.embed is what rejects it. const config = { projectName: 'test-config-project', logicalId: 'test-config-project', - checks: { - embeddedPackages: ['@acme/private-utils@^2.0.0'], + bundle: { + packages: { + embed: ['@acme/private-utils@^2.0.0'], + }, }, } 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 index a127b77a2..f54da367e 100644 --- a/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-valid.ts +++ b/packages/cli/src/services/__tests__/fixtures/configs/embedded-packages-valid.ts @@ -3,8 +3,10 @@ import { defineConfig } from 'checkly' const config = defineConfig({ projectName: 'test-config-project', logicalId: 'test-config-project', - checks: { - embeddedPackages: ['@acme/private-utils', 'legacy-private-pkg@2.1.0', '@acme/*', 'acme-*'], + bundle: { + packages: { + embed: ['@acme/private-utils', 'legacy-private-pkg@2.1.0', '@acme/*', 'acme-*'], + }, }, }) diff --git a/packages/cli/src/services/check-parser/__tests__/bundler.spec.ts b/packages/cli/src/services/check-parser/__tests__/bundler.spec.ts index ef646e1ac..29ebab1e1 100644 --- a/packages/cli/src/services/check-parser/__tests__/bundler.spec.ts +++ b/packages/cli/src/services/check-parser/__tests__/bundler.spec.ts @@ -58,10 +58,10 @@ describe('BundleTooLargeError', () => { it('suggests embedding fewer packages only when the bundle embeds some', () => { const without = new BundleTooLargeError({ sizeBytes: 1048576 }) - expect(without.message).not.toContain('embeddedPackages') + expect(without.message).not.toContain('bundle.packages.embed') const withPackages = new BundleTooLargeError({ sizeBytes: 1048576, containsEmbeddedPackages: true }) - expect(withPackages.message).toContain(`embedding fewer private packages ('checks.embeddedPackages')`) + expect(withPackages.message).toContain(`embedding fewer private packages ('bundle.packages.embed')`) }) }) @@ -97,7 +97,7 @@ describe('FinalizedBundleArchive.store()', () => { 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('embeddedPackages') + expect(failure.message).not.toContain('bundle.packages.embed') }) it('handles a 413 response that does not name the limit', async () => { @@ -153,7 +153,7 @@ describe('BundleArchive embedded package detection', () => { message: 'Payload content length greater than maximum allowed: 31457280', })) - await expect(archive.store()).rejects.toThrow(`'checks.embeddedPackages'`) + await expect(archive.store()).rejects.toThrow(`'bundle.packages.embed'`) }) }) 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 f98e016ff..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 @@ -407,7 +407,7 @@ describe('composeCacheHash', () => { // // NOTE: composeCacheHash also hashes `npmrc:` records (added for .npmrc // bundling), `embedded-package:` records (the resolved - // checks.embeddedPackages tarball set), and a `dependency-cache-version` + // 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 diff --git a/packages/cli/src/services/check-parser/bundler.ts b/packages/cli/src/services/check-parser/bundler.ts index dc9e67ad4..50edba193 100644 --- a/packages/cli/src/services/check-parser/bundler.ts +++ b/packages/cli/src/services/check-parser/bundler.ts @@ -298,7 +298,7 @@ export class BundleTooLargeError extends Error { const remedies = containsEmbeddedPackages ? `removing large files from the Playwright project, narrowing any 'include' patterns, ` - + `or embedding fewer private packages ('checks.embeddedPackages')` + + `or embedding fewer private packages ('bundle.packages.embed')` : `removing large files from the Playwright project or narrowing any 'include' patterns` super( @@ -449,7 +449,7 @@ export type CreateBundlerForWorkspaceOptions = & Omit & { /** - * The materializer for the project's `checks.embeddedPackages` option, + * 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 diff --git a/packages/cli/src/services/check-parser/cache-hash.ts b/packages/cli/src/services/check-parser/cache-hash.ts index 46f683395..ee16caf08 100644 --- a/packages/cli/src/services/check-parser/cache-hash.ts +++ b/packages/cli/src/services/check-parser/cache-hash.ts @@ -52,7 +52,7 @@ export interface ComposeCacheHashInput { npmrcs?: NpmrcInput[] /** * The resolved set of embedded package tarballs shipped in the bundle - * (`checks.embeddedPackages` after lockfile resolution). Embedded tarballs + * (`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 diff --git a/packages/cli/src/services/checkly-config-loader.ts b/packages/cli/src/services/checkly-config-loader.ts index 666cd8595..0b20851f5 100644 --- a/packages/cli/src/services/checkly-config-loader.ts +++ b/packages/cli/src/services/checkly-config-loader.ts @@ -94,53 +94,67 @@ export type ChecklyConfig = { * Extra files to be included into the playwright bundle */ include?: string | string[] - /** - * Dependencies whose registry tarballs should be embedded into the - * Playwright Check Suite code bundle, letting Checkly runners install - * packages they cannot fetch themselves — e.g. packages from a private - * registry that is only reachable from your own network. Has no effect - * on browser or multistep checks. - * - * Each entry is a package name (`'@acme/private-utils'`), which embeds - * every version of that package found in the workspace lockfile, or a - * `name@version` pin (`'legacy-private-pkg@2.1.0'`) with an exact semver - * version. Names may contain `*` wildcards (`'@acme/*'`, `'acme-*'`, - * `'@acme/*-utils'`); each `*` matches any run of characters except - * `/`, so a wildcard never crosses the scope separator. As long as a - * wildcard matches at least one registry package, matches that are not - * registry packages are skipped — workspace members silently, git/file/ - * URL dependencies with a warning (the runner must fetch those - * itself); a wildcard 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 package the runner - * cannot fetch, - * including private packages that only appear as (transitive) - * dependencies of other private packages — dependencies of listed - * packages are not embedded automatically. - * - * Tarballs are resolved against the workspace root lockfile - * (`pnpm-lock.yaml` or `package-lock.json`), reused from local caches - * (the CLI's own, then npm's) when possible and otherwise downloaded - * from the registry configured in `.npmrc` (including scoped registries - * and auth tokens), and always verified against the lockfile's recorded - * integrity. Downloads are cached under the workspace root's - * `node_modules/.cache/checkly` - * (override with `CHECKLY_CACHE_DIR`; a per-user cache directory - * serves as the fallback if the project location isn't writable), so - * nothing lands in the project outside `node_modules`. In the code - * bundle the tarballs land at - * `.checkly/embedded-packages/*.tgz`, where Checkly runners serve them - * through a local registry during dependency installation. Changing - * the resolved set of embedded packages invalidates the runner's - * dependency cache, so the next run reinstalls with the new tarballs. - */ - embeddedPackages?: string[] /** * List of playwright checks that use the defined playwright config path */ playwrightChecks?: PlaywrightSlimmedProp[] } + /** + * Code-bundle configuration properties. New options that shape the + * Playwright Check Suite code bundle belong here; `checks.include`, + * `checks.playwrightConfigPath` and `caching.dependencyCache` predate + * this section and stay where they are for compatibility. + */ + bundle?: { + /** + * Configuration for npm packages shipped inside the Playwright Check + * Suite code bundle. + */ + packages?: { + /** + * Dependencies whose registry tarballs should be embedded into the + * Playwright Check Suite code bundle, letting Checkly runners install + * packages they cannot fetch themselves — e.g. packages from a private + * registry that is only reachable from your own network. Has no effect + * on browser or multistep checks. + * + * Each entry is a package name (`'@acme/private-utils'`), which embeds + * every version of that package found in the workspace lockfile, or a + * `name@version` pin (`'legacy-private-pkg@2.1.0'`) with an exact semver + * version. Names may contain `*` wildcards (`'@acme/*'`, `'acme-*'`, + * `'@acme/*-utils'`); each `*` matches any run of characters except + * `/`, so a wildcard never crosses the scope separator. As long as a + * wildcard matches at least one registry package, matches that are not + * registry packages are skipped — workspace members silently, git/file/ + * URL dependencies with a warning (the runner must fetch those + * itself); a wildcard 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 package the runner + * cannot fetch, + * including private packages that only appear as (transitive) + * dependencies of other private packages — dependencies of listed + * packages are not embedded automatically. + * + * Tarballs are resolved against the workspace root lockfile + * (`pnpm-lock.yaml` or `package-lock.json`), reused from local caches + * (the CLI's own, then npm's) when possible and otherwise downloaded + * from the registry configured in `.npmrc` (including scoped registries + * and auth tokens), and always verified against the lockfile's recorded + * integrity. Downloads are cached under the workspace root's + * `node_modules/.cache/checkly` + * (override with `CHECKLY_CACHE_DIR`; a per-user cache directory + * serves as the fallback if the project location isn't writable), so + * nothing lands in the project outside `node_modules`. In the code + * bundle the tarballs land at + * `.checkly/embedded-packages/*.tgz`, where Checkly runners serve them + * through a local registry during dependency installation. Changing + * the resolved set of embedded packages invalidates the runner's + * dependency cache, so the next run reinstalls with the new tarballs. + */ + embed?: string[] + } + } /** * Caching-related configuration properties. */ @@ -154,7 +168,7 @@ export type ChecklyConfig = { /** * Optional value mixed into the code bundle's cache hash in addition * to its usual inputs (lockfile, package.json and .npmrc files, and - * the resolved `checks.embeddedPackages` tarball set). + * the resolved `bundle.packages.embed` tarball set). * Change the value to force runners to reinstall the bundle's * dependencies. Setting it for the first time invalidates the cache * once. Numbers must be safe integers; unset and empty string leave @@ -270,7 +284,7 @@ export async function loadChecklyConfig ( } validateConfigFields(config, ['logicalId', 'projectName'] as const) validateDependencyCacheVersion(config) - validateEmbeddedPackages(config) + validateBundle(config) const constructs = Session.checklyConfigFileConstructs @@ -321,21 +335,43 @@ function validateDependencyCacheVersion (config: ChecklyConfig): void { } } -function validateEmbeddedPackages (config: ChecklyConfig): void { - const embeddedPackages = config.checks?.embeddedPackages +function validateBundle (config: ChecklyConfig): void { + const { bundle } = config + if (bundle === undefined) { + return + } + + // A misshapen `bundle` block would otherwise read as `embed: undefined` + // and silently disable embedding, surfacing only as an install failure on + // the runner. Plain-JS configs bypass the TypeScript type, so the shape + // must be enforced at runtime. + if (bundle === null || typeof bundle !== 'object' || Array.isArray(bundle)) { + throw new Error(`Config field 'bundle' must be an object if set`) + } + + const { packages } = bundle + if (packages === undefined) { + return + } + + if (packages === null || typeof packages !== 'object' || Array.isArray(packages)) { + throw new Error(`Config field 'bundle.packages' must be an object if set`) + } + + const embeddedPackages = packages.embed if (embeddedPackages === undefined) { return } if (!Array.isArray(embeddedPackages)) { - throw new Error(`Config field 'checks.embeddedPackages' must be an array of strings if set`) + throw new Error(`Config field 'bundle.packages.embed' must be an array of strings if set`) } for (const spec of embeddedPackages) { try { parseEmbeddedPackageSpec(spec) } catch (cause) { - throw new Error(`Config field 'checks.embeddedPackages' is invalid: ${(cause as Error).message}`, { cause }) + throw new Error(`Config field 'bundle.packages.embed' is invalid: ${(cause as Error).message}`, { cause }) } } } diff --git a/packages/cli/src/services/embedded-packages/materializer.ts b/packages/cli/src/services/embedded-packages/materializer.ts index 930954e81..9ce4885dd 100644 --- a/packages/cli/src/services/embedded-packages/materializer.ts +++ b/packages/cli/src/services/embedded-packages/materializer.ts @@ -28,7 +28,7 @@ export const EMBEDDED_PACKAGES_ARCHIVE_DIR = '.checkly/embedded-packages' export interface EmbeddedPackagesIssue { type: 'invalid-spec' | 'missing-lockfile' | 'unsupported-lockfile' | 'spec-not-found' | 'spec-not-embeddable' - /** The offending `checks.embeddedPackages` entry, when tied to one. */ + /** The offending `bundle.packages.embed` entry, when tied to one. */ spec?: string message: string } @@ -73,7 +73,7 @@ export class EmbeddedPackageError extends Error { } export interface EmbeddedPackagesMaterializerOptions { - /** Raw `checks.embeddedPackages` entries. */ + /** Raw `bundle.packages.embed` entries. */ specs: string[] /** Absolute path of the workspace root lockfile, when one exists. */ lockfilePath?: string @@ -110,7 +110,7 @@ function redactUrl (url: string): string { } /** - * Resolves the configured `checks.embeddedPackages` specs against the + * 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. diff --git a/packages/cli/src/services/embedded-packages/spec.ts b/packages/cli/src/services/embedded-packages/spec.ts index a79fa080a..f318dd163 100644 --- a/packages/cli/src/services/embedded-packages/spec.ts +++ b/packages/cli/src/services/embedded-packages/spec.ts @@ -1,7 +1,7 @@ import semver from 'semver' /** - * A parsed `checks.embeddedPackages` entry: a package name — or a name + * 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`). */ @@ -60,7 +60,7 @@ export class InvalidEmbeddedPackageSpecError extends Error { } /** - * Parses a `checks.embeddedPackages` entry into a package name and an + * 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 From e6f8a2bfbfc3e798c7ccfd041de5163fcae161a4 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Fri, 21 Aug 2026 16:42:15 +0900 Subject: [PATCH 10/11] docs(cli): tighten the bundle.packages.embed config JSDoc [RED-855] Trim the option docs to what a user needs: entry syntax, the transitive-dependency requirement, npm/pnpm-only support, and the cache-invalidation behavior. Internal mechanics (archive paths, cache directories, tarball sourcing) no longer leak into the config surface. Co-Authored-By: Claude Fable 5 --- .../cli/src/services/checkly-config-loader.ts | 60 ++++++------------- 1 file changed, 18 insertions(+), 42 deletions(-) diff --git a/packages/cli/src/services/checkly-config-loader.ts b/packages/cli/src/services/checkly-config-loader.ts index 0b20851f5..1ff48c7d7 100644 --- a/packages/cli/src/services/checkly-config-loader.ts +++ b/packages/cli/src/services/checkly-config-loader.ts @@ -100,57 +100,33 @@ export type ChecklyConfig = { playwrightChecks?: PlaywrightSlimmedProp[] } /** - * Code-bundle configuration properties. New options that shape the - * Playwright Check Suite code bundle belong here; `checks.include`, - * `checks.playwrightConfigPath` and `caching.dependencyCache` predate - * this section and stay where they are for compatibility. + * Code-bundle configuration properties. */ bundle?: { /** - * Configuration for npm packages shipped inside the Playwright Check - * Suite code bundle. + * Configuration for npm packages shipped inside the code bundle. */ packages?: { /** - * Dependencies whose registry tarballs should be embedded into the - * Playwright Check Suite code bundle, letting Checkly runners install - * packages they cannot fetch themselves — e.g. packages from a private - * registry that is only reachable from your own network. Has no effect - * on browser or multistep checks. + * Dependencies to embed into the code bundle, letting Checkly runners + * install packages they cannot fetch themselves — e.g. packages from a + * private registry that is only reachable from your own network. + * Applies to Playwright Check Suites only. * * Each entry is a package name (`'@acme/private-utils'`), which embeds - * every version of that package found in the workspace lockfile, or a - * `name@version` pin (`'legacy-private-pkg@2.1.0'`) with an exact semver - * version. Names may contain `*` wildcards (`'@acme/*'`, `'acme-*'`, - * `'@acme/*-utils'`); each `*` matches any run of characters except - * `/`, so a wildcard never crosses the scope separator. As long as a - * wildcard matches at least one registry package, matches that are not - * registry packages are skipped — workspace members silently, git/file/ - * URL dependencies with a warning (the runner must fetch those - * itself); a wildcard 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 package the runner - * cannot fetch, - * including private packages that only appear as (transitive) - * dependencies of other private packages — dependencies of listed - * packages are not embedded automatically. + * every version of that package found in the workspace lockfile, or an + * exact `name@version` pin (`'legacy-private-pkg@2.1.0'`). Names may + * contain `*` wildcards (`'@acme/*'`, `'acme-*'`); a wildcard never + * crosses the `/` scope separator. List every package the runner + * cannot fetch, including private packages that only appear as + * transitive dependencies of other private packages — dependencies of + * listed packages are not embedded automatically. * - * Tarballs are resolved against the workspace root lockfile - * (`pnpm-lock.yaml` or `package-lock.json`), reused from local caches - * (the CLI's own, then npm's) when possible and otherwise downloaded - * from the registry configured in `.npmrc` (including scoped registries - * and auth tokens), and always verified against the lockfile's recorded - * integrity. Downloads are cached under the workspace root's - * `node_modules/.cache/checkly` - * (override with `CHECKLY_CACHE_DIR`; a per-user cache directory - * serves as the fallback if the project location isn't writable), so - * nothing lands in the project outside `node_modules`. In the code - * bundle the tarballs land at - * `.checkly/embedded-packages/*.tgz`, where Checkly runners serve them - * through a local registry during dependency installation. Changing - * the resolved set of embedded packages invalidates the runner's - * dependency cache, so the next run reinstalls with the new tarballs. + * Only npm and pnpm are supported at this time: packages are resolved + * against the workspace lockfile (`pnpm-lock.yaml` or + * `package-lock.json`) and always verified against its recorded + * integrity hashes. Changing the resolved set of embedded packages + * invalidates the runner's dependency cache. */ embed?: string[] } From 992db5b4530b1de2ba51838c449667ddd0be1cca Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Fri, 21 Aug 2026 17:40:31 +0900 Subject: [PATCH 11/11] feat(cli): group embedded-package config issues by problem type [RED-855] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When several bundle.packages.embed entries fail validation, the diagnostic now groups them under per-problem headings (invalid entries, not found, wrong pinned version with the available versions listed, not embeddable with reasons) and names the lockfile once — and only when entries were actually resolved against it. Headers are pre-wrapped to survive the command renderer's 78-column re-wrap. Also removes the wildcard match announcement previously written raw to stderr during bundling, which interrupted styled command output; the selection is debug-logged during planning instead. Selections needing attention already surface louder: a pattern matching nothing is a fatal validation issue and unfetchable matches produce a warning diagnostic. Co-Authored-By: Claude Fable 5 --- .../project-embedded-packages.spec.ts | 51 +++++++++- packages/cli/src/constructs/project.ts | 72 +++++++++++++- .../__tests__/materializer.spec.ts | 41 ++++++-- .../embedded-packages/materializer.ts | 95 ++++++++++++------- .../src/services/embedded-packages/spec.ts | 4 + 5 files changed, 208 insertions(+), 55 deletions(-) diff --git a/packages/cli/src/constructs/__tests__/project-embedded-packages.spec.ts b/packages/cli/src/constructs/__tests__/project-embedded-packages.spec.ts index f5958ad1a..ee9e299c9 100644 --- a/packages/cli/src/constructs/__tests__/project-embedded-packages.spec.ts +++ b/packages/cli/src/constructs/__tests__/project-embedded-packages.spec.ts @@ -11,6 +11,7 @@ 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 @@ -113,15 +114,57 @@ describe('Project embedded packages validation', () => { expect(observations[0].message).toContain('yarn.lock') }) - it('groups multiple spec issues into a single diagnostic', async () => { + it('groups multiple spec issues into a single diagnostic by problem type', async () => { const project = setupProject() - Session.embeddedPackages = ['missing-one', 'missing-two'] + 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) - expect(observations[0].message).toContain('missing-one') - expect(observations[0].message).toContain('missing-two') + 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 () => { diff --git a/packages/cli/src/constructs/project.ts b/packages/cli/src/constructs/project.ts index a88c3e8cf..bd95a00b8 100644 --- a/packages/cli/src/constructs/project.ts +++ b/packages/cli/src/constructs/project.ts @@ -17,6 +17,7 @@ import { } 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 @@ -142,7 +143,7 @@ export class Project extends Construct { return } - const { issues, warnings } = await materializer.plan() + const { issues, warnings, lockfilePath } = await materializer.plan() for (const warning of warnings) { diagnostics.add(new WarningDiagnostic({ @@ -167,10 +168,7 @@ export class Project extends Construct { } else if (specIssues.length > 1) { diagnostics.add(new InvalidPropertyValueDiagnostic( 'bundle.packages.embed', - new Error( - `${specIssues.length} entries have problems:\n\n` - + specIssues.map(issue => ` - ${issue.message}`).join('\n'), - ), + new Error(formatEmbeddedPackagesSpecIssues(specIssues, lockfilePath)), )) } } @@ -270,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/services/embedded-packages/__tests__/materializer.spec.ts b/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts index c4b5bd174..991342744 100644 --- a/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts +++ b/packages/cli/src/services/embedded-packages/__tests__/materializer.spec.ts @@ -5,6 +5,7 @@ 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' @@ -216,11 +217,13 @@ packages: expect(tarballs.map(t => t.archiveFilename)).toEqual(['@acme+foo@1.2.3.tgz']) }) - it('reports unfetchable wildcard matches as plan warnings and announces matches when materializing', async () => { + 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, the - // git dependency surfaces as a plan warning naming it, and the - // wildcard's selection is announced during materialization. + // 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([]) @@ -231,9 +234,25 @@ packages: const written = await captureStderr(async () => { await materializer.materialize() }) - const announcement = written.find(line => line.includes('matched 2 package(s)')) - expect(announcement).toContain(`'*'`) - expect(announcement).toContain('bar@2.0.0') + // 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 () => { @@ -325,7 +344,7 @@ packages: `) const { issues } = await makeMaterializer(['@acme/*@9.9.9']).plan() expect(issues).toHaveLength(1) - expect(issues[0].type).toBe('spec-not-found') + expect(issues[0].type).toBe('spec-version-not-found') expect(issues[0].message).toContain('9.9.9') expect(issues[0].message).not.toContain('workspace') }) @@ -342,9 +361,11 @@ packages: expect(issues[0].message).toContain('no-such-package') }) - it('reports a version pin that matches nothing in the lockfile', async () => { + 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-not-found') + 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 () => { diff --git a/packages/cli/src/services/embedded-packages/materializer.ts b/packages/cli/src/services/embedded-packages/materializer.ts index 9ce4885dd..46d0efa7b 100644 --- a/packages/cli/src/services/embedded-packages/materializer.ts +++ b/packages/cli/src/services/embedded-packages/materializer.ts @@ -15,7 +15,12 @@ import { loadLockfilePackages, } from './lockfile-packages.js' import { NpmrcConfig, defaultNpmrcPaths, loadNpmrcConfig, resolveAuthHeader, resolveRegistryUrl } from './npmrc.js' -import { EmbeddedPackageSpec, parseEmbeddedPackageSpec, specMatchesPackageName } from './spec.js' +import { + EmbeddedPackageSpec, + InvalidEmbeddedPackageSpecError, + parseEmbeddedPackageSpec, + specMatchesPackageName, +} from './spec.js' const debug = Debug('checkly:cli:services:embedded-packages') @@ -27,10 +32,18 @@ const debug = Debug('checkly:cli:services:embedded-packages') export const EMBEDDED_PACKAGES_ARCHIVE_DIR = '.checkly/embedded-packages' export interface EmbeddedPackagesIssue { - type: 'invalid-spec' | 'missing-lockfile' | 'unsupported-lockfile' | 'spec-not-found' | 'spec-not-embeddable' + 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 } /** @@ -50,8 +63,13 @@ export interface EmbeddedPackagesPlan { * diagnostics channel during project validation. */ warnings: string[] - /** What each wildcard spec resolved to, announced during bundling. */ - wildcardMatches: Array<{ spec: string, packages: 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 } /** @@ -92,6 +110,16 @@ 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). @@ -145,10 +173,6 @@ export class EmbeddedPackagesMaterializer { return this.#plan } - #info (message: string): void { - process.stderr.write(`${message}\n`) - } - materialize (): Promise { this.#materialized ??= this.#materializeAll() return this.#materialized @@ -157,14 +181,18 @@ export class EmbeddedPackagesMaterializer { async #createPlan (): Promise { const issues: EmbeddedPackagesIssue[] = [] const warnings: string[] = [] - const wildcardMatches: Array<{ spec: string, packages: string[] }> = [] const specs: EmbeddedPackageSpec[] = [] for (const raw of this.#options.specs) { try { specs.push(parseEmbeddedPackageSpec(raw)) } catch (err) { - issues.push({ type: 'invalid-spec', spec: String(raw), message: (err as Error).message }) + issues.push({ + type: 'invalid-spec', + spec: String(raw), + message: (err as Error).message, + detail: err instanceof InvalidEmbeddedPackageSpecError ? err.reason : (err as Error).message, + }) } } @@ -175,7 +203,7 @@ export class EmbeddedPackagesMaterializer { message: `Embedded packages require a lockfile to resolve package versions and` + ` integrity hashes, but no lockfile was found for the project.`, }) - return { tarballs: [], issues, warnings, wildcardMatches } + return { tarballs: [], issues, warnings } } let packages @@ -188,8 +216,10 @@ export class EmbeddedPackagesMaterializer { 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, wildcardMatches } + return { tarballs: [], issues, warnings } } debug( @@ -228,20 +258,22 @@ export class EmbeddedPackagesMaterializer { ? strictExcluded : nameMatches.length === 0 ? looseExcluded : [] if (excludedMatches.length > 0) { - const reasons = [...new Set(excludedMatches.map(entry => entry.reason))] - const shownReasons = reasons.slice(0, 8).join('; ') - const moreReasons = reasons.length > 8 ? `; and ${reasons.length - 8} more` : '' + 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: ${shownReasons}${moreReasons}.`, + 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-not-found', + 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}.`, + + ` ('${lockfilePath}'), but none of them at version ${spec.version}` + + ` (lockfile has: ${versions}).`, + detail: `lockfile has: ${versions}`, }) } else { const hint = spec.namePattern !== undefined @@ -270,19 +302,19 @@ export class EmbeddedPackagesMaterializer { const unfetchable = looseExcluded.filter(entry => entry.kind === 'unfetchable') if (unfetchable.length > 0) { const names = [...new Set(unfetchable.map(entry => entry.name))] - const shown = names.slice(0, 8).join(', ') - const more = names.length > 8 ? ` and ${names.length - 8} more` : '' warnings.push( `Embedded package '${spec.raw}' also matches ${names.length} package(s) that cannot` - + ` be embedded as registry tarballs and were skipped: ${shown}${more}.` + + ` be embedded as registry tarballs and were skipped: ${capList(names, ', ', ' and ')}.` + ` The runner must be able to fetch these itself.`, ) } if (spec.namePattern !== undefined) { - wildcardMatches.push({ - spec: spec.raw, - packages: candidates.map(entry => `${entry.name}@${entry.version}`), - }) + // 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) { @@ -299,12 +331,12 @@ export class EmbeddedPackagesMaterializer { tarballs: [...tarballs.values()].sort((a, b) => a.archiveFilename.localeCompare(b.archiveFilename)), issues, warnings, - wildcardMatches, + lockfilePath, } } async #materializeAll (): Promise { - const { tarballs, issues, wildcardMatches } = await this.plan() + 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. @@ -315,15 +347,6 @@ export class EmbeddedPackagesMaterializer { ) } - // Wildcards select invisibly, so say what they selected. - for (const match of wildcardMatches) { - const shown = match.packages.slice(0, 8).join(', ') - const more = match.packages.length > 8 ? ` and ${match.packages.length - 8} more` : '' - this.#info( - `Embedded package pattern '${match.spec}' matched ${match.packages.length} package(s): ${shown}${more}.`, - ) - } - if (tarballs.length === 0) { return [] } diff --git a/packages/cli/src/services/embedded-packages/spec.ts b/packages/cli/src/services/embedded-packages/spec.ts index f318dd163..1815a5fac 100644 --- a/packages/cli/src/services/embedded-packages/spec.ts +++ b/packages/cli/src/services/embedded-packages/spec.ts @@ -53,9 +53,13 @@ function compileNamePattern (name: string): RegExp { 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 } }