ci: Full nightly coverage report — unit + integration + E2E + Python merged into one lcov (no-changelog) (#31945)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Declan Carroll
2026-06-11 07:25:18 +01:00
committed by GitHub
parent b6a9e92f3b
commit 3923f1978f
15 changed files with 291 additions and 43 deletions
+1 -1
View File
@@ -65,5 +65,5 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
flags: backend-integration-postgres
name: backend-integration-postgres
files: ./packages/cli/coverage/cobertura-coverage.xml
files: ./packages/cli/coverage/lcov.info
fail_ci_if_error: false
+145 -13
View File
@@ -10,6 +10,19 @@ concurrency:
cancel-in-progress: true
jobs:
# Shared build so the Turbo remote cache is warm before any test job starts.
# Downstream jobs call setup-nodejs with the default build-command and get
# ~30s cache hits rather than racing to build in parallel from cold.
build:
name: Install & Build
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup and Build
uses: ./.github/actions/setup-nodejs
prepare-docker:
name: Prepare Docker (coverage)
uses: ./.github/workflows/prepare-docker-reusable.yml
@@ -24,6 +37,7 @@ jobs:
# then blew past the timeout; duration-weighting keeps every shard even.
generate-matrix:
name: Generate shard matrix
needs: build
runs-on: blacksmith-2vcpu-ubuntu-2204
outputs:
matrix: ${{ steps.gen.outputs.matrix }}
@@ -31,15 +45,15 @@ jobs:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup and Build janitor
- name: Setup
uses: ./.github/actions/setup-nodejs
with:
build-command: pnpm turbo run build --filter=@n8n/playwright-janitor
- name: Generate matrix (10 shards, duration-weighted)
- name: Generate matrix (16 shards, duration-weighted)
id: gen
run: |
MATRIX=$(node packages/testing/playwright/scripts/distribute-tests.mjs --matrix 10 --orchestrate)
MATRIX=$(node packages/testing/playwright/scripts/distribute-tests.mjs --matrix 16 --orchestrate)
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
e2e:
@@ -60,9 +74,114 @@ jobs:
currents-project-id: 'LRxcNt'
secrets: inherit
# Unit + integration coverage, one leg per suite — no change-scoping, run in
# parallel with the E2E shards (the shared `build` job keeps Turbo cache warm).
# The integration leg adds a Postgres pre-pull and runs from packages/cli to
# reach branches a mocked DB can't (real query paths, multi-main, migrations).
unit-coverage:
name: ${{ matrix.name }}
needs: build
runs-on: blacksmith-4vcpu-ubuntu-2204
strategy:
fail-fast: false
matrix:
include:
- name: Unit Coverage (backend + nodes)
artifact: unit-coverage
test-command: pnpm test:ci:backend:unit
artifact-path: packages/**/coverage/lcov.info
- name: Unit Coverage (frontend)
artifact: frontend-coverage
test-command: pnpm test:ci:frontend
artifact-path: packages/**/coverage/lcov.info
- name: Unit Coverage (CLI integration + Postgres)
artifact: integration-coverage
test-command: pnpm test:postgres:integration:tc
artifact-path: packages/cli/coverage/lcov.info
working-directory: packages/cli
pre-pull-containers: true
env:
COVERAGE_ENABLED: 'true'
NODE_OPTIONS: --max-old-space-size=7168
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup
uses: ./.github/actions/setup-nodejs
- name: Pre-pull Test Container Images
if: matrix.pre-pull-containers
run: pnpm tsx packages/testing/containers/pull-test-images.ts || true
- name: Run tests
working-directory: ${{ matrix.working-directory || github.workspace }}
run: ${{ matrix.test-command }}
- name: Upload coverage artifact
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ${{ matrix.artifact }}
path: ${{ matrix.artifact-path }}
retention-days: 3
# Both Python packages in one job — they're fast and share the uv install
# (each still pins its own Python version: 3.13 task-runner, 3.11 evals).
python-coverage:
name: Python Coverage (task-runner + ai-workflow-builder)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
with:
enable-cache: true
- name: Install just
uses: extractions/setup-just@e33e0265a09d6d736e2ee1e0eb685ef1de4669ff # v3.0.0
- name: Install Python 3.13 (task-runner)
working-directory: packages/@n8n/task-runner-python
run: uv python install 3.13
- name: Install task-runner dependencies
working-directory: packages/@n8n/task-runner-python
run: just sync-all
- name: Test task-runner-python
working-directory: packages/@n8n/task-runner-python
run: uv run pytest --cov=src --cov-report=xml --cov-report=term-missing
- name: Install Python 3.11 (ai-workflow-builder evals)
working-directory: packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python
run: uv python install 3.11
- name: Install ai-workflow-builder eval dependencies
working-directory: packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python
run: just sync-all
- name: Test ai-workflow-builder evals
working-directory: packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python
run: uv run pytest --cov=src --cov-report=xml --cov-report=term-missing
- name: Upload Python Coverage to Codecov
if: always()
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: |
packages/@n8n/task-runner-python/coverage.xml
packages/@n8n/ai-workflow-builder.ee/evaluations/programmatic/python/coverage.xml
flags: nightly-python
name: nightly-python-coverage
fail_ci_if_error: false
aggregate:
name: Aggregate Coverage
needs: e2e
needs: [e2e, unit-coverage, python-coverage]
if: always() && needs.e2e.result != 'skipped' && needs.e2e.result != 'cancelled'
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
@@ -72,21 +191,34 @@ jobs:
- name: Setup Environment
uses: ./.github/actions/setup-nodejs
- name: Download shard artifacts
- name: Download E2E shard artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
pattern: coverage-shard-*
path: /tmp/shards/
- name: Build janitor (tested merge-coverage)
# No merge-multiple: packages/cli/coverage/lcov.info exists in both
# unit-coverage and integration-coverage — separate artifact subdirs
# prevent clobbering, and findFiles recurses into all of them so MCR
# unions unit + integration coverage for CLI correctly.
- name: Download unit + integration + frontend coverage artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
pattern: '*-coverage'
path: /tmp/unit-lcovs/
- name: Build janitor (merge-coverage)
run: pnpm turbo run build --filter=@n8n/playwright-janitor
# Report (shard lcovs → unified lcov) + impact map (per-spec lcovs →
# spec-keyed map), both via the property-tested janitor merge-coverage.
# Logic lives in the tested aggregate-coverage.mjs, not inline bash.
# Merges all lcovs into one lcov.info (SF: paths normalised to repo-root-
# relative) and builds the per-spec E2E impact map for PR selection.
- name: Aggregate coverage + build impact map
working-directory: packages/testing/playwright
run: node scripts/aggregate-coverage.mjs --shards=/tmp/shards --out=coverage
run: |
node scripts/aggregate-coverage.mjs \
--shards=/tmp/shards \
--unit-shards=/tmp/unit-lcovs \
--out=coverage
- name: Upload Coverage Report Artifact
if: always()
@@ -96,14 +228,14 @@ jobs:
path: packages/testing/playwright/coverage/
retention-days: 14
- name: Upload E2E Coverage to Codecov
- name: Upload Combined Coverage to Codecov
if: always()
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: packages/testing/playwright/coverage/lcov.info
flags: frontend-e2e
name: playwright-e2e
flags: nightly-full
name: nightly-full-coverage
fail_ci_if_error: false
# Codecov needs ~60-90s to finish indexing the fresh upload before its
+2
View File
@@ -160,6 +160,8 @@ jobs:
packages/testing/playwright/playwright-report/
packages/testing/playwright/coverage/lcov.info
packages/testing/playwright/coverage/by-spec/
packages/testing/playwright/coverage/index.html
packages/testing/playwright/coverage/assets/
retention-days: 1
if-no-files-found: ignore
+4 -4
View File
@@ -89,7 +89,7 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
flags: backend-unit
name: backend-unit
files: ./packages/**/coverage/cobertura-coverage.xml,./coverage/cobertura-coverage.xml
files: ./packages/**/coverage/lcov.info
integration-test-backend:
name: Backend Integration Tests
@@ -149,7 +149,7 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
flags: backend-integration
name: backend-integration
files: ./packages/**/coverage/cobertura-coverage.xml,./coverage/cobertura-coverage.xml
files: ./packages/**/coverage/lcov.info
unit-test-nodes:
name: Nodes Unit Tests
@@ -194,7 +194,7 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
flags: nodes-unit
name: nodes-unit
files: ./packages/**/coverage/cobertura-coverage.xml,./coverage/cobertura-coverage.xml
files: ./packages/**/coverage/lcov.info
unit-test-frontend:
name: Frontend (${{ matrix.shard }}/2)
@@ -245,7 +245,7 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
flags: frontend
name: frontend-shard-${{ matrix.shard }}
files: ./packages/**/coverage/cobertura-coverage.xml,./coverage/cobertura-coverage.xml
files: ./packages/**/coverage/lcov.info
unit-test:
name: Unit tests
+6
View File
@@ -17,6 +17,8 @@ coverage:
project:
default:
threshold: 0.5
flags:
- nightly-full
github_checks:
annotations: false
@@ -36,6 +38,10 @@ flags:
carryforward: true
python:
carryforward: true
nightly-full:
carryforward: true
nightly-python:
carryforward: true
component_management:
default_rules:
+2 -1
View File
@@ -1,6 +1,7 @@
const { pathsToModuleNameMapper } = require('ts-jest');
const { compilerOptions } = require('get-tsconfig').getTsconfig().config;
const { resolve } = require('path');
const coverageExcludes = require('./jest.coverage-excludes');
/** @type {import('ts-jest').TsJestTransformerOptions} */
const tsJestOptions = {
@@ -100,7 +101,7 @@ const config = {
};
if (process.env.CI === 'true') {
config.collectCoverageFrom = ['src/**/*.ts'];
config.collectCoverageFrom = ['src/**/*.ts', ...coverageExcludes];
config.reporters = ['default', 'jest-junit'];
config.coverageReporters = ['cobertura'];
}
+11
View File
@@ -0,0 +1,11 @@
// Shared `collectCoverageFrom` exclusions for all jest packages. `**`-anchored
// so they apply regardless of the source roots a package collects from.
// Mirrored by packages/@n8n/vitest-config/coverage-excludes.ts (vitest) and the
// V8 path filter in packages/testing/playwright/coverage-options.ts — keep in sync.
module.exports = [
'!**/*.spec.ts',
'!**/*.test.ts',
'!**/__tests__/**',
'!**/__mocks__/**',
'!**/*.d.ts',
];
@@ -0,0 +1,16 @@
/**
* Files to drop from coverage. vitest 4 ships an empty default `exclude` and
* only auto-excludes the test files it runs, so type decls, mocks and test
* helpers otherwise land in the denominator at 0%. Spread these onto
* `coverageConfigDefaults.exclude` in each config.
*
* Mirrors the jest equivalent at the repo root (jest.coverage-excludes.js) —
* same intent, plain globs here vs `!`-negated globs there. Keep them in sync.
*/
export const coverageExcludes = [
'**/*.spec.ts',
'**/*.test.ts',
'**/__tests__/**',
'**/__mocks__/**',
'**/*.d.ts',
];
+6 -3
View File
@@ -1,6 +1,8 @@
import { defineConfig } from 'vitest/config';
import { coverageConfigDefaults, defineConfig } from 'vitest/config';
import type { InlineConfig } from 'vitest/node';
import { coverageExcludes } from './coverage-excludes.js';
export const createVitestConfig = (options: InlineConfig = {}) => {
const vitestConfig = defineConfig({
test: {
@@ -13,6 +15,7 @@ export const createVitestConfig = (options: InlineConfig = {}) => {
coverage: {
enabled: false,
include: ['src/**/*.{ts,vue}'],
exclude: [...coverageConfigDefaults.exclude, ...coverageExcludes],
provider: 'v8',
reporter: ['text-summary', 'lcov', 'html-spa'],
},
@@ -29,8 +32,8 @@ export const createVitestConfig = (options: InlineConfig = {}) => {
const { coverage } = vitestConfig.test;
coverage.enabled = true;
if (process.env.CI === 'true' && coverage.provider === 'v8') {
coverage.include = ['src/**'];
coverage.reporter = ['cobertura'];
coverage.include = ['src/**/*.{ts,vue}'];
coverage.reporter = ['lcov'];
}
}
+5 -2
View File
@@ -1,6 +1,8 @@
import { defineConfig } from 'vitest/config';
import { coverageConfigDefaults, defineConfig } from 'vitest/config';
import type { InlineConfig } from 'vitest/node';
import { coverageExcludes } from './coverage-excludes.js';
/**
* Shared test options without the outer defineConfig wrapper.
* Use this when you need to spread the config into workspace projects.
@@ -16,8 +18,9 @@ export const createBaseInlineConfig = (options: InlineConfig = {}): InlineConfig
coverage: {
enabled: true,
provider: 'v8',
reporter: process.env.CI === 'true' ? 'cobertura' : 'text-summary',
reporter: process.env.CI === 'true' ? 'lcov' : 'text-summary',
include: ['src/**/*.ts'],
exclude: [...coverageConfigDefaults.exclude, ...coverageExcludes],
},
}
: {}),
+6 -1
View File
@@ -5,7 +5,12 @@ process.env.TZ = 'UTC';
module.exports = {
...require('../../jest.config'),
testPathIgnorePatterns: ['/dist/', '/node_modules/', '\\.integration\\.test\\.ts$'],
collectCoverageFrom: ['credentials/**/*.ts', 'nodes/**/*.ts', 'utils/**/*.ts'],
collectCoverageFrom: [
'credentials/**/*.ts',
'nodes/**/*.ts',
'utils/**/*.ts',
...require('../../jest.coverage-excludes'),
],
globalSetup: '<rootDir>/test/globalSetup.ts',
setupFilesAfterEnv: ['jest-expect-message', '<rootDir>/test/setup.ts'],
};
@@ -27,9 +27,16 @@ export const coverageOptions: CoverageReportOptions = {
entry.url.includes('/assets/') || /\/packages\/[^/]+(?:\/[^/]+)?\/dist\//.test(entry.url),
// Keep first-party application source after source-map expansion; drop deps
// and any unmapped dist files. NB nodes-base sources live under `nodes/`,
// `credentials/` etc — not `src/` — so don't require `/src/`.
// `credentials/` etc — not `src/` — so don't require `/src/`. The test/mock
// exclusions mirror jest.coverage-excludes.js — keep them in sync.
sourceFilter: (sourcePath) =>
!sourcePath.includes('node_modules') && !sourcePath.includes('/dist/'),
!sourcePath.includes('node_modules') &&
!sourcePath.includes('/dist/') &&
!sourcePath.endsWith('.d.ts') &&
!sourcePath.endsWith('.spec.ts') &&
!sourcePath.endsWith('.test.ts') &&
!sourcePath.includes('/__tests__/') &&
!sourcePath.includes('/__mocks__/'),
// Key Codecov + the impact map on repo-relative `packages/.../src/...`.
// Backend map-sources resolve to absolute repo paths (package-qualified);
// the frontend bundle resolves relative, so its `src/...` sources have no
@@ -77,11 +77,25 @@ export const v8CoverageFixtures = {
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}`,
);
}
}
},
};
@@ -262,7 +262,15 @@ export function getProjects(): Project[] {
testIgnore: INSTANCE_AI_E2E_IGNORE,
timeout: 60000,
fullyParallel: true,
use: { containerConfig: {} },
use: {
containerConfig: {},
// Capture only on failure (global default is `on`). The shard artifact
// is downloaded and aggregated each run, so keep it to coverage data
// plus failure diagnostics, not full traces/videos for every test.
trace: 'retain-on-failure',
video: 'retain-on-failure',
screenshot: 'only-on-failure',
},
});
projects.push({
@@ -7,21 +7,38 @@
* `merge-coverage`. Replaces brittle inline `find`/`cp` bash in the workflows —
* the janitor CLI path lives here, in one place, instead of per-YAML-string.
*
* node aggregate-coverage.mjs --shards=<dir> --out=<dir>
* node aggregate-coverage.mjs --shards=<dir> --out=<dir> [--unit-shards=<dir>]
*
* --unit-shards: directory of downloaded unit + integration + frontend lcov
* artifacts. Jest and vitest write absolute SF: paths in CI
* (/home/runner/work/n8n/n8n/packages/cli/src/foo.ts); they are normalised
* to repo-root-relative before merging with the E2E lcovs so MCR sees the
* same key across all three suites.
*/
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync, rmSync } from 'node:fs';
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
readdirSync,
statSync,
rmSync,
} from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const JANITOR_CLI = path.resolve(__dirname, '..', '..', 'janitor', 'dist', 'cli.js');
// scripts/ → playwright/ → testing/ → packages/ → repo root
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..') + path.sep;
const arg = (name, fallback) =>
process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? fallback;
const SHARDS = arg('shards', '/tmp/shards');
const UNIT_SHARDS = arg('unit-shards', null);
const OUT = arg('out', './coverage');
/** Recursively collect files under `dir` matching `predicate(filename)`. */
@@ -35,12 +52,26 @@ function findFiles(dir, predicate, acc = []) {
return acc;
}
/** Stage matching files into a fresh temp dir with unique names, return the dir. */
/**
* Rewrite absolute SF: paths to repo-root-relative. Jest and vitest write
* absolute paths in CI; E2E lcovs are already repo-root-relative so the
* regex is a no-op for them. Merging everything through the same transform
* means MCR sees identical keys across all three suites.
*/
function normalizeLcov(content) {
return content.replace(/^SF:(.+)$/gm, (line, p) =>
p.startsWith(REPO_ROOT) ? `SF:${p.slice(REPO_ROOT.length)}` : line,
);
}
/** Stage files into a fresh temp dir with unique names, normalising SF: paths. */
function stage(label, files) {
const dir = path.join('/tmp', `agg-${label}`);
rmSync(dir, { recursive: true, force: true });
mkdirSync(dir, { recursive: true });
files.forEach((f, i) => copyFileSync(f, path.join(dir, `${label}-${i}.lcov`)));
files.forEach((f, i) => {
writeFileSync(path.join(dir, `${label}-${i}.lcov`), normalizeLcov(readFileSync(f, 'utf8')));
});
return dir;
}
@@ -60,16 +91,25 @@ function merge(inputsDir, outLcov, outMap) {
mkdirSync(OUT, { recursive: true });
// 1. Report: shard-level lcovs (frontend + backend) → unified lcov for Codecov.
const shardLcovs = findFiles(SHARDS, (name) => name === 'lcov.info');
console.log(`Report: ${shardLcovs.length} shard lcov(s)`);
if (shardLcovs.length) {
merge(stage('report', shardLcovs), path.join(OUT, 'lcov.info'), '/tmp/agg-report-map.json');
const unitLcovs = UNIT_SHARDS ? findFiles(UNIT_SHARDS, (name) => name === 'lcov.info') : [];
const allReportLcovs = [...shardLcovs, ...unitLcovs];
// 1. Combined report → lcov.info (all layers: E2E + unit + integration + frontend).
// Uploaded to Codecov as a single `nightly-full` flag — one authoritative number
// rather than per-layer flags that require an approximated union at query time.
console.log(
`Report: ${shardLcovs.length} E2E shard lcov(s), ${unitLcovs.length} unit/integration/frontend lcov(s)`,
);
if (allReportLcovs.length) {
merge(stage('report', allReportLcovs), path.join(OUT, 'lcov.info'), '/tmp/agg-report-map.json');
} else {
console.warn(' ⚠ no shard lcov.info found — skipping report');
console.warn(' ⚠ no lcovs found — skipping report');
}
// 2. Impact map: per-spec frontend lcovs (TN-tagged) → spec-keyed map for selection.
// 2. Impact map: per-spec E2E lcovs (TN-tagged) → spec-keyed map for PR selection.
// Unit/integration lcovs are not TN-tagged per test so they stay out of the
// impact map for now — cross-layer per-test attribution is Phase 3 (DEVP-293).
const specLcovs = findFiles(
SHARDS,
(name, p) => name.endsWith('.lcov') && p.includes(`${path.sep}by-spec${path.sep}`),