mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
8ea8bc3451
commit
d5bf2ef8fa
2
.github/test-metrics/e2e-impact-map.json
vendored
2
.github/test-metrics/e2e-impact-map.json
vendored
File diff suppressed because one or more lines are too long
74
packages/testing/playwright/coverage-options.test.ts
Normal file
74
packages/testing/playwright/coverage-options.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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()];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user