ci: V8 E2E coverage + per-spec impact map (DEVP-205) (#31441)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Declan Carroll 2026-06-03 13:17:27 +01:00 committed by GitHub
parent bc53dc101f
commit d57545d4d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1872 additions and 522 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,9 +1,13 @@
name: 'Test: E2E Coverage Weekly'
name: 'Test: E2E Coverage Nightly'
on:
schedule:
- cron: '0 2 * * 1' # Every Monday at 2 AM
workflow_dispatch: # Allow manual triggering
- cron: '0 2 * * *' # Nightly at 02:00 UTC
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
prepare-docker:
@ -14,17 +18,43 @@ jobs:
runner: blacksmith-8vcpu-ubuntu-2204
secrets: inherit
# Distribute the full e2e suite across shards by historical duration
# (janitor orchestrator) instead of Playwright's count-based --shard=N/total.
# Count-based sharding stacked the heavy + flaky specs onto one shard, which
# then blew past the timeout; duration-weighting keeps every shard even.
generate-matrix:
name: Generate shard matrix
runs-on: blacksmith-2vcpu-ubuntu-2204
outputs:
matrix: ${{ steps.gen.outputs.matrix }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup and Build janitor
uses: ./.github/actions/setup-nodejs
with:
build-command: pnpm turbo run build --filter=@n8n/playwright-janitor
- name: Generate matrix (8 shards, duration-weighted)
id: gen
run: |
MATRIX=$(node packages/testing/playwright/scripts/distribute-tests.mjs --matrix 8 --orchestrate)
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
e2e:
name: E2E (coverage)
needs: prepare-docker
needs: [prepare-docker, generate-matrix]
uses: ./.github/workflows/test-e2e-reusable.yml
with:
test-mode: docker-artifact
test-command: pnpm --filter=n8n-playwright test:container:coverage
# Runs the coverage project + resolves this shard's V8 to lcov/per-spec
# (keeps test-e2e-reusable coverage-agnostic). See run-coverage-shard.mjs.
test-command: pnpm --filter=n8n-playwright coverage:shard
workers: '1'
runner: blacksmith-4vcpu-ubuntu-2204
timeout-minutes: 55
pre-generated-matrix: '[{"shard":1,"images":""},{"shard":2,"images":""},{"shard":3,"images":""},{"shard":4,"images":""},{"shard":5,"images":""},{"shard":6,"images":""}]'
pre-generated-matrix: ${{ needs.generate-matrix.outputs.matrix }}
artifact-prefix: coverage
build-variant: coverage
secrets: inherit
@ -47,18 +77,15 @@ jobs:
pattern: coverage-shard-*
path: /tmp/shards/
- name: Collect coverage JSON
shell: bash
run: |
mkdir -p packages/testing/playwright/.nyc_output/coverage
found=$(find /tmp/shards -path '*/.nyc_output/coverage/*.json' 2>/dev/null | wc -l)
echo "Found $found coverage JSON files across shards"
find /tmp/shards -path '*/.nyc_output/coverage/*.json' \
-exec cp {} packages/testing/playwright/.nyc_output/coverage/ \;
ls -la packages/testing/playwright/.nyc_output/coverage/ || true
- name: Build janitor (tested merge-coverage)
run: pnpm turbo run build --filter=@n8n/playwright-janitor
- name: Generate Coverage Report
run: pnpm --filter n8n-playwright coverage:report
# Report (shard lcovs → unified lcov) + impact map (per-spec lcovs →
# spec-keyed map), both via the property-tested janitor merge-coverage.
# Logic lives in the tested aggregate-coverage.mjs, not inline bash.
- name: Aggregate coverage + build impact map
working-directory: packages/testing/playwright
run: node scripts/aggregate-coverage.mjs --shards=/tmp/shards --out=coverage
- name: Upload Coverage Report Artifact
if: always()

View File

@ -148,12 +148,10 @@ jobs:
path: |
packages/testing/playwright/test-results/
packages/testing/playwright/playwright-report/
packages/testing/playwright/.nyc_output/
packages/testing/playwright/coverage/lcov.info
packages/testing/playwright/coverage/by-spec/
retention-days: 1
if-no-files-found: ignore
# upload-artifact@v7 defaults this to false, which silently drops
# `.nyc_output/` (dotfile) — needed for the weekly coverage workflow.
include-hidden-files: true
- name: Cancel Currents run if workflow is cancelled
if: ${{ cancelled() }}

1
.gitignore vendored
View File

@ -4,6 +4,7 @@ node_modules
tmp
dist
coverage
coverage-by-spec
npm-debug.log*
yarn.lock
google-generated-credentials.json

View File

@ -7,7 +7,7 @@
"scripts": {
"clean": "rimraf dist .turbo .build",
"build": "cross-env VUE_APP_PUBLIC_PATH=\"/{{BASE_PATH}}/\" NODE_OPTIONS=\"--max-old-space-size=8192\" vite build",
"build:coverage": "cross-env VUE_APP_PUBLIC_PATH=\"/{{BASE_PATH}}/\" NODE_OPTIONS=\"--max-old-space-size=14000\" BUILD_WITH_COVERAGE=true vite build",
"build:coverage": "cross-env VUE_APP_PUBLIC_PATH=\"/{{BASE_PATH}}/\" NODE_OPTIONS=\"--max-old-space-size=10240\" BUILD_WITH_COVERAGE=true vite build",
"typecheck": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" vue-tsc --noEmit",
"typecheck:watch": "vue-tsc --watch --noEmit",
"dev": "pnpm serve",
@ -146,7 +146,6 @@
"sass-embedded": "catalog:",
"unplugin-icons": "catalog:frontend",
"vite": "catalog:",
"vite-plugin-istanbul": "^8.0.0",
"vite-plugin-node-polyfills": "^0.25.0",
"vite-plugin-static-copy": "4.0.0",
"vite-svg-loader": "catalog:frontend",

View File

@ -4,7 +4,6 @@ import { defineConfig, mergeConfig, type UserConfig } from 'vite';
import { viteStaticCopy } from 'vite-plugin-static-copy';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
import svgLoader from 'vite-svg-loader';
import istanbul from 'vite-plugin-istanbul';
import { sentryVitePlugin } from '@sentry/vite-plugin';
import { codecovVitePlugin } from '@codecov/vite-plugin';
@ -95,18 +94,6 @@ const plugins: UserConfig['plugins'] = [
compiler: 'vue3',
autoInstall: NODE_ENV === 'development',
}),
// Add istanbul coverage plugin for E2E tests
...(process.env.BUILD_WITH_COVERAGE === 'true'
? [
istanbul({
include: 'src/**/*',
exclude: ['node_modules', 'tests/', 'dist/'],
extension: ['.js', '.ts', '.vue'],
forceBuildInstrument: true,
requireEnv: false,
}),
]
: []),
viteStaticCopy({
targets: [
{
@ -247,7 +234,9 @@ export default mergeConfig(
},
build: {
minify: !!release,
sourcemap: !!release,
// Coverage builds emit INLINE maps so browser V8 coverage carries the
// map in the script source and monocart resolves offsets back to src.
sourcemap: process.env.BUILD_WITH_COVERAGE === 'true' ? 'inline' : !!release,
target,
},
optimizeDeps: {

View File

@ -1,3 +1,5 @@
import { chmodSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import type { PortWithOptionalBinding, StartedNetwork, StartedTestContainer } from 'testcontainers';
import { GenericContainer } from 'testcontainers';
@ -12,6 +14,10 @@ import { TEST_CONTAINER_IMAGES } from '../test-containers';
import type { FileToMount } from './types';
const N8N_IMAGE = TEST_CONTAINER_IMAGES.n8n;
// In-container path that NODE_V8_COVERAGE writes to when coverage collection is
// enabled (via StackConfig.coverageHostDir); bind-mounted to a host subdir.
const CONTAINER_COVERAGE_DIR = '/cov';
// Must match N8N_PORT / QUEUE_HEALTH_CHECK_PORT defaults.
const N8N_READINESS_PORT = 5678;
const N8N_STARTUP_TIMEOUT_MS = 60_000;
@ -73,6 +79,7 @@ export interface N8NInstancesOptions {
/** Resource quota for webhook procs. Falls back to `resourceQuota` if omitted. */
webhookResourceQuota?: { memory?: number; cpu?: number };
filesToMount?: FileToMount[];
coverageHostDir?: string;
}
export interface N8NInstancesResult {
@ -142,6 +149,7 @@ interface SharedConfig {
network: StartedNetwork;
resourceQuota?: { memory?: number; cpu?: number };
filesToMount?: FileToMount[];
coverageHostDir?: string;
}
interface ContainerStartResult {
@ -162,7 +170,8 @@ async function createContainer(
diagnostics: N8NStartupDiagnostics,
): Promise<ContainerStartResult> {
const { name, role, instanceNumber, networkAlias, hostPort } = instance;
const { projectName, environment, network, resourceQuota, filesToMount } = shared;
const { projectName, environment, network, resourceQuota, filesToMount, coverageHostDir } =
shared;
const { consumer, throwWithLogs, getLogs } = createSilentLogConsumer();
const { strategy: waitStrategy, getLastBody: getLastReadinessBody } = createReadinessProbe(
'/healthz/readiness',
@ -170,8 +179,12 @@ async function createContainer(
{ startupTimeoutMs: N8N_STARTUP_TIMEOUT_MS, readTimeoutMs: N8N_READ_TIMEOUT_MS },
);
const containerEnvironment = coverageHostDir
? { ...environment, NODE_V8_COVERAGE: CONTAINER_COVERAGE_DIR }
: environment;
let container = new GenericContainer(N8N_IMAGE)
.withEnvironment(environment)
.withEnvironment(containerEnvironment)
.withLabels({
'com.docker.compose.project': projectName,
'com.docker.compose.service': SERVICE_LABEL[role],
@ -180,9 +193,24 @@ async function createContainer(
.withPullPolicy(new N8nImagePullPolicy(N8N_IMAGE))
.withName(name)
.withLogConsumer(consumer)
.withReuse()
.withNetwork(network);
if (coverageHostDir) {
// Per-container host dir → /cov; n8n flushes V8 here on graceful stop.
// Reuse must stay off so the process actually exits and flushes.
const hostCoverageDir = join(coverageHostDir, name);
mkdirSync(hostCoverageDir, { recursive: true });
// The n8n container runs as `node` (uid 1000); on Linux CI the bind mount
// is direct (no Docker Desktop uid mapping), so make the dir writable by
// the container or NODE_V8_COVERAGE silently fails to flush.
chmodSync(hostCoverageDir, 0o777);
container = container.withBindMounts([
{ source: hostCoverageDir, target: CONTAINER_COVERAGE_DIR, mode: 'rw' },
]);
} else {
container = container.withReuse();
}
if (filesToMount?.length) {
container = container.withCopyContentToContainer(filesToMount);
}
@ -242,6 +270,7 @@ export async function createN8NInstances(
workerResourceQuota,
webhookResourceQuota,
filesToMount,
coverageHostDir,
} = options;
const log = createElapsedLogger('n8n-instances');
@ -255,6 +284,7 @@ export async function createN8NInstances(
network,
resourceQuota,
filesToMount,
coverageHostDir,
};
const workerShared: SharedConfig = {
@ -263,6 +293,7 @@ export async function createN8NInstances(
network,
resourceQuota: workerResourceQuota ?? resourceQuota,
filesToMount,
coverageHostDir,
};
const webhookShared: SharedConfig = {

View File

@ -91,6 +91,13 @@ export interface StackConfig {
* Benchmarks should set `'round_robin'` to actually distribute load.
*/
lbPolicy?: LoadBalancerPolicy;
/**
* When set, each n8n container collects Node V8 coverage: `NODE_V8_COVERAGE`
* is written to a per-container subdir of this host path (bind-mounted), reuse
* is disabled, and the stack stops gracefully so the process flushes on exit.
* Opt-in capability for the coverage pipeline; off by default.
*/
coverageHostDir?: string;
}
export interface Service<TResult extends ServiceResult = ServiceResult> {

View File

@ -89,6 +89,7 @@ export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack>
services: enabledServices = [],
external = false,
networkName,
coverageHostDir,
} = config;
const log = createElapsedLogger('stack');
@ -228,6 +229,7 @@ export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack>
workerResourceQuota,
webhookResourceQuota,
filesToMount,
coverageHostDir,
});
containers.push(...n8nResult.containers);
telemetry.recordN8nStartup(
@ -317,7 +319,7 @@ export async function createN8NStack(config: N8NConfig = {}): Promise<N8NStack>
return {
baseUrl,
projectName: uniqueProjectName,
stop: async () => await stopN8NStack(containers, network, uniqueProjectName),
stop: async () => await stopN8NStack(containers, network, uniqueProjectName, coverageHostDir),
containers,
serviceResults,
services: servicesProxy,
@ -360,12 +362,18 @@ async function stopN8NStack(
containers: StartedTestContainer[],
network: StartedNetwork,
uniqueProjectName: string,
coverageHostDir?: string,
): Promise<void> {
const errors: Error[] = [];
// testcontainers stops with timeout:0 (immediate SIGKILL). When collecting
// Node V8 coverage we need a graceful SIGTERM + grace so n8n flushes
// NODE_V8_COVERAGE to the bind-mounted dir before exit (~1s in practice).
// testcontainers `timeout` is in milliseconds (→ docker stop -t seconds).
const stopOptions = coverageHostDir ? { timeout: 30_000 } : undefined;
try {
const stopPromises = containers.reverse().map(async (container) => {
try {
await container.stop();
await container.stop(stopOptions);
} catch (error) {
errors.push(
new Error(`Failed to stop container ${container.getId()}: ${getErrorMessage(error)}`),

View File

@ -1,51 +1,53 @@
{
"name": "@n8n/playwright-janitor",
"private": true,
"version": "0.1.0",
"description": "Static analysis and architecture enforcement for Playwright test suites",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"janitor": "bin/janitor.mjs"
},
"files": [
"bin",
"dist"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"typecheck": "tsc --noEmit"
},
"keywords": [
"playwright",
"testing",
"static-analysis",
"architecture",
"linting"
],
"license": "MIT",
"dependencies": {
"@n8n/rules-engine": "workspace:*",
"glob": "^10.0.0",
"yaml": "catalog:"
},
"peerDependencies": {
"ts-morph": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@vitest/coverage-v8": "catalog:",
"ts-morph": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
"version": "0.1.0",
"description": "Static analysis and architecture enforcement for Playwright test suites",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"janitor": "bin/janitor.mjs"
},
"files": [
"bin",
"dist"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "vitest run",
"test:unit": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"typecheck": "tsc --noEmit"
},
"keywords": [
"playwright",
"testing",
"static-analysis",
"architecture",
"linting"
],
"license": "MIT",
"dependencies": {
"@n8n/rules-engine": "workspace:*",
"glob": "^10.0.0",
"yaml": "catalog:"
},
"peerDependencies": {
"ts-morph": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@vitest/coverage-v8": "catalog:",
"fast-check": "catalog:",
"ts-morph": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}

View File

@ -45,6 +45,14 @@ import {
formatBaselineInfo,
getBaselinePath,
} from './core/baseline.js';
import {
type ImpactMap,
type InternedImpactMap,
decodeImpactMap,
encodeImpactMap,
mergeCoverage,
resolveImpact,
} from './core/coverage-map.js';
import { extractDiffs } from './core/extract-diffs.js';
import {
ImpactAnalyzer,
@ -646,6 +654,76 @@ function runScope(options: CliOptions): void {
console.log(formatScope(result));
}
function findLcovFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of fs.readdirSync(dir)) {
const p = path.join(dir, entry);
if (fs.statSync(p).isDirectory()) out.push(...findLcovFiles(p));
else if (entry.endsWith('.lcov') || entry === 'lcov.info') out.push(p);
}
return out;
}
/** merge-coverage: per-spec lcovs (under --inputs-dir) → unified lcov + impact map. */
function runMergeCoverage(options: CliOptions): void {
if (!options.inputsDir || !options.outLcov || !options.outMap) {
console.error('Error: --inputs-dir, --out-lcov, and --out-map are required');
process.exit(1);
}
const files = fs.existsSync(options.inputsDir) ? findLcovFiles(options.inputsDir) : [];
// spec attribution comes from each lcov's TN:; the path is only a fallback.
const inputs = files.map((f) => ({ text: fs.readFileSync(f, 'utf8'), spec: f }));
const result = mergeCoverage(inputs);
fs.writeFileSync(options.outLcov, result.lcov);
// Interned on-disk form — spec paths once, referenced by index (~10x smaller).
fs.writeFileSync(options.outMap, JSON.stringify(encodeImpactMap(result.impactMap)));
console.error(
`merge-coverage: ${files.length} lcov(s) → ${result.stats.files} files, ` +
`${result.stats.mapEntries} map entries, ${result.stats.specs} specs`,
);
}
/** select-e2e: changed files + impact map spec list (JSON).
*
* Two layers of safety, both biased to OVER-select (never miss a regression):
* - FAIL-OPEN on the map source: a missing/unreadable/corrupt map broad
* (run everything). This is what makes swapping the committed file for a
* remote webhook safe a fetch failure degrades to running the full suite,
* never to skipping tests.
* - DEFAULT-BROAD on content: any changed file absent from a loaded map broad.
*/
function runSelectE2e(options: CliOptions): void {
const changed = (readChangedFiles(options) ?? []).map((file) => ({ file }));
const allSpecs = options.allSpecsFile
? fs
.readFileSync(options.allSpecsFile, 'utf8')
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean)
: undefined;
let map: ImpactMap = {};
let failOpen: string | undefined;
if (options.mapFile && fs.existsSync(options.mapFile)) {
try {
const parsed: unknown = JSON.parse(fs.readFileSync(options.mapFile, 'utf8'));
// Interned form ({specs, files}) is decoded; a plain ImpactMap is used as-is.
const isInterned =
typeof parsed === 'object' && parsed !== null && 'specs' in parsed && 'files' in parsed;
map = isInterned ? decodeImpactMap(parsed as InternedImpactMap) : (parsed as ImpactMap);
} catch (error) {
failOpen = `unreadable map: ${String(error)}`;
}
} else {
failOpen = options.mapFile ? `map not found: ${options.mapFile}` : 'no --map provided';
}
// With an empty map every changed file is "unmapped" → resolveImpact returns
// broad, so fail-open falls out of the same code path — no special-casing.
const result = resolveImpact(changed, map, { allSpecs });
console.log(JSON.stringify({ ...result, failOpen }));
}
async function main(): Promise<void> {
const options = parseArgs();
@ -704,6 +782,14 @@ async function main(): Promise<void> {
runTestScopedCmd(options);
return;
}
if (options.command === 'merge-coverage') {
runMergeCoverage(options);
return;
}
if (options.command === 'select-e2e') {
runSelectE2e(options);
return;
}
if (options.list) {
const runner = createDefaultRunner();

View File

@ -18,7 +18,9 @@ export type Command =
| 'affected-packages'
| 'scope'
| 'test-scoped'
| 'filter-shard';
| 'filter-shard'
| 'merge-coverage'
| 'select-e2e';
export interface CliOptions {
command: Command;
@ -63,6 +65,12 @@ export interface CliOptions {
passthroughArgs: string[];
// filter-shard-specific options
url?: string;
// coverage map options (merge-coverage / select-e2e)
inputsDir?: string;
outLcov?: string;
outMap?: string;
mapFile?: string;
allSpecsFile?: string;
}
const SUBCOMMANDS: Record<string, Command> = {
@ -78,6 +86,8 @@ const SUBCOMMANDS: Record<string, Command> = {
scope: 'scope',
'test-scoped': 'test-scoped',
'filter-shard': 'filter-shard',
'merge-coverage': 'merge-coverage',
'select-e2e': 'select-e2e',
};
interface FlagHandler {
@ -207,6 +217,21 @@ const VALUE_FLAG_HANDLERS: Record<string, (options: CliOptions, value: string) =
'--url=': (opts, value) => {
opts.url = value;
},
'--inputs-dir=': (opts, value) => {
opts.inputsDir = value;
},
'--out-lcov=': (opts, value) => {
opts.outLcov = value;
},
'--out-map=': (opts, value) => {
opts.outMap = value;
},
'--map=': (opts, value) => {
opts.mapFile = value;
},
'--all-specs=': (opts, value) => {
opts.allSpecsFile = value;
},
};
function createDefaultOptions(): CliOptions {

View File

@ -0,0 +1,409 @@
import fc from 'fast-check';
import { describe, expect, it } from 'vitest';
import {
type ChangedFile,
type ImpactMap,
type LcovInput,
decodeImpactMap,
encodeImpactMap,
mergeCoverage,
parseLcov,
resolveImpact,
} from './coverage-map.js';
// ---------------------------------------------------------------------------
// Model + generators
//
// A test "execution" = one spec running one function (file, fnLine) some number
// of times. hits === 0 models a function that was LOADED but not executed (must
// NOT be attributed). We build per-spec lcovs from a list of executions and
// assert the map/selector against that ground truth.
// ---------------------------------------------------------------------------
interface Exec {
spec: string;
file: string;
fnLine: number;
hits: number;
}
const fnName = (line: number) => `fn${line}`;
/** Bounded domains so specs/files/functions overlap — exercises merge + dedup. */
const arbExec: fc.Arbitrary<Exec> = fc.record({
spec: fc.integer({ min: 0, max: 4 }).map((n) => `tests/e2e/s${n}.spec.ts`),
file: fc.integer({ min: 0, max: 3 }).map((n) => `packages/p${n}/src/f.ts`),
fnLine: fc.integer({ min: 1, max: 6 }).map((n) => n * 10), // 10,20,...,60
hits: fc.nat({ max: 5 }),
});
// At most one exec per (spec, file, fnLine) so each per-spec lcov is well-formed.
const arbExecs: fc.Arbitrary<Exec[]> = fc.uniqueArray(arbExec, {
maxLength: 30,
selector: (e) => `${e.spec}|${e.file}|${e.fnLine}`,
});
/** Build per-spec lcov inputs from executions (one TN per spec, one SF per file). */
function buildInputs(execs: Exec[]): LcovInput[] {
const bySpec = new Map<string, Map<string, Exec[]>>();
for (const e of execs) {
const byFile = bySpec.get(e.spec) ?? new Map<string, Exec[]>();
bySpec.set(e.spec, byFile);
byFile.set(e.file, [...(byFile.get(e.file) ?? []), e]);
}
const inputs: LcovInput[] = [];
for (const [spec, byFile] of bySpec) {
const lines: string[] = [`TN:${spec}`];
for (const [file, fns] of byFile) {
lines.push(`SF:${file}`);
for (const f of fns) lines.push(`FN:${f.fnLine},${fnName(f.fnLine)}`);
for (const f of fns) lines.push(`FNDA:${f.hits},${fnName(f.fnLine)}`);
for (const f of fns) lines.push(`DA:${f.fnLine},${f.hits}`);
lines.push('end_of_record');
}
inputs.push({ text: lines.join('\n') + '\n', spec });
}
return inputs;
}
const shuffle = <T>(xs: T[], keys: number[]): T[] =>
xs
.map((x, i) => [x, keys[i] ?? 0] as const)
.sort((a, b) => a[1] - b[1])
.map(([x]) => x);
// ===========================================================================
// Unit tests — concrete, readable contracts
// ===========================================================================
describe('parseLcov', () => {
it('parses TN/SF/FN/FNDA/DA into per-spec records', () => {
const recs = parseLcov(
'TN:spec-a\nSF:packages/cli/src/x.ts\nFN:5,foo\nFNDA:3,foo\nDA:5,3\nend_of_record\n',
);
expect(recs).toHaveLength(1);
expect(recs[0]).toMatchObject({ spec: 'spec-a', file: 'packages/cli/src/x.ts' });
expect(recs[0].fns).toEqual([{ name: 'foo', line: 5, hits: 3 }]);
});
it('falls back to fallbackSpec when TN is absent or empty', () => {
const recs = parseLcov('SF:a.ts\nFN:1,f\nFNDA:1,f\nend_of_record\n', 'fallback');
expect(recs[0].spec).toBe('fallback');
});
});
describe('mergeCoverage', () => {
it('attributes a function to every spec that executed it (hits > 0)', () => {
const { impactMap } = mergeCoverage([
{ spec: 'A', text: 'TN:A\nSF:f.ts\nFN:10,fn\nFNDA:2,fn\nend_of_record\n' },
{ spec: 'B', text: 'TN:B\nSF:f.ts\nFN:10,fn\nFNDA:1,fn\nend_of_record\n' },
]);
expect(impactMap['f.ts']['10']).toEqual(['A', 'B']);
});
it('excludes load-only functions (hits === 0) from the map', () => {
const { impactMap } = mergeCoverage([
{
spec: 'A',
text: 'TN:A\nSF:f.ts\nFN:10,hit\nFN:20,loaded\nFNDA:4,hit\nFNDA:0,loaded\nend_of_record\n',
},
]);
expect(impactMap['f.ts']['10']).toEqual(['A']);
expect(impactMap['f.ts']['20']).toBeUndefined();
});
it('sums hit counts across specs in the unified lcov', () => {
const { lcov } = mergeCoverage([
{ spec: 'A', text: 'TN:A\nSF:f.ts\nFN:10,fn\nFNDA:2,fn\nDA:10,2\nend_of_record\n' },
{ spec: 'B', text: 'TN:B\nSF:f.ts\nFN:10,fn\nFNDA:3,fn\nDA:10,3\nend_of_record\n' },
]);
expect(lcov).toContain('FNDA:5,fn');
expect(lcov).toContain('DA:10,5');
});
});
describe('resolveImpact', () => {
const map: ImpactMap = {
'packages/cli/src/x.ts': { '10': ['spec-a'], '30': ['spec-b'] },
};
it('selects only the specs for the function a changed line falls into', () => {
expect(resolveImpact([{ file: 'packages/cli/src/x.ts', lines: [12] }], map).specs).toEqual([
'spec-a',
]);
expect(resolveImpact([{ file: 'packages/cli/src/x.ts', lines: [35] }], map).specs).toEqual([
'spec-b',
]);
});
it('selects all of a files specs when no lines are given', () => {
expect(resolveImpact([{ file: 'packages/cli/src/x.ts' }], map).specs).toEqual([
'spec-a',
'spec-b',
]);
});
it('treats a change above the first function (top-level) as whole-file', () => {
const r = resolveImpact([{ file: 'packages/cli/src/x.ts', lines: [1] }], map);
expect(r.specs).toEqual(['spec-a', 'spec-b']);
});
it('forces broad mode for an unmapped file (the safety valve)', () => {
const r = resolveImpact([{ file: 'packages/new/src/new.ts', lines: [3] }], map, {
allSpecs: ['spec-a', 'spec-b', 'spec-c'],
});
expect(r.mode).toBe('broad');
expect(r.unmapped).toEqual(['packages/new/src/new.ts']);
expect(r.specs).toEqual(['spec-a', 'spec-b', 'spec-c']);
});
});
// ===========================================================================
// Property + metamorphic tests — the soundness guarantee
// ===========================================================================
describe('mergeCoverage — properties', () => {
it('SOUNDNESS: every executed (spec, function) appears in the map', () => {
fc.assert(
fc.property(arbExecs, (execs) => {
const { impactMap } = mergeCoverage(buildInputs(execs));
for (const e of execs) {
if (e.hits > 0) {
expect(impactMap[e.file]?.[String(e.fnLine)] ?? []).toContain(e.spec);
}
}
}),
);
});
it('NO PHANTOMS: a spec is not attributed to a function it only loaded (hits 0)', () => {
fc.assert(
fc.property(arbExecs, (execs) => {
const { impactMap } = mergeCoverage(buildInputs(execs));
for (const e of execs) {
if (e.hits === 0) {
// (spec,file,fnLine) is unique per execs, so no hits>0 record revives it.
expect(impactMap[e.file]?.[String(e.fnLine)] ?? []).not.toContain(e.spec);
}
}
}),
);
});
it('ORDER-INDEPENDENT: merge result does not depend on input order', () => {
fc.assert(
fc.property(arbExecs, fc.array(fc.nat(), { maxLength: 40 }), (execs, keys) => {
const inputs = buildInputs(execs);
const a = mergeCoverage(inputs);
const b = mergeCoverage(shuffle(inputs, keys));
expect(b.impactMap).toEqual(a.impactMap);
expect(b.lcov).toEqual(a.lcov);
}),
);
});
it('MONOTONIC: adding coverage never removes a map entry', () => {
fc.assert(
fc.property(arbExecs, arbExecs, (a, b) => {
const mapA = mergeCoverage(buildInputs(a)).impactMap;
const mapAB = mergeCoverage([...buildInputs(a), ...buildInputs(b)]).impactMap;
for (const file of Object.keys(mapA)) {
for (const line of Object.keys(mapA[file])) {
for (const spec of mapA[file][line]) {
expect(mapAB[file]?.[line] ?? []).toContain(spec);
}
}
}
}),
);
});
it('IDEMPOTENT: merging duplicated inputs yields the same map', () => {
fc.assert(
fc.property(arbExecs, (execs) => {
const inputs = buildInputs(execs);
expect(mergeCoverage([...inputs, ...inputs]).impactMap).toEqual(
mergeCoverage(inputs).impactMap,
);
}),
);
});
});
describe('resolveImpact — properties', () => {
it('SOUNDNESS: a change to an executed function selects every spec that ran it', () => {
fc.assert(
fc.property(arbExecs, (execs) => {
const { impactMap } = mergeCoverage(buildInputs(execs));
for (const e of execs) {
if (e.hits > 0) {
const r = resolveImpact([{ file: e.file, lines: [e.fnLine] }], impactMap);
expect(r.specs).toContain(e.spec);
}
}
}),
);
});
it('LINE-PRECISE ⊆ FILE-LEVEL: pinning a line never selects more than the whole file', () => {
fc.assert(
fc.property(arbExecs, fc.integer({ min: 1, max: 60 }), (execs, line) => {
const { impactMap } = mergeCoverage(buildInputs(execs));
const files = Object.keys(impactMap);
if (files.length === 0) return;
const file = files[0];
const precise = new Set(resolveImpact([{ file, lines: [line] }], impactMap).specs);
const whole = new Set(resolveImpact([{ file }], impactMap).specs);
for (const s of precise) expect(whole).toContain(s);
}),
);
});
it('MONOTONIC: resolving more changed files never selects fewer specs', () => {
fc.assert(
fc.property(arbExecs, (execs) => {
const { impactMap } = mergeCoverage(buildInputs(execs));
const files = Object.keys(impactMap);
if (files.length < 2) return;
const a: ChangedFile[] = [{ file: files[0] }];
const ab: ChangedFile[] = [{ file: files[0] }, { file: files[1] }];
const specsA = new Set(resolveImpact(a, impactMap).specs);
const specsAB = new Set(resolveImpact(ab, impactMap).specs);
for (const s of specsA) expect(specsAB).toContain(s);
}),
);
});
it('DEFAULT-BROAD: any unmapped changed file forces the full spec set', () => {
fc.assert(
fc.property(
arbExecs,
fc.array(fc.string(), { minLength: 1, maxLength: 5 }),
(execs, allSpecs) => {
const { impactMap } = mergeCoverage(buildInputs(execs));
const r = resolveImpact([{ file: 'packages/never/covered.ts' }], impactMap, { allSpecs });
expect(r.mode).toBe('broad');
for (const s of allSpecs) expect(r.specs).toContain(s);
},
),
);
});
it('R9: lines:[] (empty array) resolves to whole-file, not empty', () => {
const map: ImpactMap = { 'f.ts': { '10': ['A'], '20': ['B'] } };
// Distinct from undefined; must not silently select nothing (under-selection).
expect(resolveImpact([{ file: 'f.ts', lines: [] }], map).specs).toEqual(['A', 'B']);
});
});
// ===========================================================================
// Serializer + parser hardening — the serializer is exercised by every merge
// but the algebra properties use mergeCoverage as their own oracle, so a
// wrong-but-deterministic serialization slips through. These pin it externally.
// ===========================================================================
describe('serializeLcov (via mergeCoverage.lcov)', () => {
it('SERIALIZE-CORRECT: parse∘serialize round-trips FN/FNDA/DA hits', () => {
fc.assert(
fc.property(arbExecs, (execs) => {
// Expected summed hits per (file, line) from the ground truth.
const expected = new Map<string, Map<number, number>>();
for (const e of execs) {
const f = expected.get(e.file) ?? new Map<number, number>();
expected.set(e.file, f);
f.set(e.fnLine, (f.get(e.fnLine) ?? 0) + e.hits);
}
const { lcov } = mergeCoverage(buildInputs(execs));
for (const rec of parseLcov(lcov)) {
const exp = expected.get(rec.file);
if (!exp) continue;
for (const fn of rec.fns) expect(fn.hits).toBe(exp.get(fn.line) ?? 0);
for (const ln of rec.lines) expect(ln.hits).toBe(exp.get(ln.line) ?? 0);
}
}),
);
});
it('emits FN/DA sorted by line and correct FNF/FNH/LF/LH', () => {
const { lcov } = mergeCoverage([
{
spec: 'A',
text: 'TN:A\nSF:f.ts\nFN:30,c\nFN:10,a\nFN:20,b\nFNDA:0,c\nFNDA:5,a\nFNDA:2,b\nDA:30,0\nDA:10,5\nDA:20,2\nend_of_record\n',
},
]);
const lines = lcov.split('\n');
// Sort order is asserted externally — the order-independence property can't
// catch a wrong comparator (it compares the serializer against itself).
expect(lines.filter((l) => l.startsWith('FN:'))).toEqual(['FN:10,a', 'FN:20,b', 'FN:30,c']);
expect(lines.filter((l) => l.startsWith('DA:'))).toEqual(['DA:10,5', 'DA:20,2', 'DA:30,0']);
expect(lcov).toContain('FNF:3'); // 3 functions found
expect(lcov).toContain('FNH:2'); // 2 hit (a,b; c has 0)
expect(lcov).toContain('LF:3');
expect(lcov).toContain('LH:2');
});
});
describe('encode/decode impact map (interned on-disk form)', () => {
it('LOSSLESS: decode∘encode round-trips any impact map', () => {
fc.assert(
fc.property(arbExecs, (execs) => {
const { impactMap } = mergeCoverage(buildInputs(execs));
expect(decodeImpactMap(encodeImpactMap(impactMap))).toEqual(impactMap);
}),
);
});
it('interns each spec path exactly once', () => {
const { impactMap } = mergeCoverage([
{ spec: 'A', text: 'TN:A\nSF:f.ts\nFN:10,a\nFN:20,b\nFNDA:1,a\nFNDA:1,b\nend_of_record\n' },
]);
const enc = encodeImpactMap(impactMap);
expect(enc.specs).toEqual(['A']); // one entry despite two functions
expect(enc.files['f.ts']['10']).toEqual([0]);
expect(enc.files['f.ts']['20']).toEqual([0]);
});
});
describe('parseLcov hardening', () => {
it('P5: never throws on arbitrary or malformed text', () => {
fc.assert(
fc.property(fc.string(), (text) => {
parseLcov(text); // must not throw
}),
);
fc.assert(
fc.property(
fc.array(
fc.constantFrom(
'TN:',
'TN:x',
'SF:f.ts',
'FN:bad',
'FN:1,n',
'FNDA:',
'FNDA:x,n',
'DA:',
'DA:,',
'end_of_record',
'',
'noise',
),
{ maxLength: 40 },
),
(lines) => {
parseLcov(lines.join('\n'));
},
),
);
});
it('P4: FNDA without a matching FN → line 0, and fnLine resets per SF (no bleed)', () => {
const recs = parseLcov(
'SF:a.ts\nFN:5,shared\nFNDA:1,shared\nend_of_record\nSF:b.ts\nFNDA:2,shared\nend_of_record\n',
);
const b = recs.find((r) => r.file === 'b.ts')!;
// `shared` is at line 5 in a.ts; b.ts has no FN for it → line 0, not 5.
expect(b.fns).toEqual([{ name: 'shared', line: 0, hits: 2 }]);
});
});

View File

@ -0,0 +1,293 @@
/**
* Coverage impact map the substrate for E2E test selection.
*
* Pure functions (no I/O) so they can be exhaustively unit/property tested:
* parseLcov lcov text per-spec coverage records
* mergeCoverage per-spec lcovs unified lcov + bidirectional impact map
* resolveImpact changed files (± lines) the E2E specs that must run
*
* SOUNDNESS is the contract everything is tested against: the selected set must
* be a SUPERSET of the truly-affected set. Under-selection silently drops a real
* regression; over-selection only wastes CI. So resolveImpact errs broad an
* unmapped (new/never-covered) file forces the full set, never an empty one.
*
* Attribution is keyed on the SPEC that exercised the code (lcov `TN:`), so a
* git diff resolves straight to the spec files that cover the touched functions.
*/
export interface FileCoverage {
/** function name → { definition line, summed hit count } */
fns: Map<string, { line: number; hits: number }>;
/** source line → summed hit count */
lines: Map<number, number>;
}
/** file → function-start-line (as string) → specs that executed that function */
export type ImpactMap = Record<string, Record<string, string[]>>;
/**
* On-disk form of {@link ImpactMap}: spec paths are interned into `specs` and
* referenced by index. Lossless {@link decodeImpactMap} reconstructs the
* ImpactMap exactly. Spec paths repeat across thousands of (file, line) entries,
* so interning shrinks the artifact ~10x with no loss of the function-level
* detail that line-precise selection and cross-layer overlap analysis need.
*/
export interface InternedImpactMap {
specs: string[];
files: Record<string, Record<string, number[]>>;
}
/** One per-spec lcov and the spec it belongs to (used when records carry no `TN:`). */
export interface LcovInput {
text: string;
spec: string;
}
export interface MergeResult {
lcov: string;
impactMap: ImpactMap;
stats: { files: number; functions: number; lines: number; specs: number; mapEntries: number };
}
interface LcovRecord {
spec: string;
file: string;
fns: Array<{ name: string; line: number; hits: number }>;
lines: Array<{ line: number; hits: number }>;
}
/**
* Parse lcov text into per-spec records. A record's spec is its `TN:` if present
* and non-empty, else `fallbackSpec`. Supports many `SF:` records per text.
*/
export function parseLcov(text: string, fallbackSpec = ''): LcovRecord[] {
const records: LcovRecord[] = [];
let spec = fallbackSpec;
let current: LcovRecord | null = null;
const fnLine = new Map<string, number>();
for (const raw of text.split('\n')) {
if (raw.startsWith('TN:')) {
const tn = raw.slice(3).trim();
spec = tn || fallbackSpec;
} else if (raw.startsWith('SF:')) {
fnLine.clear();
current = { spec, file: raw.slice(3).trim(), fns: [], lines: [] };
} else if (!current) {
continue;
} else if (raw.startsWith('FN:')) {
const idx = raw.indexOf(',');
const line = Number(raw.slice(3, idx));
const name = raw.slice(idx + 1);
fnLine.set(name, line);
} else if (raw.startsWith('FNDA:')) {
const idx = raw.indexOf(',');
const hits = Number(raw.slice(5, idx));
const name = raw.slice(idx + 1);
current.fns.push({ name, line: fnLine.get(name) ?? 0, hits });
} else if (raw.startsWith('DA:')) {
const [line, hits] = raw.slice(3).split(',');
current.lines.push({ line: Number(line), hits: Number(hits) });
} else if (raw.startsWith('end_of_record')) {
records.push(current);
current = null;
}
}
if (current) records.push(current);
return records;
}
/**
* Merge per-spec lcovs into one unified lcov + the impact map. Hit counts sum;
* the map records, per function, the SET of specs that executed it (hits > 0).
* Output is deterministic (sorted) so the merge is order-independent.
*/
export function mergeCoverage(inputs: LcovInput[]): MergeResult {
const files = new Map<string, FileCoverage>();
const funcToSpecs = new Map<string, Set<string>>();
const allSpecs = new Set<string>();
const getFile = (sf: string): FileCoverage => {
let f = files.get(sf);
if (!f) files.set(sf, (f = { fns: new Map(), lines: new Map() }));
return f;
};
for (const input of inputs) {
for (const rec of parseLcov(input.text, input.spec)) {
allSpecs.add(rec.spec);
const f = getFile(rec.file);
for (const fn of rec.fns) {
const cur = f.fns.get(fn.name) ?? { line: fn.line, hits: 0 };
cur.hits += fn.hits;
f.fns.set(fn.name, cur);
if (fn.hits > 0) {
const key = `${rec.file}#${fn.line}`;
let specs = funcToSpecs.get(key);
if (!specs) funcToSpecs.set(key, (specs = new Set()));
specs.add(rec.spec);
}
}
for (const ln of rec.lines) {
f.lines.set(ln.line, (f.lines.get(ln.line) ?? 0) + ln.hits);
}
}
}
return {
lcov: serializeLcov(files),
impactMap: buildImpactMap(funcToSpecs),
stats: {
files: files.size,
functions: [...files.values()].reduce((n, f) => n + f.fns.size, 0),
lines: [...files.values()].reduce((n, f) => n + f.lines.size, 0),
specs: allSpecs.size,
mapEntries: funcToSpecs.size,
},
};
}
function serializeLcov(files: Map<string, FileCoverage>): string {
const out: string[] = [];
for (const sf of [...files.keys()].sort()) {
const { fns, lines } = files.get(sf)!;
out.push('TN:', `SF:${sf}`);
const fnEntries = [...fns.entries()].sort(
(a, b) => a[1].line - b[1].line || a[0].localeCompare(b[0]),
);
for (const [name, { line }] of fnEntries) out.push(`FN:${line},${name}`);
let fnh = 0;
for (const [name, { hits }] of fnEntries) {
out.push(`FNDA:${hits},${name}`);
if (hits > 0) fnh++;
}
out.push(`FNF:${fns.size}`, `FNH:${fnh}`);
let lh = 0;
for (const [line, hits] of [...lines.entries()].sort((a, b) => a[0] - b[0])) {
out.push(`DA:${line},${hits}`);
if (hits > 0) lh++;
}
out.push(`LF:${lines.size}`, `LH:${lh}`, 'end_of_record');
}
return out.join('\n') + (out.length ? '\n' : '');
}
function buildImpactMap(funcToSpecs: Map<string, Set<string>>): ImpactMap {
const map: ImpactMap = {};
for (const [key, specs] of funcToSpecs) {
const hash = key.lastIndexOf('#');
const file = key.slice(0, hash);
const line = key.slice(hash + 1);
(map[file] ??= {})[line] = [...specs].sort();
}
return map;
}
/** Intern spec paths into an index — the lossless on-disk form (see {@link InternedImpactMap}). */
export function encodeImpactMap(map: ImpactMap): InternedImpactMap {
const index = new Map<string, number>();
const specs: string[] = [];
const idOf = (s: string): number => {
let i = index.get(s);
if (i === undefined) index.set(s, (i = specs.push(s) - 1));
return i;
};
const files: InternedImpactMap['files'] = {};
for (const file of Object.keys(map)) {
files[file] = {};
for (const line of Object.keys(map[file])) files[file][line] = map[file][line].map(idOf);
}
return { specs, files };
}
/** Expand an {@link InternedImpactMap} back to a full {@link ImpactMap}. */
export function decodeImpactMap(interned: InternedImpactMap): ImpactMap {
const { specs, files } = interned;
const map: ImpactMap = {};
for (const file of Object.keys(files)) {
map[file] = {};
for (const line of Object.keys(files[file]))
map[file][line] = files[file][line].map((i) => specs[i]);
}
return map;
}
export interface ChangedFile {
file: string;
/** Changed source line numbers. Omit for file-level (whole-file) resolution. */
lines?: number[];
}
export interface ResolveResult {
/** Specs that must run. In 'broad' mode this is `allSpecs` if supplied. */
specs: string[];
/** Changed files absent from the map (new/renamed/never-covered) → force broad. */
unmapped: string[];
mode: 'scoped' | 'broad';
}
/**
* Resolve changed files to the specs that must run.
*
* - A file in the map with changed `lines` specs for the functions those lines
* fall into (a line belongs to the function with the greatest start line).
* Lines above the first function (imports / module top-level) can't be pinned
* to one function, so they conservatively select ALL specs covering the file.
* - A file in the map without `lines` all specs covering the file.
* - A file NOT in the map `unmapped` mode 'broad' (run everything). This is
* the safety valve that keeps selection sound for new/never-covered code.
*
* SEAM CONTRACT (line precision): the map only records EXECUTED functions, so it
* cannot see an un-executed function sitting between two covered ones. A changed
* line inside such a gap is attributed to the nearest preceding covered function
* which can UNDER-SELECT if that region is in truth covered by other specs not
* in this (possibly partial/stale) map. Line precision is therefore sound only
* when the map is known function-complete for the file. The default-safe usage
* and what the CLI does is FILE-LEVEL (omit `lines`): a changed file selects
* every spec covering any part of it. Treat `lines` as an opt-in optimisation.
*/
export function resolveImpact(
changed: ChangedFile[],
map: ImpactMap,
opts: { allSpecs?: string[] } = {},
): ResolveResult {
const specs = new Set<string>();
const unmapped: string[] = [];
for (const { file, lines } of changed) {
const fileMap = map[file];
if (!fileMap) {
unmapped.push(file);
continue;
}
const fnStarts = Object.keys(fileMap)
.map(Number)
.sort((a, b) => a - b);
const addFile = () => {
for (const fnLine of fnStarts) for (const s of fileMap[String(fnLine)]) specs.add(s);
};
if (!lines || lines.length === 0) {
addFile();
continue;
}
for (const line of lines) {
// The function containing `line` is the one with the greatest start ≤ line.
let owner = -1;
for (const start of fnStarts) {
if (start <= line) owner = start;
else break;
}
if (owner === -1) {
// Above the first function (top-level/import change) → whole file.
addFile();
break;
}
for (const s of fileMap[String(owner)]) specs.add(s);
}
}
const mode = unmapped.length > 0 ? 'broad' : 'scoped';
if (mode === 'broad' && opts.allSpecs) {
for (const s of opts.allSpecs) specs.add(s);
}
return { specs: [...specs].sort(), unmapped, mode };
}

View File

@ -160,6 +160,15 @@ describe('TcrExecutor', () => {
'export const test = { name: "staged feature test" };\n',
);
// A real `tsc` isn't resolvable in this ephemeral temp dir (no
// node_modules), and this test exercises affected-test detection, not
// typechecking — stub the typecheck step to a deterministic pass so
// run() reaches findAffectedTests instead of bailing at typecheck.
fs.writeFileSync(
path.join(tempDir, 'package.json'),
JSON.stringify({ name: 'tcr-test', scripts: { typecheck: 'node -e ""' } }),
);
// Stage the new file
execSync('git add -A', { cwd: tempDir, stdio: 'pipe' });

View File

@ -5,6 +5,12 @@ export default defineConfig({
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
// Many suites here are integration tests that spin up a real git repo and
// a ts-morph Project per case — ~ms locally but 615s on loaded CI runners,
// past vitest's 5s default. Raise the package-wide timeout so these don't
// flake; fast unit tests finish well under it regardless.
testTimeout: 30_000,
hookTimeout: 30_000,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],

View File

@ -0,0 +1,57 @@
import type { CoverageReportOptions } from 'monocart-coverage-reports';
/**
* Frontend E2E coverage via browser-native V8 (Playwright `page.coverage`),
* converted to lcov/html by monocart-coverage-reports. Replaces the previous
* istanbul build-time instrumentation (`vite-plugin-istanbul`) no special
* build is needed, V8 collects from the normal bundle at runtime.
*
* Gated on COVERAGE_ENABLED so normal test runs pay no collection cost.
*/
export const COVERAGE_ENABLED = process.env.COVERAGE_ENABLED === 'true';
/**
* Shared by the per-test collector (`add`) and the report generator
* (`generate`) so both read/write the same outputDir + raw cache.
*/
export const coverageOptions: CoverageReportOptions = {
name: 'n8n E2E Coverage (V8)',
outputDir: './coverage',
// 'v8' = interactive HTML; 'lcovonly' = lcov.info for Codecov; summary to stdout.
reports: ['v8', 'lcovonly', 'console-summary'],
// Frontend: keep app bundles served by n8n. Backend: keep n8n's own
// packages (the collect step rewrites their urls to repo dist paths).
entryFilter: (entry) =>
entry.url.includes('/assets/') || /\/packages\/[^/]+(?:\/[^/]+)?\/dist\//.test(entry.url),
// Keep first-party application source after source-map expansion; drop deps
// and any unmapped dist files. NB nodes-base sources live under `nodes/`,
// `credentials/` etc — not `src/` — so don't require `/src/`.
sourceFilter: (sourcePath) =>
!sourcePath.includes('node_modules') && !sourcePath.includes('/dist/'),
// Key Codecov + the impact map on repo-relative `packages/.../src/...`.
// Backend map-sources resolve to absolute repo paths (package-qualified);
// the frontend bundle resolves relative, so its `src/...` sources have no
// package and are attributed to editor-ui.
sourcePath: (filePath) => {
const norm = filePath.replace(/\\/g, '/');
if (norm.startsWith('packages/')) return norm;
const i = norm.lastIndexOf('packages/');
if (i >= 0) return norm.slice(i);
return norm.startsWith('src/') ? `packages/frontend/editor-ui/${norm}` : `packages/${norm}`;
},
};
/**
* Directory for per-test (per-spec) raw coverage, used to build the impact map.
*
* It MUST be a sibling of `outputDir`, never inside it: the shard report's
* `CoverageReport.generate()` cleans its own `outputDir` (deleting every child
* except `.cache`/the V8 dir), so per-spec raw kept under `outputDir` is wiped
* before the emitter reads it. Single source of truth for the fixture (writes),
* the emitter (reads), and the drift guard test. See coverage-pipeline.test.ts.
*/
export function bySpecDir(outputDir: string = coverageOptions.outputDir ?? './coverage'): string {
return `${outputDir.replace(/\/+$/, '')}-by-spec`;
}
export const BY_SPEC_DIR = bySpecDir();

View File

@ -3,9 +3,8 @@ import type { CurrentsConfig } from '@currents/playwright';
const config: CurrentsConfig = {
recordKey: process.env.CURRENTS_RECORD_KEY ?? '',
projectId: process.env.CURRENTS_PROJECT_ID ?? 'LRxcNt',
coverage: {
projects: ['coverage'],
},
// Coverage is collected via browser-native V8 (fixtures/v8-coverage.ts +
// monocart-coverage-reports), not Currents' istanbul fixture.
};
// eslint-disable-next-line import-x/no-default-export

View File

@ -15,6 +15,7 @@ import {
type QuarantineTestFixtures,
type QuarantineWorkerFixtures,
} from '../fixtures/quarantine';
import { v8CoverageFixtures } from '../fixtures/v8-coverage';
import { n8nPage } from '../pages/n8nPage';
import { ApiHelpers } from '../services/api-helper';
import { TestError, type TestRequirements } from '../Types';
@ -79,7 +80,7 @@ export const test = base.extend<
WorkerFixtures & CurrentsWorkerFixtures & QuarantineWorkerFixtures
>({
...currentsFixtures.baseFixtures,
...currentsFixtures.coverageFixtures,
...v8CoverageFixtures,
...currentsFixtures.actionFixtures,
...observabilityFixtures,
...consoleErrorFixtures,
@ -113,6 +114,9 @@ export const test = base.extend<
E2E_TESTS: 'true',
N8N_RESTRICT_FILE_ACCESS_TO: '',
},
// Coverage pipeline opt-in: when the coverage runner sets N8N_COVERAGE_DIR,
// bridge it to the stack's typed config so containers collect V8 coverage.
...(process.env.N8N_COVERAGE_DIR ? { coverageHostDir: process.env.N8N_COVERAGE_DIR } : {}),
};
await use(config);

View File

@ -0,0 +1,89 @@
import type { BrowserContext, Page, TestInfo } from '@playwright/test';
import { CoverageReport } from 'monocart-coverage-reports';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import { BY_SPEC_DIR, coverageOptions, COVERAGE_ENABLED } from '../coverage-options';
/** Spec id = project-relative path (e.g. tests/e2e/nodes/if-node.spec.ts) the
* same id the runner uses, so the impact map keys match runnable specs. */
function specId(testInfo: TestInfo): string {
return relative(process.cwd(), testInfo.file).split('\\').join('/');
}
const slugify = (spec: string) => spec.replace(/[^a-zA-Z0-9]+/g, '_');
/**
* Browser-native V8 coverage collection (Chromium `page.coverage`), replacing
* the previous istanbul-instrumented build + Currents istanbul fixture.
*
* Wraps the BrowserContext: starts JS coverage on every page at creation
* (before navigation, so the initial bundle load is captured) and, on test
* teardown, hands each page's V8 coverage to monocart-coverage-reports.
*
* Coverage is accumulated TWICE: into the shared outputDir (the full shard
* report, frontend + backend) and into a PER-SPEC dir keyed by the spec file
* (`.by-spec/<slug>/`, with a `.spec` marker naming the spec). The per-spec
* data is what lets the impact map say "this spec exercised this function" for
* test selection. The extra cost is a second `add()` of data already in memory.
*
* No-op unless COVERAGE_ENABLED normal runs pay nothing.
*/
export const v8CoverageFixtures = {
context: async (
{ context }: { context: BrowserContext },
use: (context: BrowserContext) => Promise<void>,
testInfo: TestInfo,
) => {
if (!COVERAGE_ENABLED) {
await use(context);
return;
}
const tracked = new Set<Page>();
const startCoverage = async (page: Page) => {
try {
await page.coverage.startJSCoverage({ resetOnNavigation: false });
tracked.add(page);
} catch {
// Non-Chromium browser or coverage unavailable — skip silently.
}
};
context.on('page', (page) => {
startCoverage(page).catch(() => {});
});
await Promise.all(context.pages().map(startCoverage));
await use(context);
const sharedReport = new CoverageReport(coverageOptions);
// Capture this test's RAW page.coverage for the per-spec map, cloning each
// entry — MCR's add() mutates input in place, so the same object can't be
// fed to two reports. The emitter does the MCR work later from this raw.
const perSpecRaw: unknown[] = [];
for (const page of tracked) {
if (page.isClosed()) continue;
try {
const coverage = await page.coverage.stopJSCoverage();
if (coverage?.length) {
perSpecRaw.push(...coverage.map((entry) => structuredClone(entry)));
await sharedReport.add(coverage);
}
} catch {
// Page closed before collection — ignore.
}
}
if (perSpecRaw.length) {
const spec = specId(testInfo);
const specDir = join(BY_SPEC_DIR, slugify(spec));
mkdirSync(specDir, { recursive: true });
// Unique per test so multiple tests in one spec file accumulate (don't clobber).
writeFileSync(
join(specDir, `raw-${slugify(testInfo.testId)}.json`),
JSON.stringify(perSpecRaw),
);
writeFileSync(join(specDir, '.spec'), spec);
}
},
};

View File

@ -1,19 +0,0 @@
const config = {
reporter: ['html'],
reportDir: 'coverage',
tempDir: '.nyc_output',
include: [
'../../../packages/frontend/editor-ui/src/**/*.{js,ts,vue}',
'../../../packages/frontend/editor-ui/dist/**/*.{js,ts}',
],
exclude: [
'**/*.test.{js,ts}',
'**/*.spec.{js,ts}',
'**/node_modules/**',
'**/coverage/**',
'**/.nyc_output/**',
],
sourceMap: true,
};
export = config;

View File

@ -18,15 +18,18 @@
"test:container:queue": "playwright test --project='queue:*'",
"test:container:multi-main": "playwright test --project='multi-main:*'",
"test:container:multi-main:e2e": "playwright test --project='multi-main:e2e'",
"test:container:coverage": "playwright test --project=coverage",
"test:container:coverage": "COVERAGE_ENABLED=true N8N_COVERAGE_DIR=\"$PWD/coverage/.backend-v8\" playwright test --project=coverage",
"test:workflows:setup": "tsx ./tests/cli-workflows/setup-workflow-tests.ts",
"test:workflows": "playwright test --project=cli-workflows",
"test:workflows:schema": "SCHEMA=true playwright test --project=cli-workflows",
"test:workflows:update": "playwright test --project=cli-workflows --update-snapshots",
"test:evals": "playwright test --project=eval",
"test:evals:smoke": "playwright test --project=eval:smoke",
"coverage:report": "node scripts/generate-coverage-report.js",
"coverage:clean": "rm -rf coverage .nyc_output",
"test:unit": "vitest run",
"coverage:shard": "node scripts/run-coverage-shard.mjs",
"coverage:emit-shard": "tsx scripts/emit-shard-coverage.ts",
"coverage:emit-spec-lcovs": "tsx scripts/emit-spec-lcovs.ts",
"coverage:clean": "rm -rf coverage coverage-by-spec .v8-coverage .nyc_output",
"coverage:analyse": "node scripts/coverage-analysis.mjs",
"install-browsers": "PLAYWRIGHT_BROWSERS_PATH=./.playwright-browsers playwright install chromium --with-deps",
"browsers:uninstall": "playwright uninstall --all",
@ -69,10 +72,11 @@
"n8n-core": "workspace:*",
"n8n-workflow": "workspace:*",
"nanoid": "catalog:",
"nyc": "^17.1.0",
"monocart-coverage-reports": "^2.12.0",
"otplib": "^12.0.1",
"ts-morph": "catalog:",
"tsx": "catalog:",
"zod": "catalog:"
"zod": "catalog:",
"vitest": "catalog:"
}
}

View File

@ -0,0 +1,82 @@
#!/usr/bin/env node
// @ts-check
/**
* Aggregate downloaded shard artifacts into (1) a unified coverage report lcov
* and (2) the per-spec impact map, using the property-tested janitor
* `merge-coverage`. Replaces brittle inline `find`/`cp` bash in the workflows
* the janitor CLI path lives here, in one place, instead of per-YAML-string.
*
* node aggregate-coverage.mjs --shards=<dir> --out=<dir>
*/
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync, rmSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const JANITOR_CLI = path.resolve(__dirname, '..', '..', 'janitor', 'dist', 'cli.js');
const arg = (name, fallback) =>
process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? fallback;
const SHARDS = arg('shards', '/tmp/shards');
const OUT = arg('out', './coverage');
/** Recursively collect files under `dir` matching `predicate(filename)`. */
function findFiles(dir, predicate, acc = []) {
if (!existsSync(dir)) return acc;
for (const entry of readdirSync(dir)) {
const p = path.join(dir, entry);
if (statSync(p).isDirectory()) findFiles(p, predicate, acc);
else if (predicate(entry, p)) acc.push(p);
}
return acc;
}
/** Stage matching files into a fresh temp dir with unique names, return the dir. */
function stage(label, files) {
const dir = path.join('/tmp', `agg-${label}`);
rmSync(dir, { recursive: true, force: true });
mkdirSync(dir, { recursive: true });
files.forEach((f, i) => copyFileSync(f, path.join(dir, `${label}-${i}.lcov`)));
return dir;
}
function merge(inputsDir, outLcov, outMap) {
execFileSync(
'node',
[
JANITOR_CLI,
'merge-coverage',
`--inputs-dir=${inputsDir}`,
`--out-lcov=${outLcov}`,
`--out-map=${outMap}`,
],
{ stdio: 'inherit' },
);
}
mkdirSync(OUT, { recursive: true });
// 1. Report: shard-level lcovs (frontend + backend) → unified lcov for Codecov.
const shardLcovs = findFiles(SHARDS, (name) => name === 'lcov.info');
console.log(`Report: ${shardLcovs.length} shard lcov(s)`);
if (shardLcovs.length) {
merge(stage('report', shardLcovs), path.join(OUT, 'lcov.info'), '/tmp/agg-report-map.json');
} else {
console.warn(' ⚠ no shard lcov.info found — skipping report');
}
// 2. Impact map: per-spec frontend lcovs (TN-tagged) → spec-keyed map for selection.
const specLcovs = findFiles(
SHARDS,
(name, p) => name.endsWith('.lcov') && p.includes(`${path.sep}by-spec${path.sep}`),
);
console.log(`Impact map: ${specLcovs.length} per-spec lcov(s)`);
if (specLcovs.length) {
merge(stage('spec', specLcovs), '/tmp/agg-spec-fe.lcov', path.join(OUT, 'impact-map.json'));
} else {
console.warn(' ⚠ no per-spec lcovs found — impact map not built');
}

View File

@ -0,0 +1,62 @@
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { CoverageReport } from 'monocart-coverage-reports';
import { describe, expect, it } from 'vitest';
import { BY_SPEC_DIR, bySpecDir, coverageOptions } from '../coverage-options';
/** Is `child` the same as, or nested under, `parent`? */
const isUnder = (child: string, parent: string): boolean => {
const c = resolve(child);
const p = resolve(parent);
return c === p || c.startsWith(`${p}/`);
};
/**
* Regression guard for the per-spec coverage seam (DEVP-205).
*
* Bug: the fixture wrote per-spec raw under `./coverage/.by-spec`, but the shard
* report's `CoverageReport.generate()` cleans its `outputDir` (deleting every
* child except `.cache`/the V8 dir) so the per-spec raw was wiped before the
* emitter read it, and the impact map came out empty. The fix puts per-spec raw
* in a SIBLING of `outputDir`. These tests fail if it ever drifts back inside.
*/
describe('per-spec coverage dir does not drift under the report outputDir', () => {
it('bySpecDir is OUTSIDE the report outputDir for any outputDir', () => {
expect(isUnder(BY_SPEC_DIR, coverageOptions.outputDir ?? './coverage')).toBe(false);
for (const out of ['./coverage', '/a/b/coverage', 'cov/', './nested/cov']) {
expect(isUnder(bySpecDir(out), out)).toBe(false);
}
});
it("the shard report's generate() does NOT delete the per-spec sibling dir", async () => {
const tmp = resolve('/tmp', `cov-drift-${process.pid}`);
rmSync(tmp, { recursive: true, force: true });
const outputDir = join(tmp, 'coverage');
const sibling = bySpecDir(outputDir);
mkdirSync(join(sibling, 'specA'), { recursive: true });
writeFileSync(join(sibling, 'specA', '.spec'), 'tests/e2e/x.spec.ts');
// Minimal real coverage so generate() produces a report and runs its clean
// (the clean is what deleted the per-spec dir when it lived under outputDir).
const report = new CoverageReport({ ...coverageOptions, outputDir });
await report.add([
{
url: 'http://host/assets/a.js',
source: 'function f(){ return 1; }\nf();\n',
functions: [
{
functionName: 'f',
isBlockCoverage: true,
ranges: [{ startOffset: 0, endOffset: 30, count: 1 }],
},
],
},
] as never);
await report.generate();
expect(existsSync(join(sibling, 'specA', '.spec'))).toBe(true);
rmSync(tmp, { recursive: true, force: true });
});
});

View File

@ -24,6 +24,13 @@ const JANITOR_CLI = path.resolve(__dirname, '..', '..', 'janitor', 'dist', 'cli.
const PLAYWRIGHT_PREFIX = path.relative(REPO_ROOT, PLAYWRIGHT_DIR) + path.sep;
const CONTAINER_STARTUP_TIME = 22_500; // 22.5s average per fixture
// Specs excluded from orchestrated distribution. Under coverage instrumentation
// (no container reuse + slower execution), high-flake specs balloon into
// multi-minute retry tails that tip a single shard over its timeout. Quarantine
// them here until the flakiness is fixed via the Flaky pipeline.
// tests/e2e/ai/hitl-for-tools.spec.ts — 34.2% flakyRate (2x next worst)
const QUARANTINE = new Set(['tests/e2e/ai/hitl-for-tools.spec.ts']);
const CAPABILITY_IMAGES = {
email: ['mailpit'],
kafka: ['kafka'],
@ -135,7 +142,7 @@ if (matrixMode) {
const matrix = result.shards.map((shard) => ({
shard: shard.shard,
specs: shard.specs.join(' '),
specs: shard.specs.filter((s) => !QUARANTINE.has(s)).join(' '),
images: getRequiredImages(shard.capabilities).join(' '),
}));
console.log(JSON.stringify(matrix));
@ -150,6 +157,6 @@ if (matrixMode) {
const result = getOrchestration(shards, { impact: impactMode, files: filesArg, base: baseArg });
const shard = result.shards[index];
if (shard) {
console.log(shard.specs.join('\n'));
console.log(shard.specs.filter((s) => !QUARANTINE.has(s)).join('\n'));
}
}

View File

@ -0,0 +1,185 @@
/**
* Shard-side coverage emitter (shard-side compaction, DEVP-205).
*
* Resolves THIS shard's own raw V8 to a small per-shard lcov, so shards upload
* ~0.4MB of lcov instead of ~5GB of raw, and the aggregate merges lcovs in
* bounded memory instead of OOMing on the full-suite raw merge.
*
* - Frontend: browser V8 already in outputDir/.cache (inline maps w/ sources)
* monocart resolves it with no extra inputs.
* - Backend: Node V8 from the containers (N8N_COVERAGE_DIR). The repo isn't
* built on the shard, so .js/.map BYTES are read from the n8n image's dist
* (docker cp'd to IMAGE_DIST_ROOT — the exact executed files), while the map's
* `sources` are resolved to the checkout's `packages/<x>/src/*.ts`. No build.
*
* Per-shard volume (~1/8 of the suite) fits in memory, so MCR's generate() is
* safe here; the OOM only happened when merging all shards at once.
*/
import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { CoverageReport } from 'monocart-coverage-reports';
import { coverageOptions } from '../coverage-options';
const REPO_ROOT = resolve(process.cwd(), '../../..');
// Where `docker cp` placed the image's n8n package tree, and the in-image root
// those urls are prefixed with (see docker/images/n8n/Dockerfile).
const IMAGE_DIST_ROOT = process.env.IMAGE_DIST_ROOT;
const IMAGE_ROOT_PREFIX = process.env.IMAGE_ROOT_PREFIX ?? '/usr/local/lib/node_modules/n8n';
/** Map workspace package name → repo dir (e.g. n8n-core → core, n8n → cli). */
function buildPackageMap(): Map<string, string> {
const map = new Map<string, string>();
const roots = [
join(REPO_ROOT, 'packages'),
join(REPO_ROOT, 'packages/@n8n'),
join(REPO_ROOT, 'packages/frontend'),
];
for (const root of roots) {
if (!existsSync(root)) continue;
for (const entry of readdirSync(root)) {
const pkgJson = join(root, entry, 'package.json');
if (!existsSync(pkgJson)) continue;
try {
const name = JSON.parse(readFileSync(pkgJson, 'utf8')).name as string;
if (name) map.set(name, join(root, entry).slice(REPO_ROOT.length + 1));
} catch {
// unreadable/ malformed package.json — skip
}
}
}
return map;
}
function listJsonFiles(dir: string): string[] {
const out: string[] = [];
for (const e of readdirSync(dir)) {
const p = join(dir, e);
if (statSync(p).isDirectory()) out.push(...listJsonFiles(p));
else if (e.endsWith('.json')) out.push(p);
}
return out;
}
/** A resolved backend entry: where to read .js/.map bytes, and the repo dist
* dir its map sources resolve against ( checkout `src/*.ts`). */
function resolveBackendUrl(url: string, pkgMap: Map<string, string>) {
const m = url.match(/\/node_modules\/((?:@[^/]+\/)?[^/]+)\/dist\/(.+)$/);
if (!m) return null;
const repoDir = pkgMap.get(m[1]);
if (!repoDir) return null;
const repoDistDir = join(REPO_ROOT, repoDir, 'dist');
const repoDistFile = join(repoDistDir, m[2]);
// Byte source: image dist on the shard (prefix-remap), or repo dist locally.
const bytesFile = IMAGE_DIST_ROOT
? url.replace(/^file:\/\//, '').replace(IMAGE_ROOT_PREFIX, IMAGE_DIST_ROOT)
: repoDistFile;
return { repoDistFile, bytesFile };
}
const stats = { entries: 0, noMatch: 0, noPkg: 0, noJs: 0, noMap: 0, ok: 0 };
async function addBackendCoverage(report: CoverageReport): Promise<number> {
const dir = process.env.N8N_COVERAGE_DIR;
if (!dir || !existsSync(dir)) return 0;
const pkgMap = buildPackageMap();
let added = 0;
for (const file of listJsonFiles(dir)) {
let parsed: { result?: Array<{ url: string }> };
try {
parsed = JSON.parse(readFileSync(file, 'utf8'));
} catch {
continue;
}
const entries = (parsed.result ?? [])
.map((e) => {
stats.entries++;
const r = resolveBackendUrl(e.url, pkgMap);
if (!r) {
if (/\/node_modules\/.+\/dist\//.test(e.url)) stats.noPkg++;
else stats.noMatch++;
return null;
}
let source: string;
try {
source = readFileSync(r.bytesFile, 'utf8');
} catch {
stats.noJs++;
return null;
}
try {
const map = JSON.parse(readFileSync(`${r.bytesFile}.map`, 'utf8')) as {
sources?: string[];
};
// Resolve map sources to the checkout's repo src/*.ts (absolute).
// Sources are relative to the .js.map's OWN dir (e.g. a file at
// dist/commands/x.js.map has `../../src/commands/x.ts`), so anchor
// at the dist file's dir — not the package dist root.
map.sources = (map.sources ?? []).map((s) => resolve(dirname(r.repoDistFile), s));
const b64 = Buffer.from(JSON.stringify(map)).toString('base64');
source =
source.replace(/\n?\/\/# sourceMappingURL=.*\s*$/, '\n') +
`//# sourceMappingURL=data:application/json;charset=utf-8;base64,${b64}\n`;
} catch {
stats.noMap++;
return null;
}
stats.ok++;
// Key on the repo dist path so entryFilter (/packages/.../dist/) matches.
return { ...e, url: pathToFileURL(r.repoDistFile).href, source };
})
.filter(Boolean);
if (entries.length) {
await report.add(entries as never);
added += entries.length;
}
}
return added;
}
/** Drop raw cache files truncated mid-write (a killed test/container) so MCR's
* generate() which hard-aborts on the first unparseable file survives. */
function pruneCorruptRaw(dir: string): number {
if (!existsSync(dir)) return 0;
let dropped = 0;
for (const file of listJsonFiles(dir)) {
try {
JSON.parse(readFileSync(file, 'utf8'));
} catch {
unlinkSync(file);
dropped++;
}
}
return dropped;
}
async function main() {
console.log('🔍 Emitting per-shard coverage lcov...');
if (IMAGE_DIST_ROOT) console.log(` image dist: ${IMAGE_DIST_ROOT}`);
const report = new CoverageReport(coverageOptions);
const dropped = pruneCorruptRaw(join(coverageOptions.outputDir ?? './coverage', '.cache'));
if (dropped) console.warn(` ⚠ dropped ${dropped} corrupt raw coverage file(s)`);
const backend = await addBackendCoverage(report);
console.log(
` backend entries: ${stats.entries} seen → ${stats.ok} resolved ` +
`(noPkg ${stats.noPkg}, noJs ${stats.noJs}, noMap ${stats.noMap}, other ${stats.noMatch})`,
);
const result = await report.generate();
if (!result || !result.files?.length) {
console.error('❌ No coverage data resolved for this shard.');
process.exit(1);
}
console.log(
`✅ Per-shard lcov written (${result.files.length} files, ${backend} backend entries)`,
);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@ -0,0 +1,69 @@
/**
* Resolve per-spec frontend coverage (raw page.coverage the v8-coverage fixture
* wrote under BY_SPEC_DIR) into one lcov per spec, tagged with the spec id in
* `TN:`. These feed the impact map, letting a git diff select the E2E specs that
* exercise the touched frontend code.
*
* Frontend only: backend coverage is a shared worker-scoped process with no
* per-test boundary, so it stays at report granularity. See DEVP-205.
*/
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { CoverageReport } from 'monocart-coverage-reports';
import { BY_SPEC_DIR, coverageOptions } from '../coverage-options';
const OUT_DIR = join(coverageOptions.outputDir ?? './coverage', 'by-spec');
async function main() {
if (!existsSync(BY_SPEC_DIR)) {
console.log(`emit-spec-lcovs: ${BY_SPEC_DIR} absent — no per-spec coverage collected`);
return;
}
mkdirSync(OUT_DIR, { recursive: true });
const dirs = readdirSync(BY_SPEC_DIR).filter((d) => statSync(join(BY_SPEC_DIR, d)).isDirectory());
let withMarker = 0;
let withRaw = 0;
let emitted = 0;
for (const slug of dirs) {
const dir = join(BY_SPEC_DIR, slug);
const specMarker = join(dir, '.spec');
if (!existsSync(specMarker)) continue;
withMarker++;
const spec = readFileSync(specMarker, 'utf8').trim();
const rawFiles = readdirSync(dir).filter((f) => f.startsWith('raw-') && f.endsWith('.json'));
if (!rawFiles.length) continue;
withRaw++;
const report = new CoverageReport({
...coverageOptions,
name: spec,
outputDir: dir,
reports: ['lcovonly'],
});
for (const rf of rawFiles) {
try {
await report.add(JSON.parse(readFileSync(join(dir, rf), 'utf8')));
} catch (error) {
console.warn(`${slug}/${rf}: ${String(error)}`);
}
}
const result = await report.generate();
const lcovPath = join(dir, 'lcov.info');
if (!result || !result.files?.length || !existsSync(lcovPath)) continue;
// Force every record's TN to the real spec id so the merge attributes it.
let lcov = readFileSync(lcovPath, 'utf8').replace(/^TN:.*$/gm, `TN:${spec}`);
if (!lcov.startsWith('TN:')) lcov = `TN:${spec}\n${lcov}`;
writeFileSync(join(OUT_DIR, `${slug}.lcov`), lcov);
emitted++;
}
console.log(
`emit-spec-lcovs: ${dirs.length} dirs, ${withMarker} with .spec, ${withRaw} with raw → ` +
`${emitted} per-spec lcov(s) in ${OUT_DIR}`,
);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@ -1,67 +0,0 @@
#!/usr/bin/env node
/**
* Simple script to merge coverage reports and generate HTML output
*/
const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const NYC_OUTPUT_DIR = path.join(__dirname, '..', '.nyc_output');
const COVERAGE_DIR = path.join(__dirname, '..', 'coverage');
const NYC_CONFIG = path.join(__dirname, '..', 'nyc.config.ts');
const COVERAGE_INPUT_DIR = path.join(NYC_OUTPUT_DIR, 'coverage');
function main() {
console.log('🔍 Generating Coverage Report');
console.log('==============================\n');
const jsonFiles = fs.existsSync(COVERAGE_INPUT_DIR)
? fs.readdirSync(COVERAGE_INPUT_DIR).filter((f) => f.endsWith('.json'))
: [];
if (jsonFiles.length === 0) {
console.error('❌ No coverage data found in .nyc_output/coverage/');
console.log('\nTo generate coverage data:');
console.log(
'1. Build editor-ui with coverage: BUILD_WITH_COVERAGE=true pnpm --filter n8n-editor-ui build',
);
console.log('2. Run Playwright tests with coverage: pnpm test:container:coverage');
process.exit(1);
}
console.log(`Found ${jsonFiles.length} coverage files in .nyc_output/coverage/\n`);
try {
const mergedFile = path.join(NYC_OUTPUT_DIR, 'out.json');
console.log('Merging coverage files...');
execSync(`npx nyc merge "${COVERAGE_INPUT_DIR}" "${mergedFile}"`, { stdio: 'inherit' });
console.log('Generating coverage reports...');
execSync(
`npx nyc report --reporter=html --reporter=lcov --report-dir=${COVERAGE_DIR} --temp-dir=${NYC_OUTPUT_DIR} --config=${NYC_CONFIG} --exclude-after-remap=false`,
{ stdio: 'inherit' },
);
const htmlReportPath = path.join(COVERAGE_DIR, 'index.html');
const lcovPath = path.join(COVERAGE_DIR, 'lcov.info');
console.log(`\n✅ Coverage reports generated successfully!`);
if (fs.existsSync(htmlReportPath)) {
console.log(`📊 HTML Report: ${htmlReportPath}`);
}
if (fs.existsSync(lcovPath)) {
console.log(`📄 LCOV Report: ${lcovPath} (for Codecov)`);
}
} catch (error) {
console.error('❌ Failed to generate coverage report:', error.message);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = { main };

View File

@ -0,0 +1,123 @@
#!/usr/bin/env node
/**
* Prototype: turn an MCR lcov into a function<->source<->spec impact map, then
* resolve a git diff to the E2E spec(s) that exercise the changed code.
*
* The canary report came from ONE spec (if-node.spec.ts), so all its coverage
* is attributable to that spec. In production each per-test lcov carries its
* own spec id; here we pass it in. Keyed on FUNCTION coverage (not lines) so
* module-load-only execution doesn't over-select.
*
* node impact-map.mjs <lcov> <spec-id> <diff-file>
*/
import { readFileSync } from 'node:fs';
const [lcovPath, specId, diffPath] = process.argv.slice(2);
// ---- 1. Parse lcov -> per-file covered functions ----------------------------
function parseLcov(text) {
const files = new Map(); // sf -> [{ name, line, hits }]
let sf = null;
const fnLine = new Map(); // name -> line (within current file)
for (const raw of text.split('\n')) {
if (raw.startsWith('SF:')) {
sf = raw.slice(3);
files.set(sf, []);
fnLine.clear();
} else if (raw.startsWith('FN:')) {
const [line, ...name] = raw.slice(3).split(',');
fnLine.set(name.join(','), Number(line));
} else if (raw.startsWith('FNDA:')) {
const [hits, ...name] = raw.slice(5).split(',');
const n = name.join(',');
files.get(sf).push({ name: n, line: fnLine.get(n) ?? 0, hits: Number(hits) });
}
}
return files;
}
// ---- 2. Build the map (this report == one spec) -----------------------------
// fileToSpecs: source -> Set(spec) | funcToSpecs: `${source}#${line}` -> Set(spec)
function buildMap(files, spec) {
const fileToSpecs = new Map();
const funcToSpecs = new Map();
const fileFns = new Map(); // source -> [{name,line}] covered, sorted by line
for (const [sf, fns] of files) {
const covered = fns.filter((f) => f.hits > 0);
if (!covered.length) continue;
fileToSpecs.set(sf, new Set([spec]));
fileFns.set(
sf,
covered.map((f) => ({ name: f.name, line: f.line })).sort((a, b) => a.line - b.line),
);
for (const f of covered) {
const k = `${sf}#${f.line}`;
(funcToSpecs.get(k) ?? funcToSpecs.set(k, new Set()).get(k)).add(spec);
}
}
return { fileToSpecs, funcToSpecs, fileFns };
}
// ---- 3. Parse a unified git diff -> changed files + changed new-side lines ---
function parseDiff(text) {
const changed = new Map(); // path -> Set(lineNumber)
let path = null;
let newLine = 0;
for (const raw of text.split('\n')) {
if (raw.startsWith('+++ ')) {
path = raw.slice(4).replace(/^b\//, '').trim();
if (!changed.has(path)) changed.set(path, new Set());
} else if (raw.startsWith('@@')) {
const m = raw.match(/\+(\d+)/);
newLine = m ? Number(m[1]) : 0;
} else if (path) {
if (raw.startsWith('+') && !raw.startsWith('+++')) {
changed.get(path).add(newLine);
newLine++;
} else if (!raw.startsWith('-')) {
newLine++; // context line advances the new-side counter
}
}
}
return changed;
}
// A function is "touched" if a changed line falls in [fnLine, nextFnLine).
function touchedFns(fileFns, changedLines) {
if (!fileFns) return [];
const out = [];
for (let i = 0; i < fileFns.length; i++) {
const start = fileFns[i].line;
const end = i + 1 < fileFns.length ? fileFns[i + 1].line : Infinity;
if ([...changedLines].some((l) => l >= start && l < end)) out.push(fileFns[i]);
}
return out;
}
// ---- run --------------------------------------------------------------------
const map = buildMap(parseLcov(readFileSync(lcovPath, 'utf8')), specId);
const diff = parseDiff(readFileSync(diffPath, 'utf8'));
console.log(`\nDiff touches ${diff.size} file(s):`);
const selected = new Set();
for (const [file, lines] of diff) {
const fileSpecs = map.fileToSpecs.get(file);
if (!fileSpecs) {
console.log(`${file} — no coverage entry → can't scope (default: run broad)`);
continue;
}
const fns = touchedFns(map.fileFns.get(file), lines);
const fnSpecs = new Set();
for (const f of fns)
for (const s of map.funcToSpecs.get(`${file}#${f.line}`) ?? []) fnSpecs.add(s);
const specs = fns.length ? fnSpecs : fileSpecs; // function-precise when we can pin the function
for (const s of specs) selected.add(s);
console.log(
`${file}\n changed lines: ${[...lines].slice(0, 8).join(', ')}${lines.size > 8 ? '…' : ''}` +
`\n touched covered fns: ${fns.length ? fns.map((f) => `${f.name}@${f.line}`).join(', ') : '(none pinned → file-level)'}` +
`\n → specs: ${[...specs].join(', ')}`,
);
}
console.log(
`\n=> E2E specs to run: ${selected.size ? [...selected].join(', ') : '(none — skip E2E)'}\n`,
);

View File

@ -0,0 +1,47 @@
#!/usr/bin/env node
// @ts-check
/**
* Coverage shard runner the coverage caller's test-command.
*
* Runs the Playwright coverage project, then resolves THIS shard's raw V8 to a
* small lcov + per-spec lcovs. Keeping this here (not as steps in
* test-e2e-reusable.yml) leaves the shared E2E workflow coverage-agnostic: it
* just runs the test-command and uploads coverage/, same as any other shard.
*
* Args after the script (e.g. `--workers=1 <specs>`, appended by the reusable)
* are forwarded to Playwright. Coverage resolution is best-effort and never
* masks the test exit code.
*/
import { execFileSync, spawnSync } from 'node:child_process';
const forwarded = process.argv.slice(2);
const sh = (cmd, args, env = {}) =>
spawnSync(cmd, args, { stdio: 'inherit', env: { ...process.env, ...env } });
// 1. Run the coverage project (forward workers/specs from the reusable).
const test = sh('pnpm', ['test:container:coverage', ...forwarded]);
// 2. Resolve coverage — best-effort, even when tests failed (we still want the data).
try {
const image = process.env.TEST_IMAGE_N8N ?? 'n8nio/n8n:local';
// Backend V8 files are written by the container as uid 1000 / mode 0600.
spawnSync('sudo', ['chmod', '-R', 'a+rwX', 'coverage/.backend-v8'], { stdio: 'ignore' });
// Extract the image's built dist (.js + .js.map) for backend source resolution.
const cid = execFileSync('docker', ['create', image]).toString().trim();
execFileSync('rm', ['-rf', 'img-dist']);
execFileSync('docker', ['cp', `${cid}:/usr/local/lib/node_modules/n8n`, './img-dist']);
execFileSync('docker', ['rm', cid], { stdio: 'ignore' });
sh('pnpm', ['coverage:emit-shard'], {
IMAGE_DIST_ROOT: `${process.cwd()}/img-dist`,
N8N_COVERAGE_DIR: `${process.cwd()}/coverage/.backend-v8`,
// monocart's generate() is memory-hungry; containers are stopped, RAM is free.
NODE_OPTIONS: '--max-old-space-size=12288',
});
sh('pnpm', ['coverage:emit-spec-lcovs']);
} catch (error) {
console.error('coverage emit failed (non-fatal):', String(error));
}
process.exit(test.status ?? 1);

View File

@ -0,0 +1,46 @@
#!/usr/bin/env node
// @ts-check
/**
* Resolve changed files the E2E specs that must run, using the coverage
* impact map. Prints the janitor select-e2e JSON ({ specs, unmapped, mode }).
*
* node select-affected-e2e.mjs <changed-file> [<changed-file> ...]
* CHANGED_FILES=a.ts,b.vue node select-affected-e2e.mjs
*
* MAP SOURCE SEAM this is the one spot to change when moving off the committed
* file. Today the map is the committed snapshot the nightly publishes. To pull
* it from a remote webhook instead, replace `resolveMapPath()` with a fetch to a
* temp file. Keep it FAIL-OPEN: on ANY failure return null so selection degrades
* to BROAD (run everything) a missing/stale/unreachable map must never cause a
* test to be skipped.
*/
import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
const JANITOR_CLI = path.resolve(__dirname, '..', '..', 'janitor', 'dist', 'cli.js');
const COMMITTED_MAP = path.join(REPO_ROOT, '.github', 'test-metrics', 'e2e-impact-map.json');
/** @returns {string | null} path to a readable impact map, or null → fail-open broad. */
function resolveMapPath() {
// FUTURE: fetch a remote webhook to a temp file here and return that path;
// wrap in try/catch and return null on failure (fail-open).
return existsSync(COMMITTED_MAP) ? COMMITTED_MAP : null;
}
const changedFiles = process.argv.slice(2).join(',') || process.env.CHANGED_FILES || '';
const mapPath = resolveMapPath();
const args = ['select-e2e', `--changed-files=${changedFiles}`];
// Omitting --map (or pointing at a missing file) makes select-e2e fail open to broad.
if (mapPath) args.push(`--map=${mapPath}`);
const allSpecs = process.env.ALL_SPECS_FILE;
if (allSpecs) args.push(`--all-specs=${allSpecs}`);
const out = execFileSync('node', [JANITOR_CLI, ...args], { encoding: 'utf-8' });
process.stdout.write(out);

View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
// Unit tests for the coverage pipeline scripts/fixtures only. Scoped tightly so
// vitest never picks up the Playwright e2e specs under tests/ (those are *.spec.ts
// run by Playwright, not vitest).
export default defineConfig({
test: {
include: ['scripts/**/*.test.ts', 'fixtures/**/*.test.ts', '*.test.ts'],
exclude: ['tests/**'],
},
});

View File

@ -231,6 +231,9 @@ catalogs:
eslint-plugin-oxlint:
specifier: ^1.61.0
version: 1.61.0
fast-check:
specifier: ^3.23.2
version: 3.23.2
fast-glob:
specifier: 3.2.12
version: 3.2.12
@ -4589,9 +4592,6 @@ importers:
vite:
specifier: 'catalog:'
version: 8.0.2(@types/node@20.19.41)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)
vite-plugin-istanbul:
specifier: ^8.0.0
version: 8.0.0(vite@8.0.2(@types/node@20.19.41)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))
vite-plugin-node-polyfills:
specifier: ^0.25.0
version: 0.25.0(rollup@4.52.4)(vite@8.0.2(@types/node@20.19.41)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))
@ -5060,6 +5060,9 @@ importers:
'@vitest/coverage-v8':
specifier: 'catalog:'
version: 4.1.1(vitest@4.1.1)
fast-check:
specifier: 'catalog:'
version: 3.23.2
ts-morph:
specifier: 'catalog:'
version: 27.0.2
@ -5162,6 +5165,9 @@ importers:
mockserver-client:
specifier: ^5.15.0
version: 5.15.0
monocart-coverage-reports:
specifier: ^2.12.0
version: 2.12.12
n8n:
specifier: workspace:*
version: link:../../cli
@ -5177,9 +5183,6 @@ importers:
nanoid:
specifier: 'catalog:'
version: 3.3.8
nyc:
specifier: ^17.1.0
version: 17.1.0
otplib:
specifier: ^12.0.1
version: 12.0.1
@ -5189,6 +5192,9 @@ importers:
tsx:
specifier: 'catalog:'
version: 4.19.3
vitest:
specifier: 'catalog:'
version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@20.19.41)(jsdom@23.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@20.19.41)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))
zod:
specifier: 3.25.67
version: 3.25.67
@ -12067,10 +12073,18 @@ packages:
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
acorn-loose@8.5.2:
resolution: {integrity: sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==}
engines: {node: '>=0.4.0'}
acorn-walk@8.3.4:
resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
engines: {node: '>=0.4.0'}
acorn-walk@8.3.5:
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
engines: {node: '>=0.4.0'}
acorn@7.4.1:
resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==}
engines: {node: '>=0.4.0'}
@ -12254,10 +12268,6 @@ packages:
append-field@1.0.0:
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
append-transform@2.0.0:
resolution: {integrity: sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==}
engines: {node: '>=8'}
aproba@2.0.0:
resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==}
@ -12269,9 +12279,6 @@ packages:
resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==}
engines: {node: '>= 14'}
archy@1.0.0:
resolution: {integrity: sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==}
are-we-there-yet@3.0.1:
resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@ -12802,10 +12809,6 @@ packages:
cacheable@1.10.3:
resolution: {integrity: sha512-M6p10iJ/VT0wT7TLIGUnm958oVrU2cUK8pQAVU21Zu7h8rbk/PeRtRWrvHJBql97Bhzk3g1N6+2VKC+Rjxna9Q==}
caching-transform@4.0.0:
resolution: {integrity: sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==}
engines: {node: '>=8'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@ -13079,9 +13082,6 @@ packages:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
cliui@7.0.4:
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
@ -13183,6 +13183,10 @@ packages:
resolution: {integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==}
engines: {node: '>=20'}
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
@ -13278,6 +13282,9 @@ packages:
console-control-strings@1.1.0:
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
console-grid@2.2.4:
resolution: {integrity: sha512-OLjCRTiHhOpTRo9lQp/2FgJDyq5uQHwkEmVJulEnQ6JVf27oKKzXHZnNOv/e72V4++UdMZCrDWtvXW5sx4lyQg==}
constant-case@3.0.4:
resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==}
@ -13295,9 +13302,6 @@ packages:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
convert-source-map@1.9.0:
resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@ -13711,10 +13715,6 @@ packages:
resolution: {integrity: sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==}
engines: {node: '>=18'}
default-require-extensions@3.0.1:
resolution: {integrity: sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==}
engines: {node: '>=8'}
defaults@1.0.4:
resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==}
@ -14028,6 +14028,9 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
eight-colors@1.3.3:
resolution: {integrity: sha512-4B54S2Qi4pJjeHmCbDIsveQZWQ/TSSQng4ixYJ9/SYHHpeS5nYK0pzcHvWzWUfRsvJQjwoIENhAwqg59thQceg==}
ejs@3.1.10:
resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==}
engines: {node: '>=0.10.0'}
@ -14400,10 +14403,6 @@ packages:
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
eslint-visitor-keys@5.0.1:
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint@9.29.0:
resolution: {integrity: sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -14429,10 +14428,6 @@ packages:
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
espree@11.2.0:
resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
esprima-next@5.8.4:
resolution: {integrity: sha512-8nYVZ4ioIH4Msjb/XmhnBdz5WRRBaYqevKa1cv9nGJdCehMbzZCPNEEnqfLCZVetUVrUPEcb5IYyu1GG4hFqgg==}
engines: {node: '>=12'}
@ -14611,6 +14606,10 @@ packages:
fake-xml-http-request@2.1.2:
resolution: {integrity: sha512-HaFMBi7r+oEC9iJNpc3bvcW7Z7iLmM26hPDmlb0mFwyANSsOQAtJxbdWsXITKOzZUyMYK0zYCv3h5yDj9TsiXg==}
fast-check@3.23.2:
resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
engines: {node: '>=8.0.0'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@ -14745,10 +14744,6 @@ packages:
resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==}
engines: {node: '>= 0.8'}
find-cache-dir@3.3.2:
resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==}
engines: {node: '>=8'}
find-up-simple@1.0.1:
resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==}
engines: {node: '>=18'}
@ -14805,14 +14800,14 @@ packages:
foreach@2.0.6:
resolution: {integrity: sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==}
foreground-child@2.0.0:
resolution: {integrity: sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==}
engines: {node: '>=8.0.0'}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
foreground-child@4.0.3:
resolution: {integrity: sha512-yeXZaNbCBGaT9giTpLPBdtedzjwhlJBUoL/R4BVQU5mn0TQXOHwVIl1Q2DMuBIdNno4ktA1abZ7dQFVxD6uHxw==}
engines: {node: '>=16'}
form-data-encoder@1.7.2:
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
@ -14861,9 +14856,6 @@ packages:
from@0.1.7:
resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==}
fromentries@1.3.2:
resolution: {integrity: sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==}
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
@ -15054,10 +15046,6 @@ packages:
engines: {node: 20 || >=22}
hasBin: true
glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22}
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
@ -15215,10 +15203,6 @@ packages:
hash.js@1.1.7:
resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
hasha@5.2.2:
resolution: {integrity: sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==}
engines: {node: '>=8'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@ -15781,10 +15765,6 @@ packages:
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
engines: {node: '>= 0.4'}
is-windows@1.0.2:
resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
engines: {node: '>=0.10.0'}
is-wsl@2.2.0:
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
engines: {node: '>=8'}
@ -15855,10 +15835,6 @@ packages:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-hook@3.0.0:
resolution: {integrity: sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==}
engines: {node: '>=8'}
istanbul-lib-instrument@5.2.1:
resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==}
engines: {node: '>=8'}
@ -15867,10 +15843,6 @@ packages:
resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==}
engines: {node: '>=10'}
istanbul-lib-processinfo@2.0.3:
resolution: {integrity: sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
@ -16587,9 +16559,6 @@ packages:
lodash.flatten@4.4.0:
resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==}
lodash.flattendeep@4.4.0:
resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==}
lodash.get@4.4.2:
resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==}
@ -16730,6 +16699,9 @@ packages:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
lz-utils@2.1.1:
resolution: {integrity: sha512-d3Thjos0PSJQAoyMj6vipSSrtrRHS7DImqUNR8x9NW3+zQIftPIbMJAWhi5nPdg5Q9zHz6lxtN8kp/VdMlhi/Q==}
madge@8.0.0:
resolution: {integrity: sha512-9sSsi3TBPhmkTCIpVQF0SPiChj1L7Rq9kU2KDG1o6v2XH9cCw086MopjVCD+vuoL5v8S77DTbVopTO8OUiQpIw==}
engines: {node: '>=18'}
@ -17399,6 +17371,13 @@ packages:
socks:
optional: true
monocart-coverage-reports@2.12.12:
resolution: {integrity: sha512-d9FdUr2dn58Crweon0IE0zVi8r/i4vLvfvb2G7eL5vL5LfrxCB2X6F6qzuiwV6RioA4zbAI//7CYi6LjCvN3zA==}
hasBin: true
monocart-locator@1.0.3:
resolution: {integrity: sha512-pe29W2XAoA1WQmZZqxXoP7s06ZEXUhcb81086v68cqjk1HnVL7Q/iU/WJnnetxjPcLqwb4qG8vaSGUOMQU602g==}
mqtt-packet@9.0.0:
resolution: {integrity: sha512-8v+HkX+fwbodsWAZIZTI074XIoxVBOmPeggQuDFCGg1SqNcC+uoRMWu7J6QlJPqIUIJXmjNYYHxBBLr1Y/Df4w==}
@ -17628,10 +17607,6 @@ packages:
node-machine-id@1.1.12:
resolution: {integrity: sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==}
node-preload@0.2.1:
resolution: {integrity: sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==}
engines: {node: '>=8'}
node-readfiles@0.2.0:
resolution: {integrity: sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==}
@ -17800,11 +17775,6 @@ packages:
nwsapi@2.2.7:
resolution: {integrity: sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==}
nyc@17.1.0:
resolution: {integrity: sha512-U42vQ4czpKa0QdI1hu950XuNhYqgoM+ZF1HT+VuUHL9hPfDPVvNQyltmMqdE9bUHMVa+8yNbc3QKTj8zQhlVxQ==}
engines: {node: '>=18'}
hasBin: true
oas-kit-common@1.0.8:
resolution: {integrity: sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ==}
@ -18045,10 +18015,6 @@ packages:
resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
p-map@3.0.0:
resolution: {integrity: sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==}
engines: {node: '>=8'}
p-map@4.0.0:
resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}
engines: {node: '>=10'}
@ -18093,10 +18059,6 @@ packages:
resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==}
engines: {node: '>= 14'}
package-hash@4.0.0:
resolution: {integrity: sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==}
engines: {node: '>=8'}
package-json-from-dist@1.0.0:
resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==}
@ -18598,10 +18560,6 @@ packages:
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
process-on-spawn@1.1.0:
resolution: {integrity: sha512-JOnOPQ/8TZgjs1JIH/m9ni7FfimjNa/PRx7y/Wb5qdItsnhO0jE4AT7fC0HjC28DUQWDr50dwSYZLdRMlqDq3Q==}
engines: {node: '>=8'}
process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
@ -19033,10 +18991,6 @@ packages:
resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==}
engines: {node: '>= 0.10'}
release-zalgo@1.0.0:
resolution: {integrity: sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==}
engines: {node: '>=4'}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
@ -19076,9 +19030,6 @@ packages:
resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
requirejs-config-file@4.0.0:
resolution: {integrity: sha512-jnIre8cbWOyvr8a5F2KuqBnY+SDA4NXr/hzEZJG79Mxm2WiFQz2dzhC8ibtPJS7zkmBEl1mxSwp5HhC1W4qpxw==}
engines: {node: '>=10.13.0'}
@ -19736,10 +19687,6 @@ packages:
spawn-command@0.0.2:
resolution: {integrity: sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==}
spawn-wrap@2.0.0:
resolution: {integrity: sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==}
engines: {node: '>=8'}
spex@3.3.0:
resolution: {integrity: sha512-VNiXjFp6R4ldPbVRYbpxlD35yRHceecVXlct1J4/X80KuuPnW2AXMq3sGwhnJOhKkUsOxAT6nRGfGE5pocVw5w==}
engines: {node: '>=10.0.0'}
@ -20173,10 +20120,6 @@ packages:
resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==}
engines: {node: '>=8'}
test-exclude@8.0.0:
resolution: {integrity: sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==}
engines: {node: 20 || >=22}
testcontainers@11.13.0:
resolution: {integrity: sha512-fzTvgOtd6U/esOzgmDatJh79OSK0tU6vjDOJ3B6ICrrJf0dqCWtFdpOr6f/g/KixMxKDTDbszmZYjSORJXsVCQ==}
@ -20543,10 +20486,6 @@ packages:
resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
engines: {node: '>=10'}
type-fest@0.8.1:
resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==}
engines: {node: '>=8'}
type-fest@2.19.0:
resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==}
engines: {node: '>=12.20'}
@ -20928,11 +20867,6 @@ packages:
vite:
optional: true
vite-plugin-istanbul@8.0.0:
resolution: {integrity: sha512-r6L7cg2iwPqNnY/rWFyemWeDTIKRZjekEWS90e2FsTjDYH4UdTS6hvW1nEX1B++PKPCnqCaj5BJTDn5Cy5jYoQ==}
peerDependencies:
vite: '>=4'
vite-plugin-node-polyfills@0.25.0:
resolution: {integrity: sha512-rHZ324W3LhfGPxWwQb2N048TThB6nVvnipsqBUJEzh3R9xeK9KI3si+GMQxCuAcpPJBVf0LpDtJ+beYzB3/chg==}
peerDependencies:
@ -21304,9 +21238,6 @@ packages:
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
engines: {node: '>= 0.4'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which-typed-array@1.1.19:
resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==}
engines: {node: '>= 0.4'}
@ -21379,10 +21310,6 @@ packages:
workerpool@6.5.1:
resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@ -21394,9 +21321,6 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
write-file-atomic@3.0.3:
resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==}
write-file-atomic@4.0.2:
resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@ -21521,9 +21445,6 @@ packages:
peerDependencies:
yjs: ^13.0.0
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@ -21554,10 +21475,6 @@ packages:
resolution: {integrity: sha512-C/FsVVhht4iPQYXOInoxUM/1ELSf9EsgKH34FofQOp6hwCPrW4vG4w5++TED3xRUo8gD7l0P1J1dLlDYzODsTQ==}
hasBin: true
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs-parser@20.2.9:
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
engines: {node: '>=10'}
@ -21570,10 +21487,6 @@ packages:
resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==}
engines: {node: '>=10'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yargs@16.2.0:
resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
engines: {node: '>=10'}
@ -30815,10 +30728,18 @@ snapshots:
dependencies:
acorn: 8.16.0
acorn-loose@8.5.2:
dependencies:
acorn: 8.16.0
acorn-walk@8.3.4:
dependencies:
acorn: 8.14.0
acorn-walk@8.3.5:
dependencies:
acorn: 8.14.0
acorn@7.4.1: {}
acorn@8.14.0: {}
@ -31002,10 +30923,6 @@ snapshots:
append-field@1.0.0: {}
append-transform@2.0.0:
dependencies:
default-require-extensions: 3.0.1
aproba@2.0.0:
optional: true
@ -31029,8 +30946,6 @@ snapshots:
tar-stream: 3.1.7
zip-stream: 6.0.1
archy@1.0.0: {}
are-we-there-yet@3.0.1:
dependencies:
delegates: 1.0.0
@ -31722,13 +31637,6 @@ snapshots:
hookified: 1.11.0
keyv: 5.5.0
caching-transform@4.0.0:
dependencies:
hasha: 5.2.2
make-dir: 3.1.0
package-hash: 4.0.0
write-file-atomic: 3.0.3
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@ -32012,12 +31920,6 @@ snapshots:
cli-width@4.1.0: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
cliui@7.0.4:
dependencies:
string-width: 4.2.3
@ -32117,6 +32019,8 @@ snapshots:
commander@14.0.1: {}
commander@14.0.3: {}
commander@2.20.3: {}
commander@4.1.1: {}
@ -32218,6 +32122,8 @@ snapshots:
console-control-strings@1.1.0:
optional: true
console-grid@2.2.4: {}
constant-case@3.0.4:
dependencies:
no-case: 3.0.4
@ -32237,8 +32143,6 @@ snapshots:
content-type@1.0.5: {}
convert-source-map@1.9.0: {}
convert-source-map@2.0.0: {}
convict@6.2.5:
@ -32711,10 +32615,6 @@ snapshots:
bundle-name: 4.1.0
default-browser-id: 5.0.1
default-require-extensions@3.0.1:
dependencies:
strip-bom: 4.0.0
defaults@1.0.4:
dependencies:
clone: 1.0.4
@ -33065,6 +32965,8 @@ snapshots:
ee-first@1.1.1: {}
eight-colors@1.3.3: {}
ejs@3.1.10:
dependencies:
jake: 10.8.5
@ -33355,7 +33257,8 @@ snapshots:
esniff: 2.0.1
next-tick: 1.1.0
es6-error@4.1.1: {}
es6-error@4.1.1:
optional: true
es6-iterator@2.0.3:
dependencies:
@ -33625,8 +33528,6 @@ snapshots:
eslint-visitor-keys@4.2.1: {}
eslint-visitor-keys@5.0.1: {}
eslint@9.29.0(jiti@2.6.1):
dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@9.29.0(jiti@2.6.1))
@ -33693,12 +33594,6 @@ snapshots:
acorn-jsx: 5.3.2(acorn@8.16.0)
eslint-visitor-keys: 4.2.1
espree@11.2.0:
dependencies:
acorn: 8.16.0
acorn-jsx: 5.3.2(acorn@8.16.0)
eslint-visitor-keys: 5.0.1
esprima-next@5.8.4: {}
esprima@4.0.1: {}
@ -33964,6 +33859,10 @@ snapshots:
fake-xml-http-request@2.1.2: {}
fast-check@3.23.2:
dependencies:
pure-rand: 6.1.0
fast-deep-equal@3.1.3: {}
fast-fifo@1.3.2: {}
@ -34118,12 +34017,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
find-cache-dir@3.3.2:
dependencies:
commondir: 1.0.1
make-dir: 3.1.0
pkg-dir: 4.2.0
find-up-simple@1.0.1: {}
find-up@3.0.0:
@ -34174,16 +34067,15 @@ snapshots:
foreach@2.0.6: {}
foreground-child@2.0.0:
dependencies:
cross-spawn: 7.0.6
signal-exit: 3.0.7
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
foreground-child@4.0.3:
dependencies:
signal-exit: 4.1.0
form-data-encoder@1.7.2: {}
form-data-encoder@4.0.2: {}
@ -34225,8 +34117,6 @@ snapshots:
from@0.1.7: {}
fromentries@1.3.2: {}
fs-constants@1.0.0: {}
fs-extra@10.1.0:
@ -34472,12 +34362,6 @@ snapshots:
package-json-from-dist: 1.0.0
path-scurry: 2.0.2
glob@13.0.6:
dependencies:
minimatch: 10.2.3
minipass: 7.1.3
path-scurry: 2.0.2
glob@7.2.3:
dependencies:
fs.realpath: 1.0.0
@ -34708,11 +34592,6 @@ snapshots:
inherits: 2.0.4
minimalistic-assert: 1.0.1
hasha@5.2.2:
dependencies:
is-stream: 2.0.1
type-fest: 0.8.1
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@ -35325,8 +35204,6 @@ snapshots:
call-bound: 1.0.4
get-intrinsic: 1.3.0
is-windows@1.0.2: {}
is-wsl@2.2.0:
dependencies:
is-docker: 2.2.1
@ -35373,10 +35250,6 @@ snapshots:
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-hook@3.0.0:
dependencies:
append-transform: 2.0.0
istanbul-lib-instrument@5.2.1:
dependencies:
'@babel/core': 7.29.0
@ -35397,15 +35270,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
istanbul-lib-processinfo@2.0.3:
dependencies:
archy: 1.0.0
cross-spawn: 7.0.6
istanbul-lib-coverage: 3.2.2
p-map: 3.0.0
rimraf: 3.0.2
uuid: 13.0.1
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
@ -36445,8 +36309,6 @@ snapshots:
lodash.flatten@4.4.0: {}
lodash.flattendeep@4.4.0: {}
lodash.get@4.4.2: {}
lodash.groupby@4.6.0: {}
@ -36556,6 +36418,8 @@ snapshots:
lz-string@1.5.0: {}
lz-utils@2.1.1: {}
madge@8.0.0(typescript@6.0.2):
dependencies:
chalk: 4.1.2
@ -37579,6 +37443,23 @@ snapshots:
gcp-metadata: 5.3.0
socks: 2.8.3
monocart-coverage-reports@2.12.12:
dependencies:
acorn: 8.16.0
acorn-loose: 8.5.2
acorn-walk: 8.3.5
commander: 14.0.3
console-grid: 2.2.4
eight-colors: 1.3.3
foreground-child: 4.0.3
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
lz-utils: 2.1.1
monocart-locator: 1.0.3
monocart-locator@1.0.3: {}
mqtt-packet@9.0.0:
dependencies:
bl: 6.0.12
@ -37844,10 +37725,6 @@ snapshots:
node-machine-id@1.1.12: {}
node-preload@0.2.1:
dependencies:
process-on-spawn: 1.1.0
node-readfiles@0.2.0:
dependencies:
es6-promise: 3.3.1
@ -38042,38 +37919,6 @@ snapshots:
nwsapi@2.2.7: {}
nyc@17.1.0:
dependencies:
'@istanbuljs/load-nyc-config': 1.1.0
'@istanbuljs/schema': 0.1.3
caching-transform: 4.0.0
convert-source-map: 1.9.0
decamelize: 1.2.0
find-cache-dir: 3.3.2
find-up: 4.1.0
foreground-child: 3.3.1
get-package-type: 0.1.0
glob: 7.2.3
istanbul-lib-coverage: 3.2.2
istanbul-lib-hook: 3.0.0
istanbul-lib-instrument: 6.0.3
istanbul-lib-processinfo: 2.0.3
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 4.0.1
istanbul-reports: 3.2.0
make-dir: 3.1.0
node-preload: 0.2.1
p-map: 3.0.0
process-on-spawn: 1.1.0
resolve-from: 5.0.0
rimraf: 3.0.2
signal-exit: 3.0.7
spawn-wrap: 2.0.0
test-exclude: 6.0.0
yargs: 15.4.1
transitivePeerDependencies:
- supports-color
oas-kit-common@1.0.8:
dependencies:
fast-safe-stringify: 2.1.1
@ -38350,10 +38195,6 @@ snapshots:
dependencies:
p-limit: 4.0.0
p-map@3.0.0:
dependencies:
aggregate-error: 3.1.0
p-map@4.0.0:
dependencies:
aggregate-error: 3.1.0
@ -38405,13 +38246,6 @@ snapshots:
degenerator: 5.0.1
netmask: 2.1.1
package-hash@4.0.0:
dependencies:
graceful-fs: 4.2.11
hasha: 5.2.2
lodash.flattendeep: 4.4.0
release-zalgo: 1.0.0
package-json-from-dist@1.0.0: {}
package-manager-detector@1.6.0: {}
@ -38891,10 +38725,6 @@ snapshots:
process-nextick-args@2.0.1: {}
process-on-spawn@1.1.0:
dependencies:
fromentries: 1.3.2
process@0.11.10: {}
progress@2.0.3: {}
@ -39500,10 +39330,6 @@ snapshots:
relateurl@0.2.7: {}
release-zalgo@1.0.0:
dependencies:
es6-error: 4.1.1
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
@ -39565,8 +39391,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
require-main-filename@2.0.0: {}
requirejs-config-file@4.0.0:
dependencies:
esprima: 4.0.1
@ -39653,6 +39477,7 @@ snapshots:
rimraf@3.0.2:
dependencies:
glob: 7.2.3
optional: true
rimraf@5.0.1:
dependencies:
@ -40086,7 +39911,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
set-blocking@2.0.0: {}
set-blocking@2.0.0:
optional: true
set-function-length@1.2.2:
dependencies:
@ -40382,15 +40208,6 @@ snapshots:
spawn-command@0.0.2: {}
spawn-wrap@2.0.0:
dependencies:
foreground-child: 2.0.0
is-windows: 1.0.2
make-dir: 3.1.0
rimraf: 3.0.2
signal-exit: 3.0.7
which: 2.0.2
spex@3.3.0: {}
split-ca@1.0.1: {}
@ -40991,12 +40808,6 @@ snapshots:
glob: 7.2.3
minimatch: 5.1.8
test-exclude@8.0.0:
dependencies:
'@istanbuljs/schema': 0.1.3
glob: 13.0.6
minimatch: 10.2.3
testcontainers@11.13.0:
dependencies:
'@balena/dockerignore': 1.0.2
@ -41371,8 +41182,6 @@ snapshots:
type-fest@0.21.3: {}
type-fest@0.8.1: {}
type-fest@2.19.0: {}
type-fest@4.41.0: {}
@ -41785,20 +41594,6 @@ snapshots:
- rollup
- supports-color
vite-plugin-istanbul@8.0.0(vite@8.0.2(@types/node@20.19.41)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)):
dependencies:
'@babel/generator': 7.29.1
'@istanbuljs/load-nyc-config': 1.1.0
'@types/babel__generator': 7.27.0
espree: 11.2.0
istanbul-lib-instrument: 6.0.3
picocolors: 1.1.1
source-map: 0.7.6
test-exclude: 8.0.0
vite: 8.0.2(@types/node@20.19.41)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
vite-plugin-node-polyfills@0.25.0(rollup@4.52.4)(vite@8.0.2(@types/node@20.19.41)(esbuild@0.25.10)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)):
dependencies:
'@rollup/plugin-inject': 5.0.5(rollup@4.52.4)
@ -42222,8 +42017,6 @@ snapshots:
is-weakmap: 2.0.2
is-weakset: 2.0.4
which-module@2.0.1: {}
which-typed-array@1.1.19:
dependencies:
available-typed-arrays: 1.0.7
@ -42330,12 +42123,6 @@ snapshots:
workerpool@6.5.1: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@ -42350,13 +42137,6 @@ snapshots:
wrappy@1.0.2: {}
write-file-atomic@3.0.3:
dependencies:
imurmurhash: 0.1.4
is-typedarray: 1.0.0
signal-exit: 3.0.7
typedarray-to-buffer: 3.1.5
write-file-atomic@4.0.2:
dependencies:
imurmurhash: 0.1.4
@ -42443,8 +42223,6 @@ snapshots:
lib0: 0.2.117
yjs: 13.6.29
y18n@4.0.3: {}
y18n@5.0.8: {}
yaeti@0.0.6: {}
@ -42464,11 +42242,6 @@ snapshots:
argparse: 1.0.10
glob: 7.2.3
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs-parser@20.2.9: {}
yargs-parser@21.1.1: {}
@ -42480,20 +42253,6 @@ snapshots:
flat: 5.0.2
is-plain-obj: 2.1.0
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yargs@16.2.0:
dependencies:
cliui: 7.0.4

View File

@ -91,6 +91,7 @@ catalog:
csv-parse: 6.2.1
eslint: 9.29.0
eslint-plugin-oxlint: ^1.61.0
fast-check: ^3.23.2
fast-glob: 3.2.12
fast-json-patch: ^3.1.1
fastest-levenshtein: 1.0.16