diff --git a/.gitleaks.toml b/.gitleaks.toml index e155b52e3..499122f9b 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -9,3 +9,13 @@ id = "braintrust-api-key" description = "Braintrust API key" regex = '''\bsk-[a-zA-Z0-9]{40,}\b''' tags = ["key", "braintrust"] + +[allowlist] +description = "E2E scenario variant keys are cassette/snapshot identifiers, not secrets" +condition = "AND" +regexTarget = "line" +paths = [ + '''e2e/config/pr-comment-scenarios\.json''', + '''e2e/scenarios/.*/scenario\.test\.ts''', +] +regexes = ['''variantKey"?\s*:\s*"[a-z0-9-]+"'''] diff --git a/e2e/Dockerfile.e2e-bump b/e2e/Dockerfile.e2e-bump new file mode 100644 index 000000000..949b67c72 --- /dev/null +++ b/e2e/Dockerfile.e2e-bump @@ -0,0 +1,19 @@ +FROM node:22-bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + git \ + python3 \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install -g pnpm@10.33.0 + +WORKDIR /workspace +COPY . . + +RUN pnpm install --frozen-lockfile +RUN pnpm run build +RUN chmod -R a+rwX /workspace + +CMD ["node", "e2e/scripts/bump-e2e-versions.mjs"] diff --git a/e2e/README.md b/e2e/README.md index a0696cedc..ebe595db9 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -113,12 +113,14 @@ Provider credentials are only required when recording or explicitly running live `anthropic-bedrock-instrumentation` uses `AWS_BEARER_TOKEN_BEDROCK`; when recording, its region resolves from `AWS_REGION`, then `AWS_DEFAULT_REGION`, then `us-east-1`, and its model can be overridden with `BRAINTRUST_ANTHROPIC_BEDROCK_MODEL`. -`test:e2e:canary` runs inside Docker because it installs packages on the latest versions and those packages may contain malicious code (supply-chain attacks). +`test:e2e:bump` runs inside Docker because it installs freshly selected package versions and those packages may contain malicious code (supply-chain attacks). The command loads `.env` / `.env.local` from the repo root on the host, forwards the provider keys into the container, bumps configured scenario-local dependencies to the latest policy-compliant version for each tested major, refreshes scenario lockfiles, records cassettes/snapshots, and then verifies replay. ### Scenario-local `package.json` Scenario-local manifests are optional and should stay slim. They are only for scenario-specific external dependencies, such as OpenAI version matrices. Shared test tooling and workspace-local packages stay in `e2e/package.json`. +Scenarios that should participate in `test:e2e:bump` declare `braintrustScenario.bump.dependencies` in their existing scenario-local `package.json`. Each rule points at the real npm package and the tested range for that dependency alias; prereleases are ignored unless the rule sets `allowPrerelease: true`. + `workspace:` dependency specs are intentionally not supported in scenario-local manifests. If a scenario needs a workspace package, keep that dependency in `e2e/package.json`. ## Running @@ -128,7 +130,7 @@ pnpm run test:e2e # Run all e2e tests pnpm run test:e2e:update # Update snapshots in cassette replay mode pnpm run test:e2e:record # Re-record provider cassettes and update snapshots pnpm run test:e2e:record -- # Re-record one scenario from the repo root -pnpm run test:e2e:canary # Run canary e2e tests +pnpm run test:e2e:bump # Bump latest-per-major scenario deps, record, then replay in Docker ``` ## Cassettes (provider HTTP record/replay) diff --git a/e2e/helpers/file-snapshot.ts b/e2e/helpers/file-snapshot.ts index d5dd68bee..e699a9aa1 100644 --- a/e2e/helpers/file-snapshot.ts +++ b/e2e/helpers/file-snapshot.ts @@ -1,8 +1,6 @@ -import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { expect } from "vitest"; -import { isCanaryMode } from "./scenario-installer"; import { normalizeForSnapshot, type Json } from "./normalize"; function sortJsonKeys(value: Json): Json { @@ -29,14 +27,6 @@ export async function matchFileSnapshot( value: string, path: string, ): Promise { - // In canary mode always write the snapshot and pass — never fail on content - // differences. The e2e-canary job catches live API failures; snapshot drift - // is surfaced separately by the update-canary-snapshots PR workflow. - if (isCanaryMode()) { - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, value, "utf8"); - return; - } await expect(value).toMatchFileSnapshot(path); } @@ -44,14 +34,5 @@ export function resolveFileSnapshotPath( testModuleUrl: string, filename: string, ): string { - // Canary tests use the latest provider versions, which may produce different - // span shapes. Keep their snapshots separate so pinned and canary baselines - // can diverge independently. - const subdir = isCanaryMode() ? "canary" : ""; - return join( - dirname(fileURLToPath(testModuleUrl)), - "__snapshots__", - subdir, - filename, - ); + return join(dirname(fileURLToPath(testModuleUrl)), "__snapshots__", filename); } diff --git a/e2e/helpers/normalize.ts b/e2e/helpers/normalize.ts index 140c01669..6b10658bd 100644 --- a/e2e/helpers/normalize.ts +++ b/e2e/helpers/normalize.ts @@ -116,7 +116,9 @@ const TEMP_SCENARIO_PATH_REGEX = /\/e2e\/\.bt-tmp\/[^/\s)]+\/scenarios\/([^/\s)]+)\/?/g; const TEMP_HELPER_PATH_REGEX = /\/e2e\/\.bt-tmp\/[^/\s)]+\/helpers\/?/g; const TEMP_SCENARIO_DEPENDENCY_PATH_REGEX = - /\/e2e\/\.bt-tmp\/scenario-deps\/([^/\s)]+)-locked-[0-9a-f]{8,}(?=\/|$)/gi; + /\/e2e\/\.bt-tmp\/scenario-deps\/(.+?)(?:-locked)?-[0-9a-f]{8,}(?=\/|$)/gi; +const CLAUDE_AGENT_OUTPUT_FILE_REGEX = + /(?:\/private)?\/tmp\/claude-\d+\/[^"\s]+\/tasks\/[0-9a-f]+\.output/g; const PROVIDER_HELPER_CALLER_REGEX = /^\/e2e\/helpers\/.+-scenario\.mjs$/; const ANTHROPIC_MESSAGE_STREAM_PATH_REGEX = /([/\\]node_modules[/\\]\.pnpm[/\\]@anthropic-ai\+sdk@[^/\\\s)]+[/\\]node_modules[/\\]@anthropic-ai[/\\]sdk[/\\])(?:src[/\\]lib[/\\]MessageStream\.ts|lib[/\\]MessageStream\.js)/g; @@ -128,6 +130,8 @@ const SDK_CHUNK_PATH_REGEX = /(\/js\/dist\/)chunk-[A-Z0-9]+(\.(?:c?js|cjs|mjs))/g; const ANTHROPIC_PNPM_VERSION_REGEX = /([/\\]\.pnpm[/\\]@anthropic-ai\+sdk@)[^/\\\s)]+/g; +const LOCALHOST_HTTP_URL_SUBSTRING_REGEX = + /http:\/\/127\.0\.0\.1:\d+([^\s"'<>)]*)/g; function isRecord(value: Json | undefined): value is { [key: string]: Json } { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -168,6 +172,14 @@ function normalizeMockServerUrl(value: string): string | undefined { function normalizeStackLikeString(value: string): string { let normalized = value.replaceAll("file://", ""); + normalized = normalized.replace( + LOCALHOST_HTTP_URL_SUBSTRING_REGEX, + (_match, suffix: string) => { + return suffix === "/" || suffix === "" + ? "" + : `${suffix}`; + }, + ); normalized = normalized.replaceAll(REPO_ROOT, ""); normalized = normalized.replace( TEMP_SCENARIO_PATH_REGEX, @@ -178,6 +190,10 @@ function normalizeStackLikeString(value: string): string { TEMP_SCENARIO_DEPENDENCY_PATH_REGEX, "/e2e/.bt-tmp/scenario-deps/$1-locked-", ); + normalized = normalized.replace( + CLAUDE_AGENT_OUTPUT_FILE_REGEX, + "/claude-agent/tasks/", + ); normalized = normalized.replace( STACK_FRAME_REPO_PATH_REGEX, diff --git a/e2e/helpers/scenario-harness.ts b/e2e/helpers/scenario-harness.ts index 4bcfd64c1..b41e3a8be 100644 --- a/e2e/helpers/scenario-harness.ts +++ b/e2e/helpers/scenario-harness.ts @@ -22,7 +22,6 @@ import { } from "./mock-braintrust-server"; import { installScenarioDependencies, - isCanaryMode, prepareScenarioDir, readInstalledPackageVersion, type InstallScenarioDependenciesOptions, @@ -900,7 +899,6 @@ export async function withScenarioHarness( export { installScenarioDependencies, - isCanaryMode, prepareScenarioDir, readInstalledPackageVersion, type InstallScenarioDependenciesResult, diff --git a/e2e/helpers/scenario-installer.ts b/e2e/helpers/scenario-installer.ts index 935235a21..bcb254546 100644 --- a/e2e/helpers/scenario-installer.ts +++ b/e2e/helpers/scenario-installer.ts @@ -8,10 +8,7 @@ export type InstallScenarioDependenciesResult = | { status: "no-manifest" } | { status: "installed" }; -export type ScenarioDependencyMode = "canary" | "locked"; - export interface InstallScenarioDependenciesOptions { - mode?: ScenarioDependencyMode; preferOffline?: boolean; scenarioDir: string; } @@ -19,7 +16,6 @@ export interface InstallScenarioDependenciesOptions { const PNPM_COMMAND = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; const TEMP_DIR_NAME = ".bt-tmp"; const DEPENDENCY_CACHE_DIR_NAME = "scenario-deps"; -const CANARY_MODE_ENV = "BRAINTRUST_E2E_MODE"; const INSTALL_SECRET_ENV_VARS = [ "ANTHROPIC_API_KEY", "BRAINTRUST_API_KEY", @@ -44,26 +40,10 @@ const dependencyCacheInstalls = new Map< string, Promise >(); -// Canary mode can resolve "latest"; keep that cache scoped to this test process. -const processCacheId = `${process.pid}-${Date.now()}`; - -type CanaryDependencyRule = { - packageName: string; - version: string; -}; const HELPERS_DIR = path.dirname(fileURLToPath(import.meta.url)); const E2E_ROOT = path.resolve(HELPERS_DIR, ".."); -interface ScenarioManifest { - braintrustScenario?: { - canary?: { - dependencies?: Record; - }; - }; - dependencies?: Record; -} - type ScenarioInstallInputs = { lockfileRaw: string; manifestRaw: string; @@ -165,95 +145,6 @@ function scenarioNameForPath(scenarioDir: string): string { return path.basename(scenarioDir); } -function packageSpecifier( - dependencyName: string, - packageName: string, - version: string, -): string { - return dependencyName === packageName - ? version - : `npm:${packageName}@${version}`; -} - -function parseCanaryDependencyRule( - dependencyName: string, - rawRule: string, - scenarioDir: string, -): CanaryDependencyRule { - if (typeof rawRule !== "string" || rawRule.length === 0) { - throw new Error( - `Invalid canary rule for ${dependencyName} in ${scenarioDir}/package.json`, - ); - } - - if (rawRule === "latest") { - return { - packageName: dependencyName, - version: "latest", - }; - } - - const versionSeparator = rawRule.lastIndexOf("@"); - if (versionSeparator <= 0) { - throw new Error( - `Invalid canary rule for ${dependencyName} in ${scenarioDir}/package.json`, - ); - } - - return { - packageName: rawRule.slice(0, versionSeparator), - version: rawRule.slice(versionSeparator + 1), - }; -} - -async function rewriteManifestForCanary(scenarioDir: string): Promise { - const manifestPath = path.join(scenarioDir, "package.json"); - const manifestRaw = await fs.readFile(manifestPath, "utf8"); - const manifest = JSON.parse(manifestRaw) as ScenarioManifest; - const dependencies = manifest.dependencies ?? {}; - const rawRules = manifest.braintrustScenario?.canary?.dependencies ?? {}; - let updated = false; - - for (const [dependencyName, rawRule] of Object.entries(rawRules)) { - if (!(dependencyName in dependencies)) { - continue; - } - - const rule = parseCanaryDependencyRule( - dependencyName, - rawRule, - scenarioDir, - ); - dependencies[dependencyName] = packageSpecifier( - dependencyName, - rule.packageName, - rule.version, - ); - updated = true; - } - - if (!updated) { - return; - } - - manifest.dependencies = dependencies; - await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); - - await spawnOrThrow( - PNPM_COMMAND, - [ - "install", - "--dir", - scenarioDir, - "--ignore-workspace", - "--lockfile-only", - "--strict-peer-dependencies=false", - ], - scenarioDir, - installEnv(), - ); -} - function findWorkspaceSpecs( manifest: Record, ): Array<{ name: string; section: string; spec: string }> { @@ -313,14 +204,11 @@ async function readScenarioInstallInputs( function scenarioDependencyCacheKey( scenarioDir: string, - mode: ScenarioDependencyMode, inputs: ScenarioInstallInputs, ): string { const hash = createHash("sha256"); hash.update(path.resolve(scenarioDir)); hash.update("\0"); - hash.update(mode === "canary" ? `${mode}:${processCacheId}` : mode); - hash.update("\0"); hash.update(process.platform); hash.update("\0"); hash.update(process.arch); @@ -331,11 +219,10 @@ function scenarioDependencyCacheKey( hash.update("\0"); hash.update(inputs.lockfileRaw); - return `${scenarioNameForPath(scenarioDir)}-${mode}-${hash.digest("hex").slice(0, 16)}`; + return `${scenarioNameForPath(scenarioDir)}-${hash.digest("hex").slice(0, 16)}`; } async function installCachedScenarioDependencies({ - mode = getScenarioDependencyMode(), preferOffline = true, scenarioDir, }: InstallScenarioDependenciesOptions): Promise { @@ -344,7 +231,7 @@ async function installCachedScenarioDependencies({ return { status: "no-manifest" }; } - const cacheKey = scenarioDependencyCacheKey(scenarioDir, mode, inputs); + const cacheKey = scenarioDependencyCacheKey(scenarioDir, inputs); const cachedInstall = dependencyCacheInstalls.get(cacheKey); if (cachedInstall) { return await cachedInstall; @@ -378,7 +265,6 @@ async function installCachedScenarioDependencies({ inputs.lockfileRaw, ); await installScenarioDependencies({ - mode, preferOffline, scenarioDir: stagingDir, }); @@ -396,11 +282,6 @@ async function installCachedScenarioDependencies({ } } - if (mode === "canary") { - cleanupDirs.add(cacheDir); - registerCleanupHandlers(); - } - return { nodeModulesDir, status: "installed" as const }; })(); @@ -414,13 +295,11 @@ async function installCachedScenarioDependencies({ } async function linkCachedScenarioDependencies(options: { - mode?: ScenarioDependencyMode; preferOffline?: boolean; preparedDir: string; scenarioDir: string; }): Promise { const result = await installCachedScenarioDependencies({ - mode: options.mode, preferOffline: options.preferOffline, scenarioDir: options.scenarioDir, }); @@ -453,7 +332,6 @@ async function linkCachedScenarioDependencies(options: { } export async function installScenarioDependencies({ - mode = getScenarioDependencyMode(), preferOffline = true, scenarioDir, }: InstallScenarioDependenciesOptions): Promise { @@ -462,10 +340,6 @@ export async function installScenarioDependencies({ return { status: "no-manifest" }; } - if (mode === "canary") { - await rewriteManifestForCanary(scenarioDir); - } - const installArgs = [ "install", "--dir", @@ -483,17 +357,8 @@ export async function installScenarioDependencies({ return { status: "installed" }; } -export function getScenarioDependencyMode(): ScenarioDependencyMode { - return process.env[CANARY_MODE_ENV] === "canary" ? "canary" : "locked"; -} - -export function isCanaryMode(): boolean { - return getScenarioDependencyMode() === "canary"; -} - export async function prepareScenarioDir(options: { linkDependencies?: boolean; - mode?: ScenarioDependencyMode; preferOffline?: boolean; scenarioDir: string; }): Promise { @@ -530,13 +395,11 @@ export async function prepareScenarioDir(options: { if (options.linkDependencies === false) { await installScenarioDependencies({ - mode: options.mode, preferOffline: options.preferOffline, scenarioDir: preparedDir, }); } else { await linkCachedScenarioDependencies({ - mode: options.mode, preparedDir, preferOffline: options.preferOffline, scenarioDir: options.scenarioDir, diff --git a/e2e/package.json b/e2e/package.json index 40082f1a8..8de220b7e 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -5,8 +5,7 @@ "scripts": { "test:e2e": "node ./scripts/run-e2e-tests.mjs", "test:e2e:update": "node ./scripts/run-e2e-tests.mjs --update", - "test:e2e:record": "node ./scripts/run-record-tests.mjs", - "test:e2e:canary": "node ./scripts/run-canary-tests-docker.mjs" + "test:e2e:record": "node ./scripts/run-record-tests.mjs" }, "devDependencies": { "@braintrust/langchain-js": "workspace:^", diff --git a/e2e/scripts/bump-e2e-versions.mjs b/e2e/scripts/bump-e2e-versions.mjs new file mode 100644 index 000000000..c9e7a3552 --- /dev/null +++ b/e2e/scripts/bump-e2e-versions.mjs @@ -0,0 +1,569 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { readFile, readdir, writeFile } from "node:fs/promises"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const PNPM_COMMAND = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const E2E_ROOT = path.resolve(SCRIPT_DIR, ".."); +const SCENARIOS_DIR = path.join(E2E_ROOT, "scenarios"); +const REPO_ROOT = path.resolve(E2E_ROOT, ".."); +const SKIP_BUMP_ARG = "--skip-bump"; +const SKIP_RECORD_ARG = "--skip-record"; +const SKIP_REPLAY_ARG = "--skip-replay"; +const INSTALL_SECRET_ENV_VARS = [ + "ANTHROPIC_API_KEY", + "AWS_BEARER_TOKEN_BEDROCK", + "BRAINTRUST_API_KEY", + "CO_API_KEY", + "COHERE_API_KEY", + "CURSOR_API_KEY", + "GEMINI_API_KEY", + "GH_TOKEN", + "GITHUB_TOKEN", + "GOOGLE_API_KEY", + "GOOGLE_GENAI_API_KEY", + "GROQ_API_KEY", + "HUGGINGFACE_API_KEY", + "MISTRAL_API_KEY", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", +]; + +const rawArgs = process.argv.slice(2).filter((arg) => arg !== "--"); +const skipBump = rawArgs.includes(SKIP_BUMP_ARG); +const skipRecord = rawArgs.includes(SKIP_RECORD_ARG); +const skipReplay = rawArgs.includes(SKIP_REPLAY_ARG); +const scenarioFilters = rawArgs.filter( + (arg) => + arg !== SKIP_BUMP_ARG && arg !== SKIP_RECORD_ARG && arg !== SKIP_REPLAY_ARG, +); + +for (const scenarioName of scenarioFilters) { + if (!/^[a-z0-9][a-z0-9-]*$/.test(scenarioName)) { + throw new Error( + `Expected scenario names like "openai-instrumentation", got "${scenarioName}".`, + ); + } +} + +const selectedScenarios = await discoverBumpScenarios(scenarioFilters); +if (selectedScenarios.length === 0) { + throw new Error("No e2e scenarios with braintrustScenario.bump were found."); +} + +if (!skipBump) { + await bumpScenarioDependencies(selectedScenarios); +} + +const scenarioNames = selectedScenarios.map((scenario) => scenario.name); +if (!skipRecord) { + await runCommand(process.execPath, [ + "scripts/run-record-tests.mjs", + ...scenarioNames, + ]); + await runCommand( + process.execPath, + ["scripts/run-e2e-tests.mjs", "--update", ...scenarioNames], + { + env: replayVerificationEnv(), + }, + ); +} +if (!skipReplay) { + await runCommand( + process.execPath, + ["scripts/run-e2e-tests.mjs", ...scenarioNames], + { + env: replayVerificationEnv(), + }, + ); +} + +async function discoverBumpScenarios(filters) { + const filterSet = new Set(filters); + const entries = await readdir(SCENARIOS_DIR, { withFileTypes: true }); + const scenarios = []; + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + if (filterSet.size > 0 && !filterSet.has(entry.name)) { + continue; + } + + const scenarioDir = path.join(SCENARIOS_DIR, entry.name); + const manifestPath = path.join(scenarioDir, "package.json"); + if (!existsSync(manifestPath)) { + continue; + } + + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + const dependencies = manifest.braintrustScenario?.bump?.dependencies; + if ( + dependencies && + typeof dependencies === "object" && + !Array.isArray(dependencies) && + Object.keys(dependencies).length > 0 + ) { + scenarios.push({ + dir: scenarioDir, + manifest, + manifestPath, + name: entry.name, + }); + } + } + + if (filterSet.size > 0) { + const foundNames = new Set(scenarios.map((scenario) => scenario.name)); + for (const filter of filterSet) { + if (!foundNames.has(filter)) { + throw new Error( + `Scenario "${filter}" does not exist or has no braintrustScenario.bump dependencies.`, + ); + } + } + } + + return scenarios.sort((left, right) => left.name.localeCompare(right.name)); +} + +async function bumpScenarioDependencies(scenarios) { + const policy = await readPnpmPolicy(); + const metadataCache = new Map(); + + for (const scenario of scenarios) { + const dependencies = scenario.manifest.dependencies ?? {}; + const rules = scenario.manifest.braintrustScenario.bump.dependencies; + const updates = []; + + for (const [dependencyName, rawRule] of Object.entries(rules)) { + const rule = normalizeBumpRule(dependencyName, rawRule, scenario.name); + let metadata = metadataCache.get(rule.package); + if (!metadata) { + metadata = await fetchPackageMetadata(rule.package); + metadataCache.set(rule.package, metadata); + } + + const version = selectLatestVersion({ + allowPrerelease: rule.allowPrerelease, + metadata, + packageName: rule.package, + policy, + range: rule.range, + }); + const nextSpec = packageSpecifier(dependencyName, rule.package, version); + if (dependencies[dependencyName] !== nextSpec) { + updates.push({ + dependencyName, + from: dependencies[dependencyName] ?? "", + to: nextSpec, + }); + dependencies[dependencyName] = nextSpec; + } + } + + if (updates.length === 0) { + console.error(`[bump] ${scenario.name}: already up to date`); + continue; + } + + scenario.manifest.dependencies = dependencies; + await writeFile( + scenario.manifestPath, + `${JSON.stringify(scenario.manifest, null, 2)}\n`, + ); + for (const update of updates) { + console.error( + `[bump] ${scenario.name}: ${update.dependencyName} ${update.from} -> ${update.to}`, + ); + } + await runCommand( + PNPM_COMMAND, + [ + "install", + "--dir", + scenario.dir, + "--ignore-workspace", + "--lockfile-only", + "--strict-peer-dependencies=false", + ], + { env: installEnv() }, + ); + } +} + +function normalizeBumpRule(dependencyName, rawRule, scenarioName) { + if (!dependencyName.endsWith("-latest")) { + throw new Error( + `${scenarioName} has a bump rule for ${dependencyName}, but bump rules may only target latest lane aliases ending in "-latest".`, + ); + } + + if (!rawRule || typeof rawRule !== "object" || Array.isArray(rawRule)) { + throw new Error( + `${scenarioName} has an invalid bump rule for ${dependencyName}; expected an object.`, + ); + } + if (typeof rawRule.package !== "string" || rawRule.package.length === 0) { + throw new Error( + `${scenarioName} has an invalid bump rule for ${dependencyName}; expected a package name.`, + ); + } + if (typeof rawRule.range !== "string" || rawRule.range.length === 0) { + throw new Error( + `${scenarioName} has an invalid bump rule for ${dependencyName}; expected a range.`, + ); + } + + return { + allowPrerelease: rawRule.allowPrerelease === true, + package: rawRule.package, + range: rawRule.range, + }; +} + +async function readPnpmPolicy() { + const minimumReleaseAgeRaw = await pnpmConfig("minimumReleaseAge"); + const minimumReleaseAge = Number.parseInt(minimumReleaseAgeRaw, 10); + const minimumReleaseAgeMinutes = Number.isFinite(minimumReleaseAge) + ? minimumReleaseAge + : 0; + let minimumReleaseAgeExclude = []; + try { + minimumReleaseAgeExclude = JSON.parse( + await pnpmConfig("minimumReleaseAgeExclude", "--json"), + ); + } catch { + minimumReleaseAgeExclude = []; + } + + return { + minimumReleaseAgeExclude: Array.isArray(minimumReleaseAgeExclude) + ? minimumReleaseAgeExclude.filter((value) => typeof value === "string") + : [], + minimumReleaseAgeMinutes, + }; +} + +async function pnpmConfig(key, ...args) { + return await spawnCapture(PNPM_COMMAND, ["config", "get", key, ...args], { + cwd: REPO_ROOT, + env: process.env, + }); +} + +async function fetchPackageMetadata(packageName) { + const registry = ( + process.env.npm_config_registry || "https://registry.npmjs.org/" + ).replace(/\/?$/, "/"); + const response = await fetch(`${registry}${encodeURIComponent(packageName)}`); + if (!response.ok) { + throw new Error( + `Failed to fetch npm metadata for ${packageName}: ${response.status} ${response.statusText}`, + ); + } + return await response.json(); +} + +function selectLatestVersion({ + allowPrerelease, + metadata, + packageName, + policy, + range, +}) { + const now = Date.now(); + const minimumAgeMs = packageMatchesAnyPattern( + packageName, + policy.minimumReleaseAgeExclude, + ) + ? 0 + : policy.minimumReleaseAgeMinutes * 60_000; + const candidates = Object.keys(metadata.versions ?? {}) + .filter((version) => { + const parsed = parseSemver(version); + if (!parsed) { + return false; + } + if (!allowPrerelease && parsed.prerelease.length > 0) { + return false; + } + if (!satisfiesRange(parsed, range)) { + return false; + } + if (minimumAgeMs > 0) { + const publishedAt = metadata.time?.[version]; + if (!publishedAt || Date.parse(publishedAt) > now - minimumAgeMs) { + return false; + } + } + return true; + }) + .sort(compareVersions) + .reverse(); + + if (candidates.length === 0) { + throw new Error( + `No ${packageName} version satisfies range "${range}" and pnpm minimumReleaseAge=${policy.minimumReleaseAgeMinutes} minutes.`, + ); + } + return candidates[0]; +} + +function packageMatchesAnyPattern(packageName, patterns) { + return patterns.some((pattern) => { + const escaped = pattern + .replace(/[.+?^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*"); + return new RegExp(`^${escaped}$`).test(packageName); + }); +} + +function packageSpecifier(dependencyName, packageName, version) { + return dependencyName === packageName + ? version + : `npm:${packageName}@${version}`; +} + +function installEnv() { + const env = { ...process.env }; + for (const key of INSTALL_SECRET_ENV_VARS) { + delete env[key]; + } + return env; +} + +function replayVerificationEnv() { + const env = { ...process.env }; + // The record phase exercises live Braintrust forwarding when a real key is + // present. Replay should only verify the committed cassettes and snapshots. + delete env.BRAINTRUST_API_KEY; + return env; +} + +function parseSemver(version) { + const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-.]+))?(?:\+.+)?$/.exec( + version, + ); + if (!match) { + return null; + } + return { + major: Number.parseInt(match[1], 10), + minor: Number.parseInt(match[2], 10), + patch: Number.parseInt(match[3], 10), + prerelease: match[4] ? match[4].split(".") : [], + version, + }; +} + +function compareVersions(left, right) { + return compareParsedSemver(parseSemver(left), parseSemver(right)); +} + +function compareParsedSemver(left, right) { + for (const key of ["major", "minor", "patch"]) { + if (left[key] !== right[key]) { + return left[key] - right[key]; + } + } + + if (left.prerelease.length === 0 && right.prerelease.length === 0) { + return 0; + } + if (left.prerelease.length === 0) { + return 1; + } + if (right.prerelease.length === 0) { + return -1; + } + + const length = Math.max(left.prerelease.length, right.prerelease.length); + for (let index = 0; index < length; index++) { + const leftPart = left.prerelease[index]; + const rightPart = right.prerelease[index]; + if (leftPart === undefined) { + return -1; + } + if (rightPart === undefined) { + return 1; + } + if (leftPart === rightPart) { + continue; + } + + const leftNumber = /^\d+$/.test(leftPart) ? Number(leftPart) : null; + const rightNumber = /^\d+$/.test(rightPart) ? Number(rightPart) : null; + if (leftNumber !== null && rightNumber !== null) { + return leftNumber - rightNumber; + } + if (leftNumber !== null) { + return -1; + } + if (rightNumber !== null) { + return 1; + } + return leftPart.localeCompare(rightPart); + } + + return 0; +} + +function satisfiesRange(parsed, rawRange) { + const range = rawRange.trim(); + if (range === "*" || range === "latest") { + return true; + } + if (/^\d+$/.test(range)) { + return parsed.major === Number.parseInt(range, 10); + } + if (/^\d+\.(?:x|\*)$/i.test(range)) { + return parsed.major === Number.parseInt(range.split(".")[0], 10); + } + + const exact = parseSemver(range); + if (exact) { + return compareParsedSemver(parsed, exact) === 0; + } + + if (range.startsWith("^")) { + const base = parseSemver(range.slice(1)); + if (!base || compareParsedSemver(parsed, base) < 0) { + return false; + } + if (base.major > 0) { + return parsed.major === base.major; + } + if (base.minor > 0) { + return parsed.major === 0 && parsed.minor === base.minor; + } + return ( + parsed.major === 0 && parsed.minor === 0 && parsed.patch === base.patch + ); + } + + if (range.startsWith("~")) { + const base = parseSemver(range.slice(1)); + return ( + Boolean(base) && + compareParsedSemver(parsed, base) >= 0 && + parsed.major === base.major && + parsed.minor === base.minor + ); + } + + if (/^(?:[<>=]=?\S+\s*)+$/.test(range)) { + return range + .split(/\s+/) + .filter(Boolean) + .every((comparator) => satisfiesComparator(parsed, comparator)); + } + + throw new Error( + `Unsupported npm version range "${range}" in e2e bump metadata.`, + ); +} + +function satisfiesComparator(parsed, comparator) { + const match = /^(<=|>=|<|>|=)?(.+)$/.exec(comparator); + if (!match) { + return false; + } + const operator = match[1] ?? "="; + let version = parseSemver(match[2]); + if (!version) { + const partial = /^(\d+)(?:\.(\d+))?$/.exec(match[2]); + if (partial) { + version = { + major: Number.parseInt(partial[1], 10), + minor: partial[2] ? Number.parseInt(partial[2], 10) : 0, + patch: 0, + prerelease: [], + version: match[2], + }; + } + } + if (!version) { + throw new Error(`Unsupported npm version comparator "${comparator}".`); + } + const comparison = compareParsedSemver(parsed, version); + switch (operator) { + case "<": + return comparison < 0; + case "<=": + return comparison <= 0; + case ">": + return comparison > 0; + case ">=": + return comparison >= 0; + case "=": + return comparison === 0; + default: + return false; + } +} + +async function spawnCapture(command, args, options) { + const result = await spawnResult(command, args, options, "pipe"); + if (result.code !== 0) { + throw new Error( + `${command} ${args.join(" ")} failed with exit code ${result.code}\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`, + ); + } + return result.stdout.trim(); +} + +async function runCommand(command, args, options = {}) { + const result = await spawnResult( + command, + args, + { cwd: options.cwd ?? E2E_ROOT, env: options.env ?? process.env }, + "inherit", + ); + if (result.signal) { + process.kill(process.pid, result.signal); + return; + } + if (result.code !== 0) { + process.exit(result.code ?? 1); + } +} + +async function spawnResult(command, args, options, stdio) { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"], + }); + + if (stdio === "inherit") { + return await new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", (code, signal) => + resolve({ code: code ?? 1, signal, stderr: "", stdout: "" }), + ); + }); + } + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + return await new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", (code, signal) => + resolve({ code: code ?? 1, signal, stderr, stdout }), + ); + }); +} diff --git a/e2e/scripts/run-canary-tests.mjs b/e2e/scripts/run-canary-tests.mjs deleted file mode 100644 index b27624bcc..000000000 --- a/e2e/scripts/run-canary-tests.mjs +++ /dev/null @@ -1,103 +0,0 @@ -import { spawn } from "node:child_process"; -import { access, readFile, readdir } from "node:fs/promises"; -import * as path from "node:path"; -import { fileURLToPath } from "node:url"; - -const PNPM_COMMAND = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; -const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); -const E2E_ROOT = path.resolve(SCRIPT_DIR, ".."); -const SCENARIOS_DIR = path.join(E2E_ROOT, "scenarios"); - -async function fileExists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -async function getCanaryTestFiles() { - const entries = await readdir(SCENARIOS_DIR, { withFileTypes: true }); - const testFiles = new Set(); - - for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } - - const scenarioDir = path.join(SCENARIOS_DIR, entry.name); - const manifestPath = path.join(scenarioDir, "package.json"); - if (!(await fileExists(manifestPath))) { - continue; - } - - const manifest = JSON.parse(await readFile(manifestPath, "utf8")); - const canaryDependencies = - manifest?.braintrustScenario?.canary?.dependencies; - if ( - !canaryDependencies || - typeof canaryDependencies !== "object" || - Array.isArray(canaryDependencies) || - Object.keys(canaryDependencies).length === 0 - ) { - continue; - } - - const configuredTestFile = manifest?.braintrustScenario?.canary?.testFile; - const testPath = - typeof configuredTestFile === "string" && configuredTestFile.length > 0 - ? path.resolve(scenarioDir, configuredTestFile) - : path.join(scenarioDir, "scenario.test.ts"); - if (!(await fileExists(testPath))) { - throw new Error( - `Canary scenario ${entry.name} is missing test file ${path.relative( - E2E_ROOT, - testPath, - )}`, - ); - } - - testFiles.add(path.relative(E2E_ROOT, testPath)); - } - - return [...testFiles].sort(); -} - -async function runVitest(testFiles, { update = false } = {}) { - const env = { - ...process.env, - BRAINTRUST_E2E_MODE: "canary", - // Canary tests use live provider APIs with real keys; bypass any cassette - // replay that would activate because a pinned cassette file exists for the - // same variant key. Without this, the latest SDK may send slightly - // different request bodies, causing cassette misses → HttpResponse.error() - // → SDK retries → subprocess timeout. - BRAINTRUST_E2E_CASSETTE_MODE: "passthrough", - }; - - const vitestArgs = ["exec", "vitest", "run"]; - if (update) vitestArgs.push("--update"); - vitestArgs.push(...testFiles); - - const exitCode = await new Promise((resolve, reject) => { - const child = spawn(PNPM_COMMAND, vitestArgs, { - cwd: E2E_ROOT, - env, - stdio: "inherit", - }); - - child.on("error", reject); - child.on("close", (code) => resolve(code ?? 1)); - }); - - process.exit(exitCode); -} - -const testFiles = await getCanaryTestFiles(); -if (testFiles.length === 0) { - throw new Error("No canary e2e scenarios are configured."); -} - -const update = process.argv.includes("--update"); -await runVitest(testFiles, { update }); diff --git a/e2e/scripts/run-canary-tests-docker.mjs b/e2e/scripts/run-e2e-bump-docker.mjs similarity index 62% rename from e2e/scripts/run-canary-tests-docker.mjs rename to e2e/scripts/run-e2e-bump-docker.mjs index 9b75bb175..08202cee8 100644 --- a/e2e/scripts/run-canary-tests-docker.mjs +++ b/e2e/scripts/run-e2e-bump-docker.mjs @@ -1,33 +1,85 @@ +#!/usr/bin/env node + import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; +import { parse as parseDotEnv } from "dotenv"; const DOCKER_COMMAND = process.platform === "win32" ? "docker.exe" : "docker"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const E2E_ROOT = path.resolve(SCRIPT_DIR, ".."); const REPO_ROOT = path.resolve(E2E_ROOT, ".."); const SCENARIOS_DIR = path.join(E2E_ROOT, "scenarios"); -const DOCKERFILE_PATH = path.join(E2E_ROOT, "Dockerfile.canary"); -const IMAGE_NAME = "braintrust-e2e-canary:local"; +const DOCKERFILE_PATH = path.join(E2E_ROOT, "Dockerfile.e2e-bump"); +const IMAGE_NAME = "braintrust-e2e-bump:local"; const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"]; const ALLOWED_ENV_KEYS = [ "ANTHROPIC_API_KEY", + "ANTHROPIC_BEDROCK_BASE_URL", + "ANTHROPIC_BASE_URL", + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_BEDROCK_RUNTIME_BASE_URL", + "AWS_DEFAULT_REGION", + "AWS_REGION", + "BEDROCK_RUNTIME_BASE_URL", + "BRAINTRUST_ANTHROPIC_BEDROCK_MODEL", "BRAINTRUST_API_KEY", - "BRAINTRUST_E2E_PROJECT_NAME", + "BRAINTRUST_APP_PUBLIC_URL", "BRAINTRUST_APP_URL", - "GEMINI_API_KEY", - "GOOGLE_API_KEY", + "BRAINTRUST_BEDROCK_CONVERSE_MODEL", + "BRAINTRUST_E2E_MODEL_BASE_URL", + "BRAINTRUST_E2E_PROJECT_NAME", + "CO_API_KEY", "COHERE_API_KEY", + "COHERE_API_URL", + "COHERE_BASE_URL", "CURSOR_API_KEY", + "CURSOR_BACKEND_URL", + "GEMINI_API_KEY", + "GEMINI_BASE_URL", + "GEMINI_NEXT_GEN_API_BASE_URL", + "GH_TOKEN", + "GITHUB_TOKEN", + "GOOGLE_API_KEY", + "GOOGLE_GENERATIVE_AI_BASE_URL", + "GOOGLE_GENAI_API_KEY", + "GOOGLE_GENAI_BASE_URL", "GROQ_API_KEY", + "GROQ_BASE_URL", + "HF_ENDPOINT", + "HF_INFERENCE_ENDPOINT", + "HUGGINGFACE_API_KEY", + "HUGGINGFACE_BASE_URL", + "HUGGINGFACE_ROUTER_BASE_URL", + "MISTRAL_AGENT_ID", + "MISTRAL_API_KEY", + "MISTRAL_API_URL", + "MISTRAL_BASE_URL", + "MISTRAL_CLASSIFIER_MODEL", "OPENAI_API_KEY", "OPENAI_BASE_URL", + "OPENAI_CODEX_E2E_MODEL", "OPENROUTER_API_KEY", - "MISTRAL_API_KEY", - "HUGGINGFACE_API_KEY", + "OPENROUTER_BASE_URL", ]; +function loadRepoEnv() { + const originalEnvKeys = new Set(Object.keys(process.env)); + for (const envFile of [".env", ".env.local"]) { + const envPath = path.join(REPO_ROOT, envFile); + if (existsSync(envPath)) { + const parsed = parseDotEnv(readFileSync(envPath)); + for (const [key, value] of Object.entries(parsed)) { + if (!originalEnvKeys.has(key)) { + process.env[key] = value; + } + } + } + } +} + function getAllowedEnv() { const env = {}; for (const key of ALLOWED_ENV_KEYS) { @@ -86,6 +138,8 @@ async function runCommand(command, args, cwd) { } } +loadRepoEnv(); + const passthroughArgs = process.argv.slice(2); const containerEnv = getAllowedEnv(); @@ -99,7 +153,6 @@ const dockerRunArgs = [ "run", "--rm", "--cap-drop=ALL", - // Prevent privilege escalation inside the container (e.g., via setuid/setgid binaries). "--security-opt=no-new-privileges", "--volume", `${SCENARIOS_DIR}:/workspace/e2e/scenarios:rw`, @@ -111,18 +164,17 @@ if ( typeof process.getuid === "function" && typeof process.getgid === "function" ) { - // Preserve host file ownership for snapshots written via the bind mount. dockerRunArgs.push("--user", `${process.getuid()}:${process.getgid()}`); } -for (const [key, value] of Object.entries(containerEnv)) { - dockerRunArgs.push("--env", `${key}=${value}`); +for (const key of Object.keys(containerEnv)) { + dockerRunArgs.push("--env", key); } dockerRunArgs.push( IMAGE_NAME, "node", - "e2e/scripts/run-canary-tests.mjs", + "e2e/scripts/bump-e2e-versions.mjs", ...passthroughArgs, ); diff --git a/e2e/scripts/run-e2e-tests.mjs b/e2e/scripts/run-e2e-tests.mjs index 2d7108f25..d09c79388 100644 --- a/e2e/scripts/run-e2e-tests.mjs +++ b/e2e/scripts/run-e2e-tests.mjs @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; +import { readdir } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -18,10 +19,14 @@ const DEFAULT_OPENAI_CODEX_E2E_MODEL = "gpt-5.1-codex-mini"; const rawArgs = process.argv.slice(2).filter((arg) => arg !== "--"); const updateSnapshots = rawArgs.includes("--update"); const scenarioArgs = rawArgs.filter((arg) => arg !== "--update"); +const testTargets = + scenarioArgs.length > 0 + ? scenarioArgs.map((arg) => scenarioPathArg(arg)) + : await defaultScenarioTestPaths(); const vitestArgs = [ "run", "--run", - ...scenarioArgs.map((arg) => scenarioPathArg(arg)), + ...testTargets, ...(updateSnapshots ? ["--update"] : []), ]; const result = await runProcess(VITEST_COMMAND, vitestArgs, { @@ -44,6 +49,24 @@ function scenarioPathArg(arg) { return existsSync(scenarioPath) ? `scenarios/${arg}/scenario.test.ts` : arg; } +async function defaultScenarioTestPaths() { + const entries = await readdir(path.join(E2E_DIR, "scenarios"), { + withFileTypes: true, + }); + const scenarioPaths = entries + .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) + .map((entry) => `scenarios/${entry.name}/scenario.test.ts`) + .filter((scenarioPath) => existsSync(path.join(E2E_DIR, scenarioPath))) + .sort(); + + if (scenarioPaths.length === 0) { + console.error("[e2e] No scenario test files found."); + process.exit(1); + } + + return scenarioPaths; +} + function replayEnv() { const env = { ...process.env }; env.BRAINTRUST_E2E_CASSETTE_MODE = "replay"; diff --git a/e2e/scripts/run-record-tests.mjs b/e2e/scripts/run-record-tests.mjs index bcb427ad0..22dc6f997 100644 --- a/e2e/scripts/run-record-tests.mjs +++ b/e2e/scripts/run-record-tests.mjs @@ -212,8 +212,13 @@ async function runVitest(scenarioNames, runContextDir) { ); const vitestArgs = scenarioPaths.length > 0 - ? ["run", ...scenarioPaths, "--update"] - : ["run", "--update"]; + ? ["run", "--no-file-parallelism", ...scenarioPaths, "--update"] + : [ + "run", + "--no-file-parallelism", + ...(await defaultScenarioTestPaths()), + "--update", + ]; return await runProcess(VITEST_COMMAND, vitestArgs, { cwd: E2E_DIR, @@ -221,6 +226,24 @@ async function runVitest(scenarioNames, runContextDir) { }); } +async function defaultScenarioTestPaths() { + const entries = await readdir(path.join(E2E_DIR, "scenarios"), { + withFileTypes: true, + }); + const scenarioPaths = entries + .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) + .map((entry) => `scenarios/${entry.name}/scenario.test.ts`) + .filter((scenarioPath) => existsSync(path.join(E2E_DIR, scenarioPath))) + .sort(); + + if (scenarioPaths.length === 0) { + console.error("[record] No scenario test files found."); + process.exit(1); + } + + return scenarioPaths; +} + async function runProcess(command, args, options) { const child = spawn(command, args, { ...options, diff --git a/package.json b/package.json index a915e4d8a..0bdd051ae 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "test:e2e": "turbo run test:e2e", "test:e2e:update": "turbo run test:e2e:update", "test:e2e:record": "node e2e/scripts/run-record-tests.mjs --braintrust-build-deps", - "test:e2e:canary": "turbo run test:e2e:canary", + "test:e2e:bump": "node e2e/scripts/run-e2e-bump-docker.mjs", "dotagents:install": "dotagents install", "dotagents:sync": "dotagents sync", "changeset": "changeset", diff --git a/turbo.json b/turbo.json index 1f99fca3c..6e5da8d56 100644 --- a/turbo.json +++ b/turbo.json @@ -47,26 +47,6 @@ "dependsOn": ["^build"], "outputs": [] }, - "test:e2e:canary": { - "cache": false, - "env": [ - "ANTHROPIC_API_KEY", - "BRAINTRUST_API_KEY", - "BRAINTRUST_E2E_PROJECT_NAME", - "BRAINTRUST_E2E_RUN_CONTEXT_DIR", - "GEMINI_API_KEY", - "COHERE_API_KEY", - "CURSOR_API_KEY", - "GROQ_API_KEY", - "OPENAI_API_KEY", - "OPENAI_BASE_URL", - "OPENROUTER_API_KEY", - "MISTRAL_API_KEY", - "HUGGINGFACE_API_KEY" - ], - "dependsOn": [], - "outputs": [] - }, "test:e2e:record": { "cache": false, "env": [