ci: Bound per-spec V8 memory and fix the worker-heap OOM in the nightly (no-changelog) (#32202)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Declan Carroll 2026-06-15 13:37:39 +01:00 committed by GitHub
parent 8ea8bc3451
commit d5bf2ef8fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 259 additions and 66 deletions

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,74 @@
import { describe, test, expect } from 'vitest';
import { mergeV8CoverageByUrl, type V8CoverageEntry } from './coverage-options';
const fn = (functionName: string, ranges: Array<[number, number, number]>) => ({
functionName,
isBlockCoverage: true,
ranges: ranges.map(([startOffset, endOffset, count]) => ({ startOffset, endOffset, count })),
});
describe('mergeV8CoverageByUrl', () => {
test('keeps distinct URLs separate', () => {
const entries: V8CoverageEntry[] = [
{ url: 'a.js', source: 'a', functions: [fn('', [[0, 10, 1]])] },
{ url: 'b.js', source: 'b', functions: [fn('', [[0, 10, 1]])] },
];
expect(mergeV8CoverageByUrl(entries)).toHaveLength(2);
});
test('collapses re-loaded same-URL scripts into one, summing range counts', () => {
const merged = mergeV8CoverageByUrl([
{
url: 'a.js',
source: 'src',
functions: [
fn('f', [
[0, 10, 1],
[4, 8, 1],
]),
],
},
{
url: 'a.js',
source: 'src',
functions: [
fn('f', [
[0, 10, 2],
[4, 8, 0],
]),
],
},
]);
expect(merged).toHaveLength(1);
expect(merged[0].source).toBe('src'); // one source copy, not two
const ranges = merged[0].functions[0].ranges;
expect(ranges.find((r) => r.startOffset === 0)?.count).toBe(3); // 1 + 2
expect(ranges.find((r) => r.startOffset === 4)?.count).toBe(1); // 1 + 0
});
test('unions ranges and functions present in only one copy', () => {
const merged = mergeV8CoverageByUrl([
{ url: 'a.js', source: 'src', functions: [fn('f', [[0, 10, 1]])] },
{
url: 'a.js',
source: 'src',
functions: [
fn('f', [
[0, 10, 1],
[20, 30, 5],
]),
fn('g', [[40, 50, 2]]),
],
},
]);
expect(merged).toHaveLength(1);
const fns = merged[0].functions;
expect(fns.map((f) => f.functionName).sort()).toEqual(['f', 'g']);
expect(
fns.find((f) => f.functionName === 'f')?.ranges.find((r) => r.startOffset === 20)?.count,
).toBe(5);
});
});

View File

@ -1,5 +1,5 @@
import type { TestInfo } from '@playwright/test';
import type { CoverageReportOptions } from 'monocart-coverage-reports';
import type { CoverageReport, CoverageReportOptions } from 'monocart-coverage-reports';
import { relative } from 'node:path';
/**
@ -88,3 +88,89 @@ export function specId(testInfo: TestInfo): string {
/** Filesystem-safe slug for a spec id (per-spec coverage directory name). */
export const slugify = (spec: string): string => spec.replace(/[^a-zA-Z0-9]+/g, '_');
/**
* Feed a V8 coverage list into a report in byte-bounded batches. MCR's `add()`
* JSON.stringify's each call's data into a single cache file, so one
* navigation-heavy page's list (duplicate bundle sources across navigations)
* can exceed V8's ~512MB string cap. Splitting by cumulative source bytes keeps
* every stringify well under it; MCR unions the cache files at `generate()`.
* Accepts a sync or async iterable so callers can stream entries off disk.
*/
export async function addV8CoverageInBatches(
report: CoverageReport,
entries: Iterable<unknown> | AsyncIterable<unknown>,
maxBytes = 200_000_000,
): Promise<void> {
let batch: unknown[] = [];
let bytes = 0;
for await (const entry of entries) {
batch.push(entry);
bytes += (entry as { source?: string }).source?.length ?? 0;
if (bytes >= maxBytes) {
await report.add(batch as never);
batch = [];
bytes = 0;
}
}
if (batch.length) await report.add(batch as never);
}
interface V8Range {
startOffset: number;
endOffset: number;
count: number;
}
interface V8Function {
functionName: string;
isBlockCoverage: boolean;
ranges: V8Range[];
}
export interface V8CoverageEntry {
url: string;
scriptId?: string;
source?: string;
functions: V8Function[];
}
/** Sum `source`'s function-range counts into `target` (same script, re-executed). */
function mergeFunctionCounts(target: V8Function[], source: V8Function[]): void {
const fnKey = (f: V8Function) => `${f.functionName}@${f.ranges[0]?.startOffset ?? 0}`;
const fnMap = new Map(target.map((f) => [fnKey(f), f]));
for (const sf of source) {
const tf = fnMap.get(fnKey(sf));
if (!tf) {
target.push(sf);
fnMap.set(fnKey(sf), sf);
continue;
}
const rKey = (r: V8Range) => `${r.startOffset}:${r.endOffset}`;
const rMap = new Map(tf.ranges.map((r) => [rKey(r), r]));
for (const sr of sf.ranges) {
const tr = rMap.get(rKey(sr));
if (tr) tr.count += sr.count;
else {
tf.ranges.push(sr);
rMap.set(rKey(sr), sr);
}
}
}
}
/**
* Collapse V8 coverage entries that share a URL into one. With
* resetOnNavigation:false the same bundle is re-loaded under a fresh scriptId on
* every navigation, so a navigation-heavy test ends up holding N full copies of
* each script's source — enough to OOM the worker heap or blow V8's ~512MB
* string cap. Keep one source per URL and sum execution counts per range — the
* same union MCR computes at generate(), done early so the raw never bloats.
*/
export function mergeV8CoverageByUrl(entries: V8CoverageEntry[]): V8CoverageEntry[] {
const byUrl = new Map<string, V8CoverageEntry>();
for (const entry of entries) {
const existing = byUrl.get(entry.url);
if (existing) mergeFunctionCounts(existing.functions, entry.functions);
else byUrl.set(entry.url, entry);
}
return [...byUrl.values()];
}

View File

@ -1,31 +1,27 @@
import type { BrowserContext, Page, TestInfo } from '@playwright/test';
import { CoverageReport } from 'monocart-coverage-reports';
import { mkdirSync, writeFileSync } from 'node:fs';
import { createWriteStream, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { pipeline } from 'node:stream/promises';
import {
addV8CoverageInBatches,
BY_SPEC_DIR,
coverageOptions,
COVERAGE_ENABLED,
mergeV8CoverageByUrl,
type V8CoverageEntry,
slugify,
specId,
} from '../coverage-options';
/**
* Browser-native V8 coverage collection (Chromium `page.coverage`), replacing
* the previous istanbul-instrumented build + Currents istanbul fixture.
* Browser-native V8 coverage (Chromium `page.coverage`): start JS coverage on
* every page at creation, and on teardown feed each page's coverage into the
* shared shard report AND a per-spec dir (`.by-spec/<slug>/` + a `.spec` marker)
* that the impact map keys on for test selection.
*
* 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.
* No-op unless COVERAGE_ENABLED.
*/
export const v8CoverageFixtures = {
context: async (
@ -65,8 +61,11 @@ export const v8CoverageFixtures = {
try {
const coverage = await page.coverage.stopJSCoverage();
if (coverage?.length) {
perSpecRaw.push(...coverage.map((entry) => structuredClone(entry)));
await sharedReport.add(coverage);
// Collapse the per-navigation duplicate scripts (resetOnNavigation:false)
// before they accumulate, so the worker heap / per-spec raw stay bounded.
const merged = mergeV8CoverageByUrl(coverage as V8CoverageEntry[]);
perSpecRaw.push(...merged.map((entry) => structuredClone(entry)));
await addV8CoverageInBatches(sharedReport, merged);
}
} catch {
// Page closed before collection — ignore.
@ -76,26 +75,15 @@ export const v8CoverageFixtures = {
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).
try {
writeFileSync(
join(specDir, `raw-${slugify(testInfo.testId)}.json`),
JSON.stringify(perSpecRaw),
);
writeFileSync(join(specDir, '.spec'), spec);
} catch (error) {
// V8 coverage entries include full script source text. Tests that navigate
// extensively (e.g. signout + signin in one test with resetOnNavigation:false)
// accumulate enough script entries that JSON.stringify hits V8's ~536MB string
// limit (RangeError: Invalid string length). The shard-level sharedReport
// already received the data above, so aggregate coverage is unaffected — only
// this test's per-spec impact map entry is dropped. Re-throw anything else: a
// real write failure (disk full, permissions) must not silently lose attribution.
if (!(error instanceof RangeError)) throw error;
console.warn(
`[coverage] per-spec raw write skipped for ${testInfo.titlePath.join(' > ')}: ${error.message}`,
);
}
// One V8 entry per line (JSONL): a whole-array JSON.stringify of a
// navigation-heavy spec exceeds V8's ~512MB string cap. Filename is unique
// per test so multiple tests in one spec file don't clobber each other.
const rawPath = join(specDir, `raw-${slugify(testInfo.testId)}.jsonl`);
const toJsonlLines = function* () {
for (const entry of perSpecRaw) yield `${JSON.stringify(entry)}\n`;
};
await pipeline(toJsonlLines(), createWriteStream(rawPath));
writeFileSync(join(specDir, '.spec'), spec);
}
},
};

View File

@ -11,16 +11,25 @@
* 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 { readFileSync } from 'node:fs';
import { createReadStream } from 'node:fs';
import { join } from 'node:path';
import { createInterface } from 'node:readline';
import { emitPerSpecLcovs } from '@n8n/test-impact';
import type { CoverageReport } from 'monocart-coverage-reports';
import { BY_SPEC_DIR, coverageOptions } from '../coverage-options';
import { addV8CoverageInBatches, BY_SPEC_DIR, coverageOptions } from '../coverage-options';
const OUT_DIR = join(coverageOptions.outputDir ?? './coverage', 'by-spec');
/** Stream a JSONL raw (one V8 entry per line) without materialising a >512MB string. */
async function* readJsonlEntries(path: string): AsyncGenerator<unknown> {
const lines = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
for await (const line of lines) {
if (line) yield JSON.parse(line);
}
}
async function main() {
const stats = await emitPerSpecLcovs({
bySpecDir: BY_SPEC_DIR,
@ -29,7 +38,7 @@ async function main() {
feedRaws: async (report: CoverageReport, rawFiles, dir) => {
for (const rf of rawFiles) {
try {
await report.add(JSON.parse(readFileSync(join(dir, rf), 'utf8')));
await addV8CoverageInBatches(report, readJsonlEntries(join(dir, rf)));
} catch (error) {
console.warn(` ⚠ ${rf}: ${String(error)}`);
}

View File

@ -21,7 +21,15 @@ 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]);
// The coverage build serves editor-ui bundles with INLINE sourcemaps
// (vite.config.mts: BUILD_WITH_COVERAGE), so page.coverage's captured `source`
// is several times larger than a normal minified bundle. Across a
// navigation-heavy spec that overflows the Playwright worker's default ~4GB
// heap (NODE_OPTIONS is unset for this job). Playwright forks workers with the
// parent env, so setting it here reaches them.
const test = sh('pnpm', ['test:container:coverage', ...forwarded], {
NODE_OPTIONS: '--max-old-space-size=8192',
});
// 2. Resolve coverage — best-effort, even when tests failed (we still want the data).
try {

View File

@ -43,36 +43,57 @@ test.describe(
test('should allow changing an inaccessible credential when the workflow was moved to a team project @auth:owner', async ({
n8n,
}) => {
await n8n.navigate.toCredentials();
await n8n.credentials.emptyListCreateCredentialButton.click();
// Seed the setup via API (none of it is under test): a personal Notion
// credential, a personal workflow whose Notion node is bound to it, and a
// team project the member can edit. Building this through the UI navigates
// enough that V8 coverage accumulation trips the ~512MB string cap; seeding
// keeps the run to a single page load. goHome() then loads the sidebar with
// the new resources (same pattern as projects-move-resources.spec.ts).
const credential = await n8n.api.credentials.createCredential({
name: 'Credential in Home project',
type: 'notionApi',
data: { apiKey: NOTION_API_KEY },
});
await n8n.credentials.createCredentialFromCredentialPicker(
'Notion API',
{
apiKey: NOTION_API_KEY,
await n8n.api.workflows.createWorkflow({
name: 'My workflow',
active: false,
nodes: [
{
id: nanoid(),
name: "When clicking 'Execute workflow'",
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: nanoid(),
name: 'Append a block',
type: 'n8n-nodes-base.notion',
typeVersion: 2.2,
position: [220, 0],
parameters: {},
credentials: { notionApi: { id: credential.id, name: credential.name } },
},
],
connections: {
"When clicking 'Execute workflow'": {
main: [[{ node: 'Append a block', type: 'main', index: 0 }]],
},
},
{
name: 'Credential in Home project',
},
);
await n8n.navigate.toWorkflows();
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(0);
await n8n.navigate.toWorkflow('new');
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(NOTION_NODE_NAME, { action: 'Append a block', closeNDV: true });
await n8n.canvas.waitForSaveWorkflowCompleted();
const { projectId, projectName } = await n8n.projectComposer.createProject('Project 1');
});
const { id: projectId, name: projectName } =
await n8n.api.projects.createProject('Project 1');
await n8n.api.projects.addUserToProjectByEmail(
projectId,
INSTANCE_MEMBER_CREDENTIALS[0].email,
'project:editor',
);
await n8n.goHome();
await n8n.sideBar.clickPersonalMenuItem();
await n8n.sideBar.clickWorkflowsLink();

View File

@ -52,8 +52,12 @@ describe('emitPerSpecLcovs (scan/skip logic)', () => {
writeFileSync(join(bs, 'no_marker', 'raw-1.json'), '{}');
mkdirSync(join(bs, 'no_raw'), { recursive: true });
writeFileSync(join(bs, 'no_raw', '.spec'), 'x');
// Frontend writes JSONL raws — discovery must find `.jsonl`, not just `.json`.
mkdirSync(join(bs, 'spec_jsonl'), { recursive: true });
writeFileSync(join(bs, 'spec_jsonl', '.spec'), 'tests/e2e/b.spec.ts');
writeFileSync(join(bs, 'spec_jsonl', 'raw-1.jsonl'), '{}\n');
const stats = await emitPerSpecLcovs(opts(() => false));
expect(stats).toEqual({ dirs: 3, withMarker: 2, withRaw: 1, emitted: 0 });
expect(stats).toEqual({ dirs: 4, withMarker: 3, withRaw: 2, emitted: 0 });
});
});

View File

@ -13,7 +13,8 @@ export function forceSpecTn(lcov: string, spec: string): string {
}
export interface EmitPerSpecLcovsOptions {
/** Dir of per-spec raw-coverage folders, each with a `.spec` marker + `raw-*.json`. */
/** Dir of per-spec raw-coverage folders, each with a `.spec` marker + `raw-*` file
* (`.json` backend `{result}` object, or `.jsonl` frontend one-entry-per-line). */
bySpecDir: string;
/** Where to write `<slug><suffix>.lcov`. */
outDir: string;
@ -63,7 +64,9 @@ export async function emitPerSpecLcovs(opts: EmitPerSpecLcovsOptions): Promise<E
if (!existsSync(specMarker)) continue;
stats.withMarker++;
const spec = readFileSync(specMarker, 'utf8').trim();
const rawFiles = readdirSync(dir).filter((f) => f.startsWith('raw-') && f.endsWith('.json'));
const rawFiles = readdirSync(dir).filter(
(f) => f.startsWith('raw-') && (f.endsWith('.json') || f.endsWith('.jsonl')),
);
if (!rawFiles.length) continue;
stats.withRaw++;