refactor: Extract @n8n/test-impact (TIA) package (no-changelog) (#31868)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Declan Carroll
2026-06-08 11:20:25 +01:00
committed by GitHub
parent 29735c7a04
commit 3c781d8eba
32 changed files with 712 additions and 268 deletions
+49
View File
@@ -0,0 +1,49 @@
# packages/testing
n8n's **test platform** — the infrastructure that decides what to test, runs it, and measures it. Each package is one concern; they compose rather than overlap.
## Packages
| Package | Name | Purpose | Entry point | Consumed by |
|---|---|---|---|---|
| **rules-engine** | `@n8n/rules-engine` | Generic, typed rules engine for static-analysis tools (register → run → report). The shared substrate. | library | janitor, code-health |
| **test-impact** | `@n8n/test-impact` | Test Impact Analysis: build the coverage→impact map, select impacted specs, distribute them across shards. Framework-agnostic. | library | janitor (CLI), playwright |
| **janitor** | `@n8n/playwright-janitor` | Static analysis + architecture enforcement for the Playwright suite; also hosts the impact/orchestrate CLI used by CI. | `janitor` CLI | playwright, CI |
| **code-health** | `@n8n/code-health` | Static analysis for monorepo dependency hygiene. | `code-health` CLI | CI |
| **containers** | `n8n-containers` | Composable Docker stack for tests (sqlite / postgres / queue / multi-main / observability / kafka …). | `stack:*` scripts | playwright, local dev |
| **playwright** | `n8n-playwright` | The E2E harness — page objects, composables, fixtures, and the shard distributor that drives CI. | `test:*` scripts | CI, local dev |
| **performance** | `@n8n/performance` | Microbenchmarks for critical code paths (`bench`, baseline, compare). | `bench:*` scripts | CI (nightly), local |
## How they fit together
```mermaid
graph TD
rules[rules-engine<br/>rules substrate] --> janitor[janitor<br/>PW lint + impact CLI]
rules --> codehealth[code-health<br/>monorepo deps]
impact[test-impact<br/>select + distribute] --> janitor
impact --> playwright[playwright<br/>E2E harness]
janitor --> playwright
containers[containers<br/>Docker stacks] --> playwright
performance[performance<br/>benchmarks]
classDef substrate fill:#eef,stroke:#88a;
class rules,impact substrate;
```
- **Static-analysis lane:** `rules-engine` (substrate) → `janitor` (Playwright architecture) and `code-health` (monorepo deps).
- **Selection lane:** `test-impact` (coverage map → select → distribute) feeds the janitor CLI and the playwright shard distributor.
- **Execution lane:** `playwright` runs E2E against `containers`-provided stacks.
- **Performance lane:** `performance` is standalone (benchmarks).
## Where to look (quick nav for humans + agents)
| I want to… | Go to |
|---|---|
| change which E2E specs a PR runs (impact map, selection, sharding) | `test-impact/` (+ `janitor` CLI `select` / `distribute`) |
| add/modify an architecture-lint rule for tests | `janitor/src/rules/` (engine: `rules-engine/`) |
| write or fix an E2E test, page object, or fixture | `playwright/` (see its `AGENTS.md`) |
| spin a DB/queue/multi-main stack for a test | `containers/` (`stack:*`) |
| add a microbenchmark | `performance/` |
| enforce a monorepo dependency rule | `code-health/` |
Per-package detail lives in each package's own `README.md` (and `playwright/AGENTS.md`).
+1
View File
@@ -37,6 +37,7 @@
"license": "MIT",
"dependencies": {
"@n8n/rules-engine": "workspace:*",
"@n8n/test-impact": "workspace:*",
"glob": "^10.0.0",
"yaml": "catalog:"
},
+14 -16
View File
@@ -16,6 +16,7 @@
* invoked from any package via `pnpm exec janitor ...`.
*/
import { encodeImpactMap, buildImpactMap, distributeShards, selectTests } from '@n8n/test-impact';
import * as fs from 'node:fs';
import * as path from 'node:path';
@@ -45,7 +46,6 @@ import {
formatBaselineInfo,
getBaselinePath,
} from './core/baseline.js';
import { encodeImpactMap, mergeCoverage } from './core/coverage-map.js';
import { extractDiffs } from './core/extract-diffs.js';
import {
ImpactAnalyzer,
@@ -71,12 +71,10 @@ import {
formatMethodUsageIndexConsole,
formatMethodUsageIndexJSON,
} from './core/method-usage-analyzer.js';
import { orchestrate } from './core/orchestrator.js';
import { createProject } from './core/project-loader.js';
import { toJSON, toConsole } from './core/reporter.js';
import { filterToFailedSpecs } from './core/retry-filter.js';
import { computeScope, formatScope } from './core/scope-analyzer.js';
import { selectE2e } from './core/select-e2e.js';
import { TcrExecutor, formatTcrResultConsole, formatTcrResultJSON } from './core/tcr-executor.js';
import { TestDiscoveryAnalyzer } from './core/test-discovery-analyzer.js';
import { runTestScoped } from './core/test-scoped-runner.js';
@@ -486,7 +484,7 @@ async function runFilterShard(options: CliOptions): Promise<void> {
}
}
async function runOrchestrate(options: CliOptions): Promise<void> {
async function runDistribute(options: CliOptions): Promise<void> {
const config = getConfig();
if (!options.shards || options.shards < 1) {
@@ -534,7 +532,7 @@ async function runOrchestrate(options: CliOptions): Promise<void> {
}
// Composable allowlist filter. distribute-tests.mjs pre-computes the union
// of AST + V8 selection and writes it here; orchestrate then balances shards
// of AST + V8 selection and writes it here; distributeShards then balances shards
// against that subset instead of the full discovered set.
if (options.includeSpecsFile) {
const includeRaw = fs.readFileSync(options.includeSpecsFile, 'utf-8');
@@ -577,7 +575,7 @@ async function runOrchestrate(options: CliOptions): Promise<void> {
}
}
const result = orchestrate(specs, options.shards, metrics, config.orchestration);
const result = distributeShards(specs, options.shards, metrics, config.orchestration);
if (options.shardIndex !== undefined) {
if (Number.isNaN(options.shardIndex) || options.shardIndex < 0) {
@@ -671,7 +669,7 @@ function runMergeCoverage(options: CliOptions): void {
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);
const result = buildImpactMap(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)));
@@ -681,10 +679,10 @@ function runMergeCoverage(options: CliOptions): void {
);
}
/** select-e2e: changed files + impact map → spec list (JSON). I/O wrapper
* around {@link selectE2e}, where the fail-open safety contract lives. */
function runSelectE2e(options: CliOptions): void {
const result = selectE2e({
/** select: changed files + impact map → spec list (JSON). I/O wrapper
* around {@link selectTests}, where the fail-open safety contract lives. */
function runSelect(options: CliOptions): void {
const result = selectTests({
changedFiles: readChangedFiles(options) ?? [],
mapFile: options.mapFile,
allSpecsFile: options.allSpecsFile,
@@ -719,7 +717,7 @@ async function main(): Promise<void> {
case 'discover':
showDiscoverHelp();
break;
case 'orchestrate':
case 'distribute':
showOrchestrateHelp();
break;
case 'affected-packages':
@@ -754,8 +752,8 @@ async function main(): Promise<void> {
runMergeCoverage(options);
return;
}
if (options.command === 'select-e2e') {
runSelectE2e(options);
if (options.command === 'select') {
runSelect(options);
return;
}
@@ -796,8 +794,8 @@ async function main(): Promise<void> {
case 'discover':
runDiscover();
break;
case 'orchestrate':
await runOrchestrate(options);
case 'distribute':
await runDistribute(options);
break;
case 'filter-shard':
await runFilterShard(options);
@@ -14,13 +14,13 @@ export type Command =
| 'baseline'
| 'rules'
| 'discover'
| 'orchestrate'
| 'distribute'
| 'affected-packages'
| 'scope'
| 'test-scoped'
| 'filter-shard'
| 'merge-coverage'
| 'select-e2e';
| 'select';
export interface CliOptions {
command: Command;
@@ -63,13 +63,13 @@ export interface CliOptions {
passthroughArgs: string[];
// filter-shard-specific options
url?: string;
// coverage map options (merge-coverage / select-e2e)
// coverage map options (merge-coverage / select)
inputsDir?: string;
outLcov?: string;
outMap?: string;
mapFile?: string;
allSpecsFile?: string;
/** Path to a newline-separated allowlist of spec paths (orchestrate). */
/** Path to a newline-separated allowlist of spec paths (distribute). */
includeSpecsFile?: string;
}
@@ -81,13 +81,13 @@ const SUBCOMMANDS: Record<string, Command> = {
baseline: 'baseline',
rules: 'rules',
discover: 'discover',
orchestrate: 'orchestrate',
distribute: 'distribute',
'affected-packages': 'affected-packages',
scope: 'scope',
'test-scoped': 'test-scoped',
'filter-shard': 'filter-shard',
'merge-coverage': 'merge-coverage',
'select-e2e': 'select-e2e',
select: 'select',
};
interface FlagHandler {
+7 -7
View File
@@ -20,7 +20,7 @@ Commands:
method-impact Find tests that use a specific method (e.g., CanvasPage.addNode)
tcr Run TCR (Test && Commit || Revert) workflow
discover Discover test specs and capabilities (for orchestration)
orchestrate Distribute specs across shards using capability-aware bin-packing
distribute Distribute specs across shards using capability-aware bin-packing
Analysis Options:
--config=<path> Path to janitor.config.js (default: ./janitor.config.js)
@@ -48,7 +48,7 @@ For command-specific help:
playwright-janitor method-impact --help
playwright-janitor tcr --help
playwright-janitor discover --help
playwright-janitor orchestrate --help
playwright-janitor distribute --help
`);
}
@@ -183,8 +183,8 @@ Groups tests by capability to minimize fixture overhead, then uses greedy
bin-packing to balance test time across shards. Outputs JSON to stdout.
Usage:
playwright-janitor orchestrate --shards=<N> # Full result as JSON
playwright-janitor orchestrate --shards=<N> --shard-index=<I> # Specs for one shard
playwright-janitor distribute --shards=<N> # Full result as JSON
playwright-janitor distribute --shards=<N> --shard-index=<I> # Specs for one shard
Options:
--shards=<N> Number of shards (required)
@@ -201,9 +201,9 @@ Output:
{ shards: [{ shard, specs, testTime, capabilities, fixtureCount }], totalTestTime }
Examples:
playwright-janitor orchestrate --shards=14 | jq '.shards[0].specs'
playwright-janitor orchestrate --shards=8 --impact
playwright-janitor orchestrate --shards=4 --impact --file=pages/CanvasPage.ts
playwright-janitor distribute --shards=14 | jq '.shards[0].specs'
playwright-janitor distribute --shards=8 --impact
playwright-janitor distribute --shards=4 --impact --file=pages/CanvasPage.ts
`);
}
@@ -5,18 +5,18 @@
* Replaces the Playwright `--list` + regex approach used by distribute-tests.mjs.
*/
import type { DiscoveredSpec } from '@n8n/test-impact';
import { SyntaxKind, type Project, type SourceFile, type CallExpression } from 'ts-morph';
import { getConfig } from '../config.js';
import { getSourceFiles } from './project-loader.js';
import { getRelativePath } from '../utils/paths.js';
export interface DiscoveredSpec {
/** Spec file path relative to rootDir */
path: string;
/** Capabilities extracted from tags matching capabilityPrefix */
capabilities: string[];
}
// DiscoveredSpec is owned by @n8n/test-impact (the framework-free orchestrator
// consumes it); re-exported here so this module's DiscoveryReport + existing
// importers keep their API.
export type { DiscoveredSpec };
export interface DiscoveryReport {
/** Active specs (specs with all tests skipped are excluded) */
+1
View File
@@ -59,6 +59,7 @@
"@n8n/instance-ai": "workspace:*",
"@n8n/permissions": "workspace:*",
"@n8n/playwright-janitor": "workspace:*",
"@n8n/test-impact": "workspace:*",
"@n8n/workflow-sdk": "workspace:*",
"@playwright/cli": "catalog:e2e",
"@playwright/test": "catalog:e2e",
@@ -146,12 +146,6 @@ export function formatBackendStats(stats: BackendResolveStats): string {
);
}
/**
* Force every `TN:` record in an lcov to `spec`, so the merge attributes the
* coverage to exactly this spec (the impact map keys on TN). Prepends a `TN:`
* if the lcov has none. Mirrors the frontend per-spec emitter.
*/
export function forceSpecTn(lcov: string, spec: string): string {
const tagged = lcov.replace(/^TN:.*$/gm, `TN:${spec}`);
return tagged.startsWith('TN:') ? tagged : `TN:${spec}\n${tagged}`;
}
// forceSpecTn is owned by @n8n/test-impact's map-build kernel; re-exported here
// so existing importers (and the test) keep their path.
export { forceSpecTn } from '@n8n/test-impact';
@@ -4,7 +4,7 @@
/**
* n8n CI Adapter for Test Distribution
*
* Thin wrapper that calls `janitor orchestrate` for generic shard distribution,
* Thin wrapper that calls `janitor distribute` for generic shard distribution,
* then maps capabilities to n8n-specific Docker images for the CI matrix.
*
* Impact scoping is a domain-partitioned UNION of two analyzers (DEVP-364):
@@ -174,7 +174,7 @@ function logSelectionDecision(decision) {
}
function getOrchestration(numShards, options = {}) {
const cliArgs = ['orchestrate', `--shards=${numShards}`];
const cliArgs = ['distribute', `--shards=${numShards}`];
const includeFile = options.includeSpecsFile;
if (includeFile) cliArgs.push(`--include-specs-file=${includeFile}`);
const output = execFileSync('node', [JANITOR_CLI, ...cliArgs], {
@@ -7,22 +7,22 @@
* resolves each entry's dist URL to the checkout's `packages/<x>/src/*.ts` (the
* same resolution the shard emitter uses), and emits one lcov per spec tagged
* with the spec id in `TN:`. These join the frontend per-spec lcovs in
* `coverage/by-spec/` and let the impact map attribute backend source files to
* the specs that exercise them — so a backend change selects E2E specs instead
* of running the whole suite.
* `coverage/by-spec/` so the impact map attributes backend source files to the
* specs that exercise them.
*
* Best-effort: a spec with no resolvable backend coverage is simply skipped
* (fail-open — no backend rows, never a failure).
* Thin wrapper over the generic @n8n/test-impact build kernel; the n8n-specific
* dist→source resolution is injected via `feedRaws`. Best-effort: a spec with no
* resolvable backend coverage is skipped (fail-open).
*/
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { CoverageReport } from 'monocart-coverage-reports';
import { emitPerSpecLcovs } from '@n8n/test-impact';
import type { CoverageReport } from 'monocart-coverage-reports';
import {
buildPackageMap,
createBackendResolveStats,
forceSpecTn,
formatBackendStats,
resolveBackendEntries,
} from './backend-coverage-resolver';
@@ -31,57 +31,35 @@ import { BACKEND_BY_SPEC_DIR, coverageOptions } from '../coverage-options';
const OUT_DIR = join(coverageOptions.outputDir ?? './coverage', 'by-spec');
async function main() {
if (!existsSync(BACKEND_BY_SPEC_DIR)) {
console.log(`emit-spec-backend-lcovs: ${BACKEND_BY_SPEC_DIR} absent — no per-spec backend raw`);
return;
}
mkdirSync(OUT_DIR, { recursive: true });
const pkgMap = buildPackageMap();
const stats = createBackendResolveStats();
const dirs = readdirSync(BACKEND_BY_SPEC_DIR).filter((d) =>
statSync(join(BACKEND_BY_SPEC_DIR, d)).isDirectory(),
);
let emitted = 0;
for (const slug of dirs) {
const dir = join(BACKEND_BY_SPEC_DIR, slug);
const specMarker = join(dir, '.spec');
if (!existsSync(specMarker)) continue;
const spec = readFileSync(specMarker, 'utf8').trim();
const rawFiles = readdirSync(dir).filter((f) => f.startsWith('raw-') && f.endsWith('.json'));
if (!rawFiles.length) continue;
const report = new CoverageReport({
...coverageOptions,
name: spec,
outputDir: dir,
reports: ['lcovonly'],
});
let added = 0;
for (const rf of rawFiles) {
let parsed: { result?: Array<{ url: string }> };
try {
parsed = JSON.parse(readFileSync(join(dir, rf), 'utf8'));
} catch {
continue;
const emit = await emitPerSpecLcovs({
bySpecDir: BACKEND_BY_SPEC_DIR,
outDir: OUT_DIR,
coverageOptions,
suffix: '-backend',
feedRaws: async (report: CoverageReport, rawFiles, dir) => {
let added = 0;
for (const rf of rawFiles) {
let parsed: { result?: Array<{ url: string }> };
try {
parsed = JSON.parse(readFileSync(join(dir, rf), 'utf8'));
} catch {
continue;
}
const entries = resolveBackendEntries(parsed, pkgMap, stats);
if (entries.length) {
await report.add(entries as never);
added += entries.length;
}
}
const entries = resolveBackendEntries(parsed, pkgMap, stats);
if (entries.length) {
await report.add(entries as never);
added += entries.length;
}
}
if (!added) continue;
return added > 0;
},
});
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.
const lcov = forceSpecTn(readFileSync(lcovPath, 'utf8'), spec);
writeFileSync(join(OUT_DIR, `${slug}-backend.lcov`), lcov);
emitted++;
}
console.log(
`emit-spec-backend-lcovs: ${dirs.length} dirs → ${emitted} per-spec backend lcov(s) in ${OUT_DIR} ` +
`emit-spec-backend-lcovs: ${emit.dirs} dirs → ${emit.emitted} per-spec backend lcov(s) in ${OUT_DIR} ` +
`(${formatBackendStats(stats)})`,
);
}
@@ -4,62 +4,42 @@
* `TN:`. These feed the impact map, letting a git diff select the E2E specs that
* exercise the touched frontend code.
*
* Thin wrapper: the generic build kernel lives in @n8n/test-impact; this script
* supplies the n8n-specific input dir + monocart coverage options, and feeds the
* raw page.coverage directly into the report.
*
* 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 { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { CoverageReport } from 'monocart-coverage-reports';
import { emitPerSpecLcovs } from '@n8n/test-impact';
import type { 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 stats = await emitPerSpecLcovs({
bySpecDir: BY_SPEC_DIR,
outDir: OUT_DIR,
coverageOptions,
feedRaws: async (report: CoverageReport, rawFiles, dir) => {
for (const rf of rawFiles) {
try {
await report.add(JSON.parse(readFileSync(join(dir, rf), 'utf8')));
} catch (error) {
console.warn(`${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++;
}
return true;
},
});
console.log(
`emit-spec-lcovs: ${dirs.length} dirs, ${withMarker} with .spec, ${withRaw} with raw → ` +
`${emitted} per-spec lcov(s) in ${OUT_DIR}`,
`emit-spec-lcovs: ${stats.dirs} dirs, ${stats.withMarker} with .spec, ${stats.withRaw} with raw → ` +
`${stats.emitted} per-spec lcov(s) in ${OUT_DIR}`,
);
}
@@ -3,7 +3,7 @@
/**
* Resolve changed files → the E2E specs that must run, using the coverage
* impact map. Prints the janitor select-e2e JSON ({ specs, unmapped, mode }).
* impact map. Prints the janitor select JSON ({ specs, unmapped, mode }).
*
* node select-affected-e2e.mjs <changed-file> [<changed-file> ...]
* CHANGED_FILES=a.ts,b.vue node select-affected-e2e.mjs
@@ -57,7 +57,7 @@ export function resolveMapPath(opts = {}) {
* @returns {string[]}
*/
export function buildArgs({ changedFiles, mapPath, allSpecs }) {
const args = ['select-e2e', `--changed-files=${changedFiles}`];
const args = ['select', `--changed-files=${changedFiles}`];
if (mapPath) args.push(`--map=${mapPath}`);
if (allSpecs) args.push(`--all-specs=${allSpecs}`);
return args;
@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { buildArgs, resolveMapPath } from './select-affected-e2e.mjs';
// The wrapper is the bridge between CI (raw changed-files list) and janitor
// select-e2e. Its only job is to keep selection FAIL-OPEN: any failure in
// select. Its only job is to keep selection FAIL-OPEN: any failure in
// locating the map must degrade to broad — never throw, never hide selection.
describe('select-affected-e2e wrapper — fail-open contract', () => {
@@ -54,19 +54,14 @@ describe('select-affected-e2e wrapper — fail-open contract', () => {
// the janitor CLI — assert by absence, not by a placeholder value.
it('omits --map when mapPath is null (fail-open broad)', () => {
const args = buildArgs({ changedFiles: 'a.ts,b.ts', mapPath: null });
expect(args).toEqual(['select-e2e', '--changed-files=a.ts,b.ts']);
expect(args).toEqual(['select', '--changed-files=a.ts,b.ts']);
expect(args.some((a: string) => a.startsWith('--map='))).toBe(false);
});
it('passes --map and --all-specs through when provided', () => {
expect(
buildArgs({ changedFiles: 'a.ts', mapPath: '/tmp/m.json', allSpecs: '/tmp/s.txt' }),
).toEqual([
'select-e2e',
'--changed-files=a.ts',
'--map=/tmp/m.json',
'--all-specs=/tmp/s.txt',
]);
).toEqual(['select', '--changed-files=a.ts', '--map=/tmp/m.json', '--all-specs=/tmp/s.txt']);
});
});
});
@@ -0,0 +1,14 @@
import { defineConfig } from 'eslint/config';
import { baseConfig } from '@n8n/eslint-config/base';
export default defineConfig(
baseConfig,
{ ignores: ['dist/**', 'coverage/**'] },
{
// Test fixtures use file paths, spec names and line numbers as object keys
// (the impact-map shape is `file → { line → specs }`) — these are data, not
// identifiers, so the naming-convention rule doesn't apply.
files: ['**/*.test.ts'],
rules: { '@typescript-eslint/naming-convention': 'off' },
},
);
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@n8n/test-impact",
"private": true,
"version": "0.1.0",
"description": "Test Impact Analysis: coverage-map selection + shard orchestration for n8n CI. Framework-agnostic core.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"clean": "rimraf dist .turbo",
"build": "tsc",
"build:unchecked": "tsc --noCheck",
"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": [
"testing",
"test-impact-analysis",
"coverage",
"ci",
"sharding"
],
"license": "MIT",
"dependencies": {
"monocart-coverage-reports": "^2.12.0"
},
"devDependencies": {
"@types/node": "catalog:",
"@vitest/coverage-v8": "catalog:",
"fast-check": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}
@@ -7,10 +7,10 @@ import {
type LcovInput,
decodeImpactMap,
encodeImpactMap,
mergeCoverage,
buildImpactMap,
parseLcov,
resolveImpact,
} from './coverage-map.js';
} from './impact-map.js';
// ---------------------------------------------------------------------------
// Model + generators
@@ -93,9 +93,9 @@ describe('parseLcov', () => {
});
});
describe('mergeCoverage', () => {
describe('buildImpactMap', () => {
it('attributes a function to every spec that executed it (hits > 0)', () => {
const { impactMap } = mergeCoverage([
const { impactMap } = buildImpactMap([
{ 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' },
]);
@@ -103,7 +103,7 @@ describe('mergeCoverage', () => {
});
it('excludes load-only functions (hits === 0) from the map', () => {
const { impactMap } = mergeCoverage([
const { impactMap } = buildImpactMap([
{
spec: 'A',
text: 'TN:A\nSF:f.ts\nFN:10,hit\nFN:20,loaded\nFNDA:4,hit\nFNDA:0,loaded\nend_of_record\n',
@@ -114,7 +114,7 @@ describe('mergeCoverage', () => {
});
it('sums hit counts across specs in the unified lcov', () => {
const { lcov } = mergeCoverage([
const { lcov } = buildImpactMap([
{ 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' },
]);
@@ -229,11 +229,11 @@ describe('resolveImpact — sibling fallback', () => {
// Property + metamorphic tests — the soundness guarantee
// ===========================================================================
describe('mergeCoverage — properties', () => {
describe('buildImpactMap — properties', () => {
it('SOUNDNESS: every executed (spec, function) appears in the map', () => {
fc.assert(
fc.property(arbExecs, (execs) => {
const { impactMap } = mergeCoverage(buildInputs(execs));
const { impactMap } = buildImpactMap(buildInputs(execs));
for (const e of execs) {
if (e.hits > 0) {
expect(impactMap[e.file]?.[String(e.fnLine)] ?? []).toContain(e.spec);
@@ -246,7 +246,7 @@ describe('mergeCoverage — properties', () => {
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));
const { impactMap } = buildImpactMap(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.
@@ -261,8 +261,8 @@ describe('mergeCoverage — properties', () => {
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));
const a = buildImpactMap(inputs);
const b = buildImpactMap(shuffle(inputs, keys));
expect(b.impactMap).toEqual(a.impactMap);
expect(b.lcov).toEqual(a.lcov);
}),
@@ -272,8 +272,8 @@ describe('mergeCoverage — properties', () => {
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;
const mapA = buildImpactMap(buildInputs(a)).impactMap;
const mapAB = buildImpactMap([...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]) {
@@ -289,8 +289,8 @@ describe('mergeCoverage — properties', () => {
fc.assert(
fc.property(arbExecs, (execs) => {
const inputs = buildInputs(execs);
expect(mergeCoverage([...inputs, ...inputs]).impactMap).toEqual(
mergeCoverage(inputs).impactMap,
expect(buildImpactMap([...inputs, ...inputs]).impactMap).toEqual(
buildImpactMap(inputs).impactMap,
);
}),
);
@@ -301,7 +301,7 @@ 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));
const { impactMap } = buildImpactMap(buildInputs(execs));
for (const e of execs) {
if (e.hits > 0) {
const r = resolveImpact([{ file: e.file, lines: [e.fnLine] }], impactMap);
@@ -315,7 +315,7 @@ describe('resolveImpact — properties', () => {
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 { impactMap } = buildImpactMap(buildInputs(execs));
const files = Object.keys(impactMap);
if (files.length === 0) return;
const file = files[0];
@@ -329,7 +329,7 @@ describe('resolveImpact — properties', () => {
it('MONOTONIC: resolving more changed files never selects fewer specs', () => {
fc.assert(
fc.property(arbExecs, (execs) => {
const { impactMap } = mergeCoverage(buildInputs(execs));
const { impactMap } = buildImpactMap(buildInputs(execs));
const files = Object.keys(impactMap);
if (files.length < 2) return;
const a: ChangedFile[] = [{ file: files[0] }];
@@ -347,7 +347,7 @@ describe('resolveImpact — properties', () => {
arbExecs,
fc.array(fc.string(), { minLength: 1, maxLength: 5 }),
(execs, allSpecs) => {
const { impactMap } = mergeCoverage(buildInputs(execs));
const { impactMap } = buildImpactMap(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);
@@ -365,11 +365,11 @@ describe('resolveImpact — properties', () => {
// ===========================================================================
// Serializer + parser hardening — the serializer is exercised by every merge
// but the algebra properties use mergeCoverage as their own oracle, so a
// but the algebra properties use buildImpactMap as their own oracle, so a
// wrong-but-deterministic serialization slips through. These pin it externally.
// ===========================================================================
describe('serializeLcov (via mergeCoverage.lcov)', () => {
describe('serializeLcov (via buildImpactMap.lcov)', () => {
it('SERIALIZE-CORRECT: parse∘serialize round-trips FN/FNDA/DA hits', () => {
fc.assert(
fc.property(arbExecs, (execs) => {
@@ -380,7 +380,7 @@ describe('serializeLcov (via mergeCoverage.lcov)', () => {
expected.set(e.file, f);
f.set(e.fnLine, (f.get(e.fnLine) ?? 0) + e.hits);
}
const { lcov } = mergeCoverage(buildInputs(execs));
const { lcov } = buildImpactMap(buildInputs(execs));
for (const rec of parseLcov(lcov)) {
const exp = expected.get(rec.file);
if (!exp) continue;
@@ -392,7 +392,7 @@ describe('serializeLcov (via mergeCoverage.lcov)', () => {
});
it('emits FN/DA sorted by line and correct FNF/FNH/LF/LH', () => {
const { lcov } = mergeCoverage([
const { lcov } = buildImpactMap([
{
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',
@@ -414,14 +414,14 @@ 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));
const { impactMap } = buildImpactMap(buildInputs(execs));
expect(decodeImpactMap(encodeImpactMap(impactMap))).toEqual(impactMap);
}),
);
});
it('interns each spec path exactly once', () => {
const { impactMap } = mergeCoverage([
const { impactMap } = buildImpactMap([
{ 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);
@@ -3,7 +3,7 @@
*
* 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
* buildImpactMap 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
@@ -134,7 +134,7 @@ export function parseLcov(text: string, fallbackSpec = ''): LcovRecord[] {
* 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 {
export function buildImpactMap(inputs: LcovInput[]): MergeResult {
const files = new Map<string, FileCoverage>();
const funcToSpecs = new Map<string, Set<string>>();
const allSpecs = new Set<string>();
@@ -168,7 +168,7 @@ export function mergeCoverage(inputs: LcovInput[]): MergeResult {
return {
lcov: serializeLcov(files),
impactMap: buildImpactMap(funcToSpecs),
impactMap: assembleImpactMap(funcToSpecs),
stats: {
files: files.size,
functions: [...files.values()].reduce((n, f) => n + f.fns.size, 0),
@@ -204,7 +204,7 @@ function serializeLcov(files: Map<string, FileCoverage>): string {
return out.join('\n') + (out.length ? '\n' : '');
}
function buildImpactMap(funcToSpecs: Map<string, Set<string>>): ImpactMap {
function assembleImpactMap(funcToSpecs: Map<string, Set<string>>): ImpactMap {
const map: ImpactMap = {};
for (const [key, specs] of funcToSpecs) {
const hash = key.lastIndexOf('#');
@@ -249,7 +249,7 @@ export function encodeImpactMap(map: ImpactMap): InternedImpactMap {
* Expand an {@link InternedImpactMap} back to a full {@link ImpactMap}. Handles
* both entry forms (index list or `"b:…"` bitmask) and older maps that predate
* the bitmask form (all index lists). Spec lists come back sorted, matching
* {@link mergeCoverage}'s output, so decodeencode round-trips exactly.
* {@link buildImpactMap}'s output, so decodeencode round-trips exactly.
*/
export function decodeImpactMap(interned: InternedImpactMap): ImpactMap {
const { specs, files } = interned;
+17
View File
@@ -0,0 +1,17 @@
/**
* `@n8n/test-impact` — framework-agnostic Test Impact Analysis core.
*
* Phase 1 (ts-morph-free): coverage-map (build + resolve), orchestrator
* (shard bin-packing), and the V8 selection path. AST-based selection and
* the Playwright fixture subpath land in later phases.
*/
export * from './impact-map.js';
export * from './shard-distributor.js';
export * from './select.js';
export * from './map-build.js';
export type { DiscoveredSpec } from './types.js';
// Strategy + Pipeline selection layer.
export type { SelectionStrategy } from './select/strategy.js';
export { CoverageMapStrategy } from './select/coverage-map-strategy.js';
export { selectImpactedTests } from './select/pipeline.js';
@@ -0,0 +1,59 @@
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { forceSpecTn, emitPerSpecLcovs, type EmitPerSpecLcovsOptions } from './map-build.js';
describe('forceSpecTn', () => {
it('rewrites every record TN to the spec id', () => {
const lcov = 'TN:wrong\nSF:a.ts\nend_of_record\nTN:also\nSF:b.ts\nend_of_record';
expect(forceSpecTn(lcov, 'tests/e2e/x.spec.ts').match(/^TN:.*$/gm)).toEqual([
'TN:tests/e2e/x.spec.ts',
'TN:tests/e2e/x.spec.ts',
]);
});
it('prepends TN when the lcov has none', () => {
expect(forceSpecTn('SF:a.ts\nend_of_record', 'spec')).toBe('TN:spec\nSF:a.ts\nend_of_record');
});
});
describe('emitPerSpecLcovs (scan/skip logic)', () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'mapbuild-'));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
const opts = (feedRaws: EmitPerSpecLcovsOptions['feedRaws']): EmitPerSpecLcovsOptions => ({
bySpecDir: join(root, 'by-spec'),
outDir: join(root, 'out'),
coverageOptions: {},
feedRaws,
});
it('returns empty stats when bySpecDir is absent', async () => {
expect(await emitPerSpecLcovs(opts(() => false))).toEqual({
dirs: 0,
withMarker: 0,
withRaw: 0,
emitted: 0,
});
});
it('counts dirs / markers / raws and skips when feedRaws returns false', async () => {
const bs = join(root, 'by-spec');
mkdirSync(join(bs, 'spec_a'), { recursive: true });
writeFileSync(join(bs, 'spec_a', '.spec'), 'tests/e2e/a.spec.ts');
writeFileSync(join(bs, 'spec_a', 'raw-1.json'), '{}');
mkdirSync(join(bs, 'no_marker'), { recursive: true });
writeFileSync(join(bs, 'no_marker', 'raw-1.json'), '{}');
mkdirSync(join(bs, 'no_raw'), { recursive: true });
writeFileSync(join(bs, 'no_raw', '.spec'), 'x');
const stats = await emitPerSpecLcovs(opts(() => false));
expect(stats).toEqual({ dirs: 3, withMarker: 2, withRaw: 1, emitted: 0 });
});
});
@@ -0,0 +1,88 @@
import { CoverageReport, type CoverageReportOptions } from 'monocart-coverage-reports';
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* Force every lcov record's `TN:` to the real spec id, so the impact-map merge
* attributes the file→spec edges to that spec (monocart stamps its own TN).
*/
export function forceSpecTn(lcov: string, spec: string): string {
let out = lcov.replace(/^TN:.*$/gm, `TN:${spec}`);
if (!out.startsWith('TN:')) out = `TN:${spec}\n${out}`;
return out;
}
export interface EmitPerSpecLcovsOptions {
/** Dir of per-spec raw-coverage folders, each with a `.spec` marker + `raw-*.json`. */
bySpecDir: string;
/** Where to write `<slug><suffix>.lcov`. */
outDir: string;
/** monocart report options (the bundle-specific entry/source filters live here). */
coverageOptions: CoverageReportOptions;
/** Output filename suffix — `''` frontend, `'-backend'` backend. */
suffix?: string;
/**
* Feed a spec's raw files into the report. Frontend adds raws directly;
* backend resolves dist→source first. Return `false` to skip the spec.
* Keeps this kernel framework-agnostic — the runner-specific raw handling
* (and any n8n dist→src resolution) is injected by the caller.
*/
feedRaws: (
report: CoverageReport,
rawFiles: string[],
dir: string,
spec: string,
) => boolean | Promise<boolean>;
}
export interface EmitStats {
dirs: number;
withMarker: number;
withRaw: number;
emitted: number;
}
/**
* Turn per-spec raw coverage folders into one lcov per spec (TN-tagged with the
* spec id) under `outDir`. These per-spec lcovs are the impact-map inputs — a
* git diff then selects the specs that executed the touched code. Generic over
* how raws are fed in (see {@link EmitPerSpecLcovsOptions.feedRaws}).
*/
export async function emitPerSpecLcovs(opts: EmitPerSpecLcovsOptions): Promise<EmitStats> {
const { bySpecDir, outDir, coverageOptions, suffix = '', feedRaws } = opts;
const stats: EmitStats = { dirs: 0, withMarker: 0, withRaw: 0, emitted: 0 };
if (!existsSync(bySpecDir)) return stats;
mkdirSync(outDir, { recursive: true });
const dirs = readdirSync(bySpecDir).filter((d) => statSync(join(bySpecDir, d)).isDirectory());
stats.dirs = dirs.length;
for (const slug of dirs) {
const dir = join(bySpecDir, slug);
const specMarker = join(dir, '.spec');
if (!existsSync(specMarker)) continue;
stats.withMarker++;
const spec = readFileSync(specMarker, 'utf8').trim();
const rawFiles = readdirSync(dir).filter((f) => f.startsWith('raw-') && f.endsWith('.json'));
if (!rawFiles.length) continue;
stats.withRaw++;
const report = new CoverageReport({
...coverageOptions,
name: spec,
outputDir: dir,
reports: ['lcovonly'],
});
if (!(await feedRaws(report, rawFiles, dir, spec))) continue;
const result = await report.generate();
const lcovPath = join(dir, 'lcov.info');
if (!result?.files?.length || !existsSync(lcovPath)) continue;
writeFileSync(
join(outDir, `${slug}${suffix}.lcov`),
forceSpecTn(readFileSync(lcovPath, 'utf8'), spec),
);
stats.emitted++;
}
return stats;
}
@@ -3,19 +3,19 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { type ImpactMap, type InternedImpactMap, encodeImpactMap } from './coverage-map.js';
import { selectE2e } from './select-e2e.js';
import { type ImpactMap, type InternedImpactMap, encodeImpactMap } from './impact-map.js';
import { selectTests } from './select.js';
// The handler's fail-open contract is its safety guarantee: every failure mode
// of the map source must degrade to mode 'broad', never to an empty spec set
// (which would silently skip real tests). These cases pin that contract so a
// future refactor of resolveImpact / loadMap can't quietly invert it.
describe('selectE2e — fail-open contract', () => {
describe('selectTests — fail-open contract', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'select-e2e-')));
tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'select-')));
});
afterEach(() => {
@@ -31,7 +31,7 @@ describe('selectE2e — fail-open contract', () => {
};
it('no --map provided → broad with failOpen reason', () => {
const result = selectE2e({
const result = selectTests({
changedFiles: ['packages/cli/src/x.ts'],
allSpecsFile: writeAllSpecs(ALL_SPECS.join('\n')),
});
@@ -42,7 +42,7 @@ describe('selectE2e — fail-open contract', () => {
it("map path that doesn't exist → broad with failOpen reason", () => {
const missing = path.join(tempDir, 'never-existed.json');
const result = selectE2e({
const result = selectTests({
changedFiles: ['packages/cli/src/x.ts'],
mapFile: missing,
allSpecsFile: writeAllSpecs(ALL_SPECS.join('\n')),
@@ -55,7 +55,7 @@ describe('selectE2e — fail-open contract', () => {
it('corrupt / non-JSON map → broad with failOpen reason', () => {
const mapPath = path.join(tempDir, 'corrupt.json');
fs.writeFileSync(mapPath, '{not valid json,,,');
const result = selectE2e({
const result = selectTests({
changedFiles: ['packages/cli/src/x.ts'],
mapFile: mapPath,
allSpecsFile: writeAllSpecs(ALL_SPECS.join('\n')),
@@ -71,7 +71,7 @@ describe('selectE2e — fail-open contract', () => {
it('empty map {} → broad (the fail-open collapse)', () => {
const mapPath = path.join(tempDir, 'empty.json');
fs.writeFileSync(mapPath, '{}');
const result = selectE2e({
const result = selectTests({
changedFiles: ['packages/cli/src/x.ts'],
mapFile: mapPath,
allSpecsFile: writeAllSpecs(ALL_SPECS.join('\n')),
@@ -92,7 +92,7 @@ describe('selectE2e — fail-open contract', () => {
const mapPath = path.join(tempDir, 'interned.json');
fs.writeFileSync(mapPath, JSON.stringify(interned));
const result = selectE2e({
const result = selectTests({
changedFiles: ['packages/cli/src/x.ts'],
mapFile: mapPath,
});
@@ -103,7 +103,7 @@ describe('selectE2e — fail-open contract', () => {
describe('--all-specs parsing', () => {
const triggerBroad = (allSpecsFile: string) =>
selectE2e({
selectTests({
changedFiles: ['packages/never/covered.ts'],
allSpecsFile,
});
@@ -1,12 +1,12 @@
/**
* `select-e2e` handler: changed files + impact map spec list.
* `select` handler: changed files + impact map spec list.
*
* The file-system-aware wrapper around the pure {@link resolveImpact} resolver.
* This is where the FAIL-OPEN safety contract lives every failure mode
* (missing map, unreadable map, corrupt JSON, empty map) must degrade to
* `mode: 'broad'` so the caller runs the full suite, never an empty one.
*
* Extracted from {@link runSelectE2e} in `cli.ts` so the contract can be
* Extracted from {@link runSelect} in `cli.ts` so the contract can be
* exhaustively unit-tested without spawning a subprocess.
*/
@@ -17,10 +17,11 @@ import {
type InternedImpactMap,
type ResolveResult,
decodeImpactMap,
resolveImpact,
} from './coverage-map.js';
} from './impact-map.js';
import { CoverageMapStrategy } from './select/coverage-map-strategy.js';
import { selectImpactedTests } from './select/pipeline.js';
export interface SelectE2eInput {
export interface SelectTestsInput {
/** Changed files (file paths). */
changedFiles: string[];
/** Path to the impact map JSON. Missing/unreadable → fail-open broad. */
@@ -29,7 +30,7 @@ export interface SelectE2eInput {
allSpecsFile?: string;
}
export interface SelectE2eResult extends ResolveResult {
export interface SelectTestsResult extends ResolveResult {
/** Set when the map could not be loaded; the result is broad as a safety. */
failOpen?: string;
}
@@ -61,14 +62,17 @@ function loadMap(mapFile: string | undefined): { map: ImpactMap; failOpen?: stri
* empty/missing map every changed file is "unmapped" {@link resolveImpact}
* returns `mode: 'broad'`, so fail-open falls out of the same code path.
*/
export function selectE2e(input: SelectE2eInput): SelectE2eResult {
export function selectTests(input: SelectTestsInput): SelectTestsResult {
const allSpecs = input.allSpecsFile
? parseSpecList(fs.readFileSync(input.allSpecsFile, 'utf8'))
: undefined;
const { map, failOpen } = loadMap(input.mapFile);
const changed = input.changedFiles.map((file) => ({ file }));
// Sibling fallback on: a new/unmapped file scopes to its nearest covered
// directory's specs rather than forcing the whole suite (see resolveImpact).
const result = resolveImpact(changed, map, { allSpecs, siblingFallback: true });
// The live V8 selection runs through the pipeline as the single selection
// mechanism; the AST / dep-graph selectors join this array later. Sibling
// fallback scopes a new/unmapped file to its nearest covered directory.
const result = selectImpactedTests(changed, [
new CoverageMapStrategy(map, { allSpecs, siblingFallback: true }),
]);
return { ...result, failOpen };
}
@@ -0,0 +1,22 @@
import type { ChangedFile, ImpactMap, ResolveResult } from '../impact-map.js';
import { resolveImpact } from '../impact-map.js';
import type { SelectionStrategy } from './strategy.js';
/**
* Selection via the runtime V8 coverage map: a changed source file resolves to
* the specs that executed it (function/line-level when `ChangedFile.lines` is
* supplied, whole-file otherwise). Thin Strategy wrapper around the pure
* {@link resolveImpact}; the fail-open-to-broad contract lives there.
*/
export class CoverageMapStrategy implements SelectionStrategy {
readonly name = 'coverage-map';
constructor(
private readonly map: ImpactMap,
private readonly opts: { allSpecs?: string[]; siblingFallback?: boolean } = {},
) {}
resolve(changed: ChangedFile[]): ResolveResult {
return resolveImpact(changed, this.map, this.opts);
}
}
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import type { ChangedFile, ImpactMap, ResolveResult } from '../impact-map.js';
import { resolveImpact } from '../impact-map.js';
import { CoverageMapStrategy } from './coverage-map-strategy.js';
import { selectImpactedTests } from './pipeline.js';
import type { SelectionStrategy } from './strategy.js';
const MAP: ImpactMap = {
'packages/cli/src/a.ts': { '1': ['tests/e2e/a.spec.ts'] },
'packages/cli/src/b.ts': { '1': ['tests/e2e/b.spec.ts', 'tests/e2e/a.spec.ts'] },
};
const ALL = ['tests/e2e/a.spec.ts', 'tests/e2e/b.spec.ts', 'tests/e2e/c.spec.ts'];
const stub = (name: string, result: ResolveResult): SelectionStrategy => ({
name,
resolve: () => result,
});
describe('CoverageMapStrategy', () => {
it('is a thin wrapper — resolve() equals resolveImpact()', () => {
const changed: ChangedFile[] = [{ file: 'packages/cli/src/b.ts' }];
const opts = { allSpecs: ALL, siblingFallback: true };
const selector = new CoverageMapStrategy(MAP, opts);
expect(selector.resolve(changed)).toEqual(resolveImpact(changed, MAP, opts));
});
it('exposes a stable name', () => {
expect(new CoverageMapStrategy(MAP).name).toBe('coverage-map');
});
});
describe('selectImpactedTests (pipeline)', () => {
it('unions scoped spec sets across selectors, sorted', () => {
const a = stub('a', { specs: ['tests/e2e/b.spec.ts'], unmapped: [], mode: 'scoped' });
const b = stub('b', { specs: ['tests/e2e/a.spec.ts'], unmapped: [], mode: 'scoped' });
const result = selectImpactedTests([{ file: 'x' }], [a, b]);
expect(result.mode).toBe('scoped');
expect(result.specs).toEqual(['tests/e2e/a.spec.ts', 'tests/e2e/b.spec.ts']);
});
it('broad wins — any broad selector makes the whole result broad', () => {
const scoped = stub('scoped', { specs: ['tests/e2e/a.spec.ts'], unmapped: [], mode: 'scoped' });
const broad = stub('broad', { specs: ALL, unmapped: ['pnpm-lock.yaml'], mode: 'broad' });
const result = selectImpactedTests([{ file: 'x' }], [scoped, broad]);
expect(result.mode).toBe('broad');
});
it('merges unmapped + viaSibling from scoped selectors', () => {
const a = stub('a', { specs: ['s1'], unmapped: ['u1'], mode: 'scoped', viaSibling: ['v1'] });
const b = stub('b', { specs: ['s2'], unmapped: ['u2'], mode: 'scoped' });
const result = selectImpactedTests([{ file: 'x' }], [a, b]);
expect(result.specs).toEqual(['s1', 's2']);
expect(result.unmapped.sort()).toEqual(['u1', 'u2']);
expect(result.viaSibling).toEqual(['v1']);
});
it('composes with CoverageMapStrategy as the only strategy', () => {
const changed: ChangedFile[] = [{ file: 'packages/cli/src/a.ts' }];
const result = selectImpactedTests(changed, [new CoverageMapStrategy(MAP, { allSpecs: ALL })]);
expect(result.mode).toBe('scoped');
expect(result.specs).toEqual(['tests/e2e/a.spec.ts']);
});
});
@@ -0,0 +1,37 @@
import type { ChangedFile, ResolveResult } from '../impact-map.js';
import type { SelectionStrategy } from './strategy.js';
/**
* Run every selector over the changed files and combine, biased to OVER-select:
*
* - if ANY selector returns `mode: 'broad'`, the whole result is broad (broad
* wins — never skip a test on uncertainty);
* - otherwise union the scoped spec sets.
*
* This is the single place the fail-open contract lives — it formalises the
* `V8 AST` union that `distribute-tests` previously did inline, so adding a
* dep-graph or AST selector needs no change here.
*/
export function selectImpactedTests(
changed: ChangedFile[],
selectors: SelectionStrategy[],
): ResolveResult {
const specs = new Set<string>();
const unmapped = new Set<string>();
const viaSibling = new Set<string>();
for (const selector of selectors) {
const result = selector.resolve(changed);
if (result.mode === 'broad') return result; // broad wins, short-circuit
for (const spec of result.specs) specs.add(spec);
for (const file of result.unmapped) unmapped.add(file);
for (const file of result.viaSibling ?? []) viaSibling.add(file);
}
return {
specs: [...specs].sort(),
unmapped: [...unmapped],
mode: 'scoped',
...(viaSibling.size ? { viaSibling: [...viaSibling] } : {}),
};
}
@@ -0,0 +1,16 @@
import type { ChangedFile, ResolveResult } from '../impact-map.js';
/**
* A selection strategy: given changed files, decide which specs must run.
*
* Each strategy resolves *independently* and may return `mode: 'broad'` to mean
* "I can't scope this — run everything." The {@link selectImpactedTests} pipeline
* unions scoped results and lets broad win (fail-open). Implementations:
* `CoverageMapStrategy` (runtime coverage), and — later — an AST selector for
* test-internal changes and a dep-graph selector for dependency changes.
*/
export interface SelectionStrategy {
/** Stable id for logging / telemetry (e.g. 'coverage-map', 'ast', 'dep-graph'). */
readonly name: string;
resolve(changed: ChangedFile[]): ResolveResult;
}
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { orchestrate } from './orchestrator.js';
import type { DiscoveredSpec } from './test-discovery-analyzer.js';
import { distributeShards } from './shard-distributor.js';
import type { DiscoveredSpec } from './types.js';
const DEFAULT_CONFIG = { defaultDuration: 60_000, maxGroupDuration: 300_000 };
@@ -9,16 +9,16 @@ function spec(path: string, capabilities: string[] = []): DiscoveredSpec {
return { path, capabilities };
}
describe('orchestrate', () => {
describe('distributeShards', () => {
it('returns 0 shards when no specs provided', () => {
const result = orchestrate([], 3, {}, DEFAULT_CONFIG);
const result = distributeShards([], 3, {}, DEFAULT_CONFIG);
expect(result.shards).toHaveLength(0);
expect(result.totalTestTime).toBe(0);
});
it('assigns single spec to single shard', () => {
const result = orchestrate([spec('test.spec.ts')], 1, {}, DEFAULT_CONFIG);
const result = distributeShards([spec('test.spec.ts')], 1, {}, DEFAULT_CONFIG);
expect(result.shards).toHaveLength(1);
expect(result.shards[0].specs).toEqual(['test.spec.ts']);
@@ -26,21 +26,21 @@ describe('orchestrate', () => {
});
it('strips empty shards when more shards than specs', () => {
const result = orchestrate([spec('a.spec.ts'), spec('b.spec.ts')], 5, {}, DEFAULT_CONFIG);
const result = distributeShards([spec('a.spec.ts'), spec('b.spec.ts')], 5, {}, DEFAULT_CONFIG);
expect(result.shards).toHaveLength(2);
expect(result.shards.every((s) => s.specs.length > 0)).toBe(true);
});
it('re-numbers shards sequentially after stripping empty ones', () => {
const result = orchestrate([spec('a.spec.ts'), spec('b.spec.ts')], 5, {}, DEFAULT_CONFIG);
const result = distributeShards([spec('a.spec.ts'), spec('b.spec.ts')], 5, {}, DEFAULT_CONFIG);
expect(result.shards.map((s) => s.shard)).toEqual([1, 2]);
});
it('uses defaultDuration when metrics are missing', () => {
const config = { defaultDuration: 30_000, maxGroupDuration: 300_000 };
const result = orchestrate([spec('a.spec.ts')], 1, {}, config);
const result = distributeShards([spec('a.spec.ts')], 1, {}, config);
expect(result.shards[0].testTime).toBe(30_000);
expect(result.totalTestTime).toBe(30_000);
@@ -48,7 +48,7 @@ describe('orchestrate', () => {
it('uses metric duration when available', () => {
const metrics = { 'a.spec.ts': 120_000 };
const result = orchestrate([spec('a.spec.ts')], 1, metrics, DEFAULT_CONFIG);
const result = distributeShards([spec('a.spec.ts')], 1, metrics, DEFAULT_CONFIG);
expect(result.shards[0].testTime).toBe(120_000);
expect(result.totalTestTime).toBe(120_000);
@@ -60,7 +60,7 @@ describe('orchestrate', () => {
spec('email2.spec.ts', ['email']),
spec('standard.spec.ts'),
];
const result = orchestrate(specs, 3, {}, DEFAULT_CONFIG);
const result = distributeShards(specs, 3, {}, DEFAULT_CONFIG);
const emailShard = result.shards.find((s) => s.capabilities.includes('email'));
expect(emailShard).toBeDefined();
@@ -70,7 +70,7 @@ describe('orchestrate', () => {
it('places different capabilities on separate shards when space allows', () => {
const specs = [spec('email.spec.ts', ['email']), spec('proxy.spec.ts', ['proxy'])];
const result = orchestrate(specs, 2, {}, DEFAULT_CONFIG);
const result = distributeShards(specs, 2, {}, DEFAULT_CONFIG);
const emailShard = result.shards.find((s) => s.capabilities.includes('email'));
const proxyShard = result.shards.find((s) => s.capabilities.includes('proxy'));
@@ -87,7 +87,7 @@ describe('orchestrate', () => {
const config = { defaultDuration: 60_000, maxGroupDuration: 200_000 };
const specs = [spec('email1.spec.ts', ['email']), spec('email2.spec.ts', ['email'])];
const result = orchestrate(specs, 2, metrics, config);
const result = distributeShards(specs, 2, metrics, config);
const shardsWithEmail = result.shards.filter((s) => s.capabilities.includes('email'));
expect(shardsWithEmail.length).toBeGreaterThanOrEqual(2);
@@ -107,7 +107,7 @@ describe('orchestrate', () => {
spec('light2.spec.ts'),
];
const result = orchestrate(specs, 2, metrics, DEFAULT_CONFIG);
const result = distributeShards(specs, 2, metrics, DEFAULT_CONFIG);
const times = result.shards.map((s) => s.testTime).sort((a, b) => a - b);
expect(times[0]).toBe(100_000);
@@ -121,7 +121,7 @@ describe('orchestrate', () => {
spec('standard.spec.ts'),
];
const result = orchestrate(specs, 1, {}, DEFAULT_CONFIG);
const result = distributeShards(specs, 1, {}, DEFAULT_CONFIG);
const shard = result.shards[0];
// 2 capabilities + standard specs = 3
@@ -130,28 +130,28 @@ describe('orchestrate', () => {
it('fixtureCount is 1 for shard with only standard specs', () => {
const specs = [spec('a.spec.ts'), spec('b.spec.ts')];
const result = orchestrate(specs, 1, {}, DEFAULT_CONFIG);
const result = distributeShards(specs, 1, {}, DEFAULT_CONFIG);
expect(result.shards[0].fixtureCount).toBe(1);
});
it('fixtureCount is 1 for shard with only one capability and no standard specs', () => {
const specs = [spec('email.spec.ts', ['email'])];
const result = orchestrate(specs, 1, {}, DEFAULT_CONFIG);
const result = distributeShards(specs, 1, {}, DEFAULT_CONFIG);
expect(result.shards[0].fixtureCount).toBe(1);
});
it('sorts capabilities alphabetically', () => {
const specs = [spec('proxy.spec.ts', ['proxy']), spec('email.spec.ts', ['email'])];
const result = orchestrate(specs, 1, {}, DEFAULT_CONFIG);
const result = distributeShards(specs, 1, {}, DEFAULT_CONFIG);
expect(result.shards[0].capabilities).toEqual(['email', 'proxy']);
});
it('uses 1-indexed shard numbers', () => {
const specs = [spec('a.spec.ts'), spec('b.spec.ts'), spec('c.spec.ts')];
const result = orchestrate(specs, 3, {}, DEFAULT_CONFIG);
const result = distributeShards(specs, 3, {}, DEFAULT_CONFIG);
expect(result.shards.map((s) => s.shard)).toEqual([1, 2, 3]);
});
@@ -160,7 +160,7 @@ describe('orchestrate', () => {
const metrics = { 'a.spec.ts': 100_000, 'b.spec.ts': 200_000 };
const specs = [spec('a.spec.ts'), spec('b.spec.ts'), spec('c.spec.ts')];
const result = orchestrate(specs, 2, metrics, DEFAULT_CONFIG);
const result = distributeShards(specs, 2, metrics, DEFAULT_CONFIG);
expect(result.totalTestTime).toBe(100_000 + 200_000 + 60_000);
});
@@ -6,7 +6,7 @@
* 4. Greedy bin-packing: assign heaviest items to lightest shard
*/
import type { DiscoveredSpec } from './test-discovery-analyzer.js';
import type { DiscoveredSpec } from './types.js';
export interface ShardAssignment {
shard: number;
@@ -16,12 +16,12 @@ export interface ShardAssignment {
fixtureCount: number;
}
export interface OrchestrationResult {
export interface ShardDistribution {
shards: ShardAssignment[];
totalTestTime: number;
}
interface OrchestrationConfig {
interface DistributeConfig {
defaultDuration: number;
maxGroupDuration: number;
}
@@ -150,12 +150,12 @@ function assignToShards(items: PackingItem[], numShards: number): Bucket[] {
return buckets;
}
export function orchestrate(
export function distributeShards(
specs: DiscoveredSpec[],
numShards: number,
metrics: Record<string, number>,
config: OrchestrationConfig,
): OrchestrationResult {
config: DistributeConfig,
): ShardDistribution {
const enriched = enrichWithDuration(specs, metrics, config.defaultDuration);
const { groups, standard } = groupByCapability(enriched);
+13
View File
@@ -0,0 +1,13 @@
/**
* A test spec discovered by a runner adapter, with the capabilities (container
* tags) it needs. Owned here — not by any runner-specific discovery — so the
* framework-free orchestrator can bin-pack without depending on the ts-morph
* discovery that produces these. Janitor's `test-discovery-analyzer` re-exports
* this type for its own `DiscoveryReport`.
*/
export interface DiscoveredSpec {
/** Spec file path relative to rootDir. */
path: string;
/** Capabilities extracted from tags matching the capability prefix. */
capabilities: string[];
}
@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules", "**/__tests__/**"]
}
@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/index.ts'],
},
},
});
+69 -41
View File
@@ -5103,6 +5103,9 @@ importers:
'@n8n/rules-engine':
specifier: workspace:*
version: link:../rules-engine
'@n8n/test-impact':
specifier: workspace:*
version: link:../test-impact
glob:
specifier: 10.5.0
version: 10.5.0
@@ -5179,6 +5182,9 @@ importers:
'@n8n/playwright-janitor':
specifier: workspace:*
version: link:../janitor
'@n8n/test-impact':
specifier: workspace:*
version: link:../test-impact
'@n8n/workflow-sdk':
specifier: workspace:*
version: link:../../@n8n/workflow-sdk
@@ -5277,6 +5283,28 @@ importers:
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))
packages/testing/test-impact:
dependencies:
monocart-coverage-reports:
specifier: ^2.12.0
version: 2.12.12
devDependencies:
'@types/node':
specifier: ^20.17.50
version: 20.19.41
'@vitest/coverage-v8':
specifier: 'catalog:'
version: 4.1.1(vitest@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)))
fast-check:
specifier: 'catalog:'
version: 3.23.2
typescript:
specifier: 6.0.2
version: 6.0.2
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))
packages/workflow:
dependencies:
'@n8n/errors':
@@ -25427,14 +25455,14 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 20.19.21
'@types/node': 20.19.41
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 3.9.0
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
jest-config: 29.7.0(@types/node@20.19.21)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@20.19.21)(typescript@6.0.2))
jest-config: 29.7.0(@types/node@20.19.41)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@20.19.21)(typescript@6.0.2))
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
@@ -25462,14 +25490,14 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 20.19.21
'@types/node': 20.19.41
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 3.9.0
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
jest-config: 29.7.0(@types/node@20.19.21)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@20.19.41)(typescript@6.0.2))
jest-config: 29.7.0(@types/node@20.19.41)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@20.19.41)(typescript@6.0.2))
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
@@ -25494,7 +25522,7 @@ snapshots:
dependencies:
'@jest/fake-timers': 29.7.0
'@jest/types': 29.6.3
'@types/node': 20.19.21
'@types/node': 20.19.41
jest-mock: 29.7.0
'@jest/expect-utils@29.7.0':
@@ -25512,7 +25540,7 @@ snapshots:
dependencies:
'@jest/types': 29.6.3
'@sinonjs/fake-timers': 10.3.0
'@types/node': 20.19.21
'@types/node': 20.19.41
jest-message-util: 29.7.0
jest-mock: 29.7.0
jest-util: 29.7.0
@@ -25604,7 +25632,7 @@ snapshots:
'@jest/schemas': 29.6.3
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/yargs': 17.0.33
chalk: 4.1.2
@@ -29107,11 +29135,11 @@ snapshots:
'@types/adm-zip@0.5.7':
dependencies:
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/amqplib@0.10.1':
dependencies:
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/argparse@1.0.38': {}
@@ -29119,15 +29147,15 @@ snapshots:
'@types/asn1@0.2.0':
dependencies:
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/autocannon@7.12.7':
dependencies:
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/aws4@1.11.2':
dependencies:
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/babel__core@7.20.5':
dependencies:
@@ -29266,7 +29294,7 @@ snapshots:
'@types/formidable@3.4.5':
dependencies:
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/fs-extra@9.0.13':
dependencies:
@@ -29274,16 +29302,16 @@ snapshots:
'@types/ftp@0.3.33':
dependencies:
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/glob@8.0.0':
dependencies:
'@types/minimatch': 5.1.2
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/gm@1.25.0':
dependencies:
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/graceful-fs@4.1.9':
dependencies:
@@ -29297,7 +29325,7 @@ snapshots:
'@types/http-proxy@1.17.16':
dependencies:
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/humanize-duration@3.27.1': {}
@@ -29337,7 +29365,7 @@ snapshots:
'@types/jsdom@20.0.1':
dependencies:
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/tough-cookie': 4.0.5
parse5: 7.1.2
@@ -29374,7 +29402,7 @@ snapshots:
'@types/mailparser@3.4.4':
dependencies:
'@types/node': 20.19.21
'@types/node': 20.19.41
iconv-lite: 0.6.3
'@types/markdown-it-emoji@2.0.5':
@@ -29416,7 +29444,7 @@ snapshots:
'@types/mssql@9.1.5':
dependencies:
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/tedious': 4.0.14
tarn: 3.0.2
@@ -29440,13 +29468,13 @@ snapshots:
'@types/nodemailer@7.0.3':
dependencies:
'@aws-sdk/client-sesv2': 3.918.0
'@types/node': 20.19.21
'@types/node': 20.19.41
transitivePeerDependencies:
- aws-crt
'@types/oracledb@6.10.3':
dependencies:
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/pg@8.15.6':
dependencies:
@@ -29468,14 +29496,14 @@ snapshots:
dependencies:
'@types/bluebird': 3.5.37
'@types/ftp': 0.3.33
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/promise-ftp-common': 1.1.0
'@types/prop-types@15.7.15': {}
'@types/proxy-from-env@1.0.4':
dependencies:
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/psl@1.1.3': {}
@@ -29507,7 +29535,7 @@ snapshots:
'@types/resolve@1.17.1':
dependencies:
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/responselike@1.0.3':
dependencies:
@@ -29538,7 +29566,7 @@ snapshots:
'@types/shelljs@0.8.11':
dependencies:
'@types/glob': 8.0.0
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/showdown@1.9.4': {}
@@ -29557,18 +29585,18 @@ snapshots:
'@types/ssh2@1.11.6':
dependencies:
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/sshpk@1.17.4':
dependencies:
'@types/asn1': 0.2.0
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/stack-utils@2.0.3': {}
'@types/stream-chain@2.1.0':
dependencies:
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/stream-json@1.7.8':
dependencies:
@@ -29581,7 +29609,7 @@ snapshots:
dependencies:
'@types/cookiejar': 2.1.5
'@types/methods': 1.1.4
'@types/node': 20.19.21
'@types/node': 20.19.41
form-data: 4.0.4
'@types/supertest@6.0.3':
@@ -29596,11 +29624,11 @@ snapshots:
'@types/syslog-client@1.1.2':
dependencies:
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/tedious@4.0.14':
dependencies:
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/temp@0.9.4':
dependencies:
@@ -29608,7 +29636,7 @@ snapshots:
'@types/through@0.0.30':
dependencies:
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/tough-cookie@4.0.5': {}
@@ -34177,7 +34205,7 @@ snapshots:
groq-sdk@0.19.0(encoding@0.1.13):
dependencies:
'@types/node': 20.19.21
'@types/node': 20.19.41
'@types/node-fetch': 2.6.13
abort-controller: 3.0.0
agentkeepalive: 4.6.0
@@ -35049,7 +35077,7 @@ snapshots:
- babel-plugin-macros
- supports-color
jest-config@29.7.0(@types/node@20.19.21)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@20.19.41)(typescript@6.0.2)):
jest-config@29.7.0(@types/node@20.19.41)(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@20.19.21)(typescript@6.0.2)):
dependencies:
'@babel/core': 7.29.0
'@jest/test-sequencer': 29.7.0
@@ -35074,8 +35102,8 @@ snapshots:
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 20.19.21
ts-node: 10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@20.19.41)(typescript@6.0.2)
'@types/node': 20.19.41
ts-node: 10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.17))(@types/node@20.19.21)(typescript@6.0.2)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@@ -35136,7 +35164,7 @@ snapshots:
'@jest/fake-timers': 29.7.0
'@jest/types': 29.6.3
'@types/jsdom': 20.0.1
'@types/node': 20.19.21
'@types/node': 20.19.41
jest-mock: 29.7.0
jest-util: 29.7.0
jsdom: 20.0.2(bufferutil@4.0.9)(utf-8-validate@5.0.10)
@@ -35220,7 +35248,7 @@ snapshots:
jest-mock@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 20.19.21
'@types/node': 20.19.41
jest-util: 29.7.0
jest-pnp-resolver@1.2.3(jest-resolve@29.7.0):
@@ -35329,7 +35357,7 @@ snapshots:
jest-util@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 20.19.21
'@types/node': 20.19.41
chalk: 4.1.2
ci-info: 3.9.0
graceful-fs: 4.2.11
@@ -38514,7 +38542,7 @@ snapshots:
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.1
'@types/node': 20.19.21
'@types/node': 20.19.41
long: 5.3.2
proxy-addr@2.0.7: