mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 19:45:09 +02:00
134 lines
6.3 KiB
Markdown
134 lines
6.3 KiB
Markdown
# The E2E Impact Map (in plain terms)
|
||
|
||
> Audience: anyone touching the E2E coverage pipeline for the first time.
|
||
> TL;DR: the impact map lets a PR run **only the E2E specs that actually exercise
|
||
> the code it changed**, instead of the whole suite. This explains how it works,
|
||
> why the file can get big, and how we keep it small **without losing accuracy**.
|
||
|
||
## 1. The problem it solves
|
||
|
||
Running the full Playwright E2E suite on every PR is slow and expensive. But a PR
|
||
that only touches, say, `If.node.ts` doesn't need the 100+ specs that never use an
|
||
If node. We want to run **just the affected specs**.
|
||
|
||
To do that we need to know, for every source file, **which specs exercise it**.
|
||
That lookup table is the **impact map**.
|
||
|
||
## 2. What the map looks like
|
||
|
||
Conceptually it's "source file → the specs that run it":
|
||
|
||
```
|
||
packages/nodes-base/nodes/If/If.node.ts → [ if-node.spec.ts, switch-routing.spec.ts ]
|
||
packages/frontend/editor-ui/src/…/NodeView → [ canvas.spec.ts, ndv.spec.ts, … ]
|
||
```
|
||
|
||
So when a PR changes `If.node.ts`, the selector returns just those specs (`mode:
|
||
scoped`). The committed map lives at `.github/test-metrics/e2e-impact-map.json`.
|
||
|
||
## 3. How it's built
|
||
|
||
A nightly job runs the whole E2E suite with **V8 code coverage** turned on. For
|
||
each spec we record which functions actually ran:
|
||
|
||
- **Frontend**: the browser's own coverage (`page.coverage`), per spec.
|
||
- **Backend**: the n8n server's coverage, captured per spec via a test-only hook
|
||
(`/rest/e2e/coverage/*`). See `backend-v8-coverage.ts` / `emit-spec-backend-lcovs.ts`.
|
||
|
||
We only record a function if it **actually executed** (`hits > 0`) — code that was
|
||
merely loaded but never run is not mapped.
|
||
|
||
## 4. Why the map can get big — the "hub file" problem
|
||
|
||
Frontend coverage is naturally **sparse**: one spec touches a small slice of the UI.
|
||
|
||
Backend coverage is **dense**: executing *any* workflow runs the whole server
|
||
request → execution path — the workflow engine, execution services, shared DTOs.
|
||
Those "hub" files are touched by **almost every spec**.
|
||
|
||
So a hub file's row in the map isn't `[2 specs]`, it's `[130 of 148 specs]`. Real
|
||
numbers from the current map: the busiest 10% of entries are covered by **107+ of
|
||
148 specs**. Store thousands of those as plain number lists and the file balloons.
|
||
|
||
## 5. How we keep it small — without losing anything
|
||
|
||
The naive storage is a JSON list of spec numbers per entry: `[0,5,12,…]`. That's
|
||
fine when an entry has 2 specs, wasteful when it has 130.
|
||
|
||
We use a **hybrid encoding** — pick the cheaper representation per entry:
|
||
|
||
| entry is… | stored as | why |
|
||
|---|---|---|
|
||
| **sparse** (a few specs) | a number list `[3, 17]` | tiny already |
|
||
| **dense** (many specs) | a **bitmask** `"b:AB7…"` | one bit per spec — *constant* size no matter how many specs |
|
||
|
||
A **bitmask** is just a **row of light switches, one per spec** — flip ON the
|
||
switches for the specs that ran this line. With 8 specs, "specs 1, 3 and 6 ran it"
|
||
becomes one byte:
|
||
|
||
```
|
||
spec: 7 6 5 4 3 2 1 0
|
||
switch: 0 1 0 0 1 0 1 0 → one byte (0x4A)
|
||
```
|
||
|
||
That one byte covers 8 specs whether 1 or all 8 are on; 148 specs → 148 switches
|
||
→ 19 bytes, flat. (We base64 the bytes so they fit in JSON: `"b:Sg…"`.) For a hub
|
||
line hit by 130 specs the list would be ~600 chars but the mask is still 19 bytes;
|
||
for a 2-spec line the list `[3,17]` is smaller, so we keep the list. Best of both.
|
||
Reading it back: check which switches are on → recover the spec numbers. Lossless.
|
||
|
||
This is **lossless** — every spec→file relationship is preserved exactly. Measured
|
||
on the real map it cuts the file ~3× with **zero accuracy lost** (and is why the
|
||
map *shrank* even after adding dense backend data).
|
||
|
||
## 6. New files: sibling fallback (not "run everything")
|
||
|
||
What about a file that isn't in the map — a brand-new file, or code no spec has
|
||
exercised yet? The naive answer is "run all specs," but that's wasteful: a new
|
||
file in a well-covered area is almost certainly exercised by the same specs as
|
||
its neighbours. So instead we use a **sibling fallback**:
|
||
|
||
> An unmapped file selects the specs covering its **nearest covered ancestor
|
||
> directory**. A new `packages/@n8n/instance-ai/foo.ts` → the `instance-ai`
|
||
> specs, not all 172.
|
||
|
||
Only a file with **no covered ancestor at all** (a genuinely unknown area)
|
||
falls all the way back to running everything. This is the single biggest lever
|
||
for selection quality, because high-churn packages add new files constantly —
|
||
without it, ~3/4 of backend PRs would run the whole suite.
|
||
|
||
## 7. The golden safety rule: never skip, only narrow
|
||
|
||
The map is an **optimisation, never a gate**. If anything is genuinely unknown we
|
||
run **more** tests, never fewer:
|
||
|
||
- File not in the map → **sibling fallback** (its directory's specs); only a file
|
||
with no covered ancestor → **all** specs.
|
||
- Map missing / stale / unreadable → **all** specs.
|
||
- Either analyzer (static import graph *or* coverage map) errors → **all** specs.
|
||
|
||
So the worst case is "ran too many tests," never "skipped a test that should have
|
||
run." (The sibling fallback is a deliberate, sound trade: a new file is assumed
|
||
exercised by the specs that exercise its directory — a superset in practice, far
|
||
cheaper than the whole suite.)
|
||
|
||
## 8. Where things live
|
||
|
||
| Thing | File |
|
||
|---|---|
|
||
| Committed map | `.github/test-metrics/e2e-impact-map.json` |
|
||
| Build + encode the map | `scripts/aggregate-coverage.mjs`, janitor `coverage-map.ts` (`mergeCoverage`, `encodeImpactMap`) |
|
||
| Pick specs for a change | `scripts/select-affected-e2e.mjs` → janitor `select-e2e` |
|
||
| Backend per-spec capture | `fixtures/backend-v8-coverage.ts`, `scripts/emit-spec-backend-lcovs.ts` |
|
||
| Full pipeline notes | `scripts/coverage-workflow.md` |
|
||
|
||
## Further reading (prior art)
|
||
|
||
This is a studied field — **Regression Test Selection (RTS)**:
|
||
|
||
- Yoo & Harrold, *Regression Testing Minimization, Selection and Prioritization: A Survey* (2012) — the overview.
|
||
- Ekstazi (Gligoric et al., 2015) & STARTS (Legunsen et al., 2017) — file/class-level selection is nearly as accurate as line-level but far cheaper to store.
|
||
- Google (Elbaum et al., 2014) — selecting on the build/dependency graph at scale.
|
||
- Meta, *Predictive Test Selection* (2019) — replacing the map with an ML model when maps stop scaling.
|
||
- Lemire et al., *Roaring Bitmaps* (2016) — the compact-bitset idea behind §5.
|