From dcb5cf97783c7489ecb5088c2d5e9b28a62077a2 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Wed, 12 Aug 2026 19:39:01 +0900 Subject: [PATCH 1/6] 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 821c8d0a9e0e8bd591dadf44f0f4c2252a9119ef Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Wed, 12 Aug 2026 21:31:38 +0900 Subject: [PATCH 2/6] 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 f3b849c4cc38f8cdf51f41f3fec5f134206a322f Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Wed, 12 Aug 2026 22:10:07 +0900 Subject: [PATCH 3/6] 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 b7e22339a8bf6360ea9d2de2a97531868a8ba901 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Thu, 13 Aug 2026 00:25:33 +0900 Subject: [PATCH 4/6] 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 20c3a23e3e72b26639d3bd8f4f9ff87f2cbd4123 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Thu, 13 Aug 2026 15:41:14 +0900 Subject: [PATCH 5/6] 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 46796fd399a64a7b7678709bffa208a26c223803 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Fri, 14 Aug 2026 15:52:02 +0900 Subject: [PATCH 6/6] 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 } }