ci: Scope frontend + nodes unit tests via janitor (DEVP-194) (#31096)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Declan Carroll 2026-05-27 14:54:57 +01:00 committed by GitHub
parent 29fd8ccc4e
commit e495833a64
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1082 additions and 70 deletions

View File

@ -34,6 +34,8 @@ jobs:
merge_base: ${{ steps.ci-filter.outputs.merge-base }}
matrix: ${{ steps.generate-matrix.outputs.matrix }}
skip_tests: ${{ steps.generate-matrix.outputs.skip-tests }}
affected_packages: ${{ steps.affected-packages.outputs.list }}
changed_files: ${{ steps.ci-filter.outputs.changed-files }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@ -145,6 +147,16 @@ jobs:
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
echo "skip-tests=$(node -e "process.stdout.write(JSON.parse(process.argv[1])[0]?.skip === true ? 'true' : 'false')" "$MATRIX")" >> "$GITHUB_OUTPUT"
- name: Compute affected packages
id: affected-packages
if: fromJSON(steps.ci-filter.outputs.results).unit
env:
CHANGED_FILES: ${{ steps.ci-filter.outputs.changed-files }}
run: |
PACKAGES=$(node packages/testing/janitor/dist/cli.js affected-packages | tr '\n' ' ' | sed 's/ *$//')
echo "Affected packages: $PACKAGES"
echo "list=$PACKAGES" >> "$GITHUB_OUTPUT"
unit-test:
name: Unit tests
if: needs.install-and-build.outputs.unit == 'true'
@ -153,6 +165,8 @@ jobs:
with:
ref: ${{ needs.install-and-build.outputs.commit_sha }}
collectCoverage: ${{ github.event_name != 'merge_group' }}
affectedPackages: ${{ needs.install-and-build.outputs.affected_packages }}
changedFiles: ${{ needs.install-and-build.outputs.changed_files }}
secrets: inherit
typecheck:

View File

@ -17,6 +17,23 @@ on:
required: false
default: false
type: boolean
affectedPackages:
description: |
Space-separated list of workspace packages affected by the PR, as
computed by `janitor affected-packages` in install-and-build. Empty
string means "no scoping data, run everything".
required: false
default: ''
type: string
changedFiles:
description: |
Newline-separated list of CHANGED_FILES from ci-filter. Passed to
per-package `janitor scope` invocations so test runners can filter
to actually-changed source files via jest --findRelatedTests or
vitest related.
required: false
default: ''
type: string
env:
NODE_OPTIONS: --max-old-space-size=7168
@ -26,6 +43,8 @@ jobs:
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
env:
COVERAGE_ENABLED: ${{ inputs.collectCoverage }} # Coverage collected when true
AFFECTED_PACKAGES: ${{ inputs.affectedPackages }}
CHANGED_FILES: ${{ inputs.changedFiles }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@ -69,6 +88,8 @@ jobs:
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
env:
COVERAGE_ENABLED: ${{ inputs.collectCoverage }} # Coverage collected when true
AFFECTED_PACKAGES: ${{ inputs.affectedPackages }}
CHANGED_FILES: ${{ inputs.changedFiles }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@ -112,13 +133,12 @@ jobs:
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
env:
COVERAGE_ENABLED: ${{ inputs.collectCoverage }} # Coverage collected when true
MERGE_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || '' }}
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || '' }}
AFFECTED_PACKAGES: ${{ inputs.affectedPackages }}
CHANGED_FILES: ${{ inputs.changedFiles }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
- name: Build
uses: ./.github/actions/setup-nodejs
@ -126,12 +146,7 @@ jobs:
node-version: ${{ inputs.nodeVersion }}
- name: Test Nodes
run: |
if [ -n "${MERGE_BASE:-}" ]; then
pnpm turbo test:changed --filter=n8n-nodes-base --affected --summarize
else
pnpm turbo test --filter=n8n-nodes-base --summarize
fi
run: pnpm turbo test:changed --filter=n8n-nodes-base --summarize
- name: Send Test Stats
if: ${{ !cancelled() }}
@ -167,6 +182,8 @@ jobs:
shard: [1, 2]
env:
COVERAGE_ENABLED: ${{ inputs.collectCoverage }}
AFFECTED_PACKAGES: ${{ inputs.affectedPackages }}
CHANGED_FILES: ${{ inputs.changedFiles }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@ -178,7 +195,7 @@ jobs:
node-version: ${{ inputs.nodeVersion }}
- name: Test
run: pnpm test:ci:frontend --summarize -- --shard=${{ matrix.shard }}/2
run: pnpm test:ci:frontend:changed --summarize -- --shard=${{ matrix.shard }}/2
env:
VITEST_SHARD: ${{ matrix.shard }}/2

View File

@ -44,6 +44,7 @@
"test": "JEST_JUNIT_CLASSNAME={filepath} turbo run test",
"test:ci": "turbo run test --continue --concurrency=1",
"test:ci:frontend": "turbo run test --continue --filter='./packages/frontend/**'",
"test:ci:frontend:changed": "turbo run test:changed --continue --filter='./packages/frontend/**'",
"test:ci:backend": "turbo run test --continue --concurrency=1 --filter='!./packages/frontend/**'",
"test:ci:backend:unit": "turbo run test:unit --continue --filter='!./packages/frontend/**'",
"test:ci:backend:integration": "turbo run test:integration --continue --concurrency=1 --filter='!./packages/frontend/**'",

View File

@ -9,6 +9,7 @@
"preview": "vite preview",
"test:dev": "vitest",
"test": "vitest run",
"test:changed": "janitor test-scoped --runner=vitest",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint src --quiet",
"lint:fix": "eslint src --fix",
@ -47,6 +48,7 @@
"devDependencies": {
"@iconify-json/mdi": "^1.1.54",
"@n8n/eslint-config": "workspace:*",
"@n8n/playwright-janitor": "workspace:*",
"@n8n/stylelint-config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",

View File

@ -18,6 +18,7 @@
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run",
"test:changed": "janitor test-scoped --runner=vitest",
"test:dev": "vitest --silent=false",
"lint": "eslint src --quiet",
"lint:fix": "eslint src --fix",
@ -26,6 +27,7 @@
},
"devDependencies": {
"@n8n/eslint-config": "workspace:*",
"@n8n/playwright-janitor": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@testing-library/vue": "catalog:frontend",

View File

@ -11,6 +11,7 @@
"typecheck": "vue-tsc --noEmit",
"typecheck:watch": "vue-tsc --watch --noEmit",
"test": "vitest run",
"test:changed": "janitor test-scoped --runner=vitest",
"test:dev": "vitest",
"chromatic": "chromatic",
"format": "biome format --write . && prettier --write . --ignore-path ../../../../.prettierignore",
@ -22,6 +23,7 @@
},
"devDependencies": {
"@n8n/eslint-config": "workspace:*",
"@n8n/playwright-janitor": "workspace:*",
"@n8n/stylelint-config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",

View File

@ -26,6 +26,7 @@
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run",
"test:changed": "janitor test-scoped --runner=vitest",
"test:dev": "vitest --silent=false",
"lint": "eslint src --quiet",
"lint:fix": "eslint src --fix",
@ -38,6 +39,7 @@
},
"devDependencies": {
"@n8n/eslint-config": "workspace:*",
"@n8n/playwright-janitor": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@testing-library/jest-dom": "catalog:frontend",

View File

@ -26,6 +26,7 @@
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run",
"test:changed": "janitor test-scoped --runner=vitest",
"test:dev": "vitest --silent=false",
"lint": "eslint src --quiet",
"lint:fix": "eslint src --fix",
@ -45,6 +46,7 @@
"devDependencies": {
"@n8n/eslint-config": "workspace:*",
"@n8n/i18n": "workspace:*",
"@n8n/playwright-janitor": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@testing-library/jest-dom": "catalog:frontend",

View File

@ -26,6 +26,7 @@
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run",
"test:changed": "janitor test-scoped --runner=vitest",
"test:dev": "vitest --silent=false",
"lint": "eslint src --quiet",
"lint:fix": "eslint src --fix",
@ -37,6 +38,7 @@
},
"devDependencies": {
"@n8n/eslint-config": "workspace:*",
"@n8n/playwright-janitor": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@testing-library/jest-dom": "catalog:frontend",

View File

@ -19,6 +19,7 @@
"format:check": "biome ci . && prettier --check . --ignore-path ../../../.prettierignore",
"serve": "cross-env VUE_APP_URL_BASE_API=http://localhost:5678/ vite --host 0.0.0.0 --port 8080 dev",
"test": "vitest run",
"test:changed": "janitor test-scoped --runner=vitest",
"test:dev": "vitest --silent=false"
},
"dependencies": {
@ -118,6 +119,7 @@
"@faker-js/faker": "^8.0.2",
"@iconify/json": "^2.2.349",
"@n8n/eslint-config": "workspace:*",
"@n8n/playwright-janitor": "workspace:*",
"@n8n/stylelint-config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",

View File

@ -15,7 +15,7 @@
"lint:fix": "eslint nodes credentials utils test --fix",
"watch": "tsc-watch -p tsconfig.build.cjs.json --onCompilationComplete \"pnpm copy-nodes-json && tsc-alias -p tsconfig.build.cjs.json\" --onSuccess \"pnpm n8n-generate-metadata\"",
"test": "jest",
"test:changed": "node scripts/test-changed.mjs",
"test:changed": "janitor test-scoped --runner=jest",
"test:dev": "jest --watch",
"test:integration:skip": "vitest run --config vitest.integration.config.ts"
},
@ -869,6 +869,7 @@
"devDependencies": {
"@n8n/client-oauth2": "workspace:*",
"@n8n/eslint-plugin-community-nodes": "workspace:*",
"@n8n/playwright-janitor": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@types/amqplib": "^0.10.1",

View File

@ -1,40 +0,0 @@
#!/usr/bin/env node
import { execSync, spawnSync } from 'node:child_process';
const mergeBase = process.env.MERGE_BASE;
const cwd = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim();
const log = (m) => console.log(`[test-changed] ${m}`);
const diff = (range, paths = '') =>
execSync(`git diff --name-only ${range} ${paths}`.trim(), { cwd, encoding: 'utf-8' }).trim();
const runJest = (extra = []) => {
const r = spawnSync('jest', [...extra, ...process.argv.slice(2)], { stdio: 'inherit' });
process.exit(r.status ?? 1);
};
if (!mergeBase) {
log('MERGE_BASE unset → full');
runJest();
}
// Turbo --affected can invoke us when only an upstream package changed, in
// which case jest --changedSince would walk an empty in-package diff and run
// zero tests — the one direction we must never take.
const inPackage = diff(`${mergeBase}...HEAD`, '-- packages/nodes-base/');
if (!inPackage) {
log('upstream-only change → full');
runJest();
}
if (/\/(jest\.config\.js|test\/(setup|globalSetup)\.ts|package\.json)$/m.test(inPackage)) {
log('in-package config change → full');
runJest();
}
const all = diff(`${mergeBase}...HEAD`);
if (/^(pnpm-lock\.yaml|package\.json|packages\/cli\/src\/public-api\/v1\/)/m.test(all)) {
log('cross-cutting change → full');
runJest();
}
log(`scoping via jest --changedSince=${mergeBase}`);
runJest([`--changedSince=${mergeBase}`]);

View File

@ -127,7 +127,7 @@ For existing codebases with many violations, use a baseline to enable incrementa
```bash
# Create baseline of current violations
playwright-janitor baseline
janitor baseline
# Commit the baseline
git add .janitor-baseline.json
@ -140,10 +140,10 @@ Once a baseline exists, janitor and TCR **only fail on new violations**. Pre-exi
```bash
# This now passes (only checks for NEW violations)
playwright-janitor tcr --execute -m="Add new feature"
janitor tcr --execute -m="Add new feature"
# As you fix violations, update the baseline (manual commit required - TCR won't commit baseline changes)
playwright-janitor baseline
janitor baseline
git add .janitor-baseline.json
git commit -m "chore: update baseline after cleanup"
```
@ -156,13 +156,13 @@ View all available rules with their descriptions:
```bash
# Human-readable list
playwright-janitor rules
janitor rules
# JSON output (for AI agents/automation)
playwright-janitor rules --json
janitor rules --json
# Verbose (includes target globs)
playwright-janitor rules --verbose
janitor rules --verbose
```
The JSON output is useful for AI agents that need to understand the rules before writing code.
@ -173,20 +173,76 @@ Discover test specs via AST analysis and distribute them across CI shards:
```bash
# Discover specs and capabilities (JSON output)
playwright-janitor discover
janitor discover
# Distribute specs across shards (JSON output)
playwright-janitor orchestrate --shards=14
janitor orchestrate --shards=14
# Get specs for a single shard (0-indexed)
playwright-janitor orchestrate --shards=14 --shard-index=0
janitor orchestrate --shards=14 --shard-index=0
# Only include specs affected by git changes
playwright-janitor orchestrate --shards=14 --impact
janitor orchestrate --shards=14 --impact
```
Discovery detects `test.fixme()` and `test.skip()` via AST and excludes them automatically. Capability tags (`@capability:proxy`) are extracted for grouping.
### Workspace-Wide CI Test Scoping
The `affected-packages`, `scope`, and `test-scoped` subcommands operate at
the workspace level — they do not require a `janitor.config.js` and can be
invoked from any package via `pnpm exec janitor ...` (the package's bin is
named `janitor`). They replace the need for `turbo run --affected`, which
requires git history on the runner and is incompatible with `fetch-depth: 1`
checkouts.
**Pipeline:**
```
ci-filter (in install-and-build)
└─→ CHANGED_FILES (newline-separated)
├─→ janitor affected-packages (walks pnpm workspace dep graph)
│ │
│ └─→ AFFECTED_PACKAGES (space-separated list)
│ │
│ └─→ passed to test jobs as workflow input
└─→ CHANGED_FILES forwarded to test jobs
└─→ janitor test-scoped --runner=jest|vitest (per-package)
├─→ SKIP → exit 0 (no in-package changes)
├─→ RUN_FULL → spawn runner with no scope flags
└─→ scoped → jest --findRelatedTests / vitest related
```
**Usage:**
```bash
# Walk the workspace dep graph. Output: one package name per line.
CHANGED_FILES="packages/workflow/src/x.ts" janitor affected-packages
# Compute scope for the cwd package. Output: SKIP | RUN_FULL | <files>
janitor scope --runner=vitest
# Compute scope AND spawn the runner. Unrecognised flags forward to runner.
janitor test-scoped --runner=vitest --shard=1/2 --coverage
```
**Bailout triggers (force ALL packages):** `pnpm-lock.yaml`, root `package.json`.
**Per-package bailout (force full suite):** `jest.config.*`, `vitest.config.*`,
`vite.config.*` (vitest reads vite config), `package.json`, `tsconfig.*`,
plus setup files at `<pkg>/jest.setup.*`, `<pkg>/vitest.setup.*`, and
`<pkg>/src/__tests__/setup.*`. The scope analyzer detects these and emits
`RUN_FULL`; `test-scoped` then spawns the runner without scope flags.
**Turbo extra inputs:** `n8n-nodes-base#test`'s declared input
`../cli/src/public-api/v1/**/*.yml` is honoured — a change to that yml
marks nodes-base as affected.
## Rules
### Architecture Rules

View File

@ -0,0 +1,10 @@
#!/usr/bin/env node
// Shim that exists in the source tree so pnpm can create the bin symlink at
// install time, before `pnpm build` produces dist/cli.js. Re-imports the
// built entry at invocation time, by which point the build has run via
// turbo's `^build` dependency on consuming tasks.
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
await import(resolve(here, '..', 'dist', 'cli.js'));

View File

@ -4,7 +4,7 @@ import { baseConfig } from '@n8n/eslint-config/base';
export default defineConfig(
baseConfig,
{
ignores: ['coverage/**'],
ignores: ['coverage/**', 'bin/**'],
},
{
rules: {

View File

@ -5,7 +5,11 @@
"description": "Static analysis and architecture enforcement for Playwright test suites",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"janitor": "bin/janitor.mjs"
},
"files": [
"bin",
"dist"
],
"scripts": {
@ -30,7 +34,8 @@
"license": "MIT",
"dependencies": {
"@n8n/rules-engine": "workspace:*",
"glob": "^10.0.0"
"glob": "^10.0.0",
"yaml": "catalog:"
},
"peerDependencies": {
"ts-morph": "catalog:"

View File

@ -1,13 +1,19 @@
#!/usr/bin/env node
/**
* Playwright Janitor CLI
* Janitor CLI
*
* Usage:
* playwright-janitor # Run all rules
* playwright-janitor inventory # Show codebase inventory
* playwright-janitor impact # Show impact of changes
* playwright-janitor tcr # TCR workflow
* playwright-janitor --help # Show help
* janitor # Run all rules
* janitor inventory # Show codebase inventory
* janitor impact # Show impact of changes
* janitor tcr # TCR workflow
* janitor affected-packages # List workspace packages affected by changed files
* janitor scope # Compute per-package jest/vitest scope list
* janitor --help # Show help
*
* The `affected-packages` and `scope` subcommands are workspace-wide utilities
* for CI test scoping they do not require a janitor.config.js and can be
* invoked from any package via `pnpm exec janitor ...`.
*/
import * as fs from 'node:fs';
@ -25,8 +31,12 @@ import {
showTcrHelp,
showDiscoverHelp,
showOrchestrateHelp,
showAffectedPackagesHelp,
showScopeHelp,
showTestScopedHelp,
} from './cli/index.js';
import { setConfig, getConfig, defineConfig, type JanitorConfig } from './config.js';
import { affectedPackages, findWorkspaceRoot } from './core/affected-packages-analyzer.js';
import {
generateBaseline,
saveBaseline,
@ -64,8 +74,10 @@ import { orchestrate } from './core/orchestrator.js';
import { createProject } from './core/project-loader.js';
import { toJSON, toConsole, printFixResults } from './core/reporter.js';
import { filterToFailedSpecs } from './core/retry-filter.js';
import { computeScope, formatScope } from './core/scope-analyzer.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';
import { createDefaultRunner } from './index.js';
import type { RunOptions } from './types.js';
import { resolveInputPaths } from './utils/paths.js';
@ -576,6 +588,62 @@ async function runOrchestrate(options: CliOptions): Promise<void> {
console.log(JSON.stringify(result, null, 2));
}
/**
* Read CHANGED_FILES from --changed-files flag or env. Returns null when
* neither is set callers treat that as "no signal, run everything" so local
* dev (`pnpm test:changed` with no env) doesn't silently skip tests.
*/
function readChangedFiles(options: CliOptions): string[] | null {
const flag = options.changedFiles;
const env = process.env.CHANGED_FILES;
if (flag === undefined && env === undefined) return null;
const raw = flag ?? env ?? '';
return raw
.split(/[\n,]+/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
function runAffectedPackages(options: CliOptions): void {
const result = affectedPackages({
rootDir: findWorkspaceRoot(process.cwd()),
changedFiles: readChangedFiles(options),
});
console.log(result.join('\n'));
}
function runTestScopedCmd(options: CliOptions): void {
if (!options.runner) {
console.error('Error: --runner=jest|vitest is required');
process.exit(1);
}
const packageDir = options.packageDir ?? process.cwd();
const changedFiles = readChangedFiles(options);
const exitCode = runTestScoped({
runner: options.runner,
packageDir,
rootDir: findWorkspaceRoot(process.cwd()),
changedFiles,
passthroughArgs: options.passthroughArgs,
});
process.exit(exitCode);
}
function runScope(options: CliOptions): void {
if (!options.runner) {
console.error('Error: --runner=jest|vitest is required');
process.exit(1);
}
const result = computeScope({
runner: options.runner,
packageDir: options.packageDir ?? process.cwd(),
changedFiles: readChangedFiles(options),
rootDir: findWorkspaceRoot(process.cwd()),
});
console.log(formatScope(result));
}
async function main(): Promise<void> {
const options = parseArgs();
@ -606,12 +674,35 @@ async function main(): Promise<void> {
case 'orchestrate':
showOrchestrateHelp();
break;
case 'affected-packages':
showAffectedPackagesHelp();
break;
case 'scope':
showScopeHelp();
break;
case 'test-scoped':
showTestScopedHelp();
break;
default:
showHelp();
}
return;
}
// Workspace-wide CI utilities: no config file required.
if (options.command === 'affected-packages') {
runAffectedPackages(options);
return;
}
if (options.command === 'scope') {
runScope(options);
return;
}
if (options.command === 'test-scoped') {
runTestScopedCmd(options);
return;
}
if (options.list) {
const runner = createDefaultRunner();
console.log('\nAvailable rules:\n');

View File

@ -15,6 +15,9 @@ export type Command =
| 'rules'
| 'discover'
| 'orchestrate'
| 'affected-packages'
| 'scope'
| 'test-scoped'
| 'filter-shard';
export interface CliOptions {
@ -51,6 +54,12 @@ export interface CliOptions {
shards?: number;
shardIndex?: number;
impact: boolean;
// Affected-packages / scope options
changedFiles?: string;
runner?: 'jest' | 'vitest';
packageDir?: string;
/** Anything after `--` — forwarded to the test runner by `test-scoped`. */
passthroughArgs: string[];
// filter-shard-specific options
url?: string;
}
@ -64,6 +73,9 @@ const SUBCOMMANDS: Record<string, Command> = {
rules: 'rules',
discover: 'discover',
orchestrate: 'orchestrate',
'affected-packages': 'affected-packages',
scope: 'scope',
'test-scoped': 'test-scoped',
'filter-shard': 'filter-shard',
};
@ -171,6 +183,19 @@ const VALUE_FLAG_HANDLERS: Record<string, (options: CliOptions, value: string) =
'--shard-index=': (opts, value) => {
opts.shardIndex = Number.parseInt(value, 10);
},
'--changed-files=': (opts, value) => {
opts.changedFiles = value;
},
'--runner=': (opts, value) => {
if (value === 'jest' || value === 'vitest') {
opts.runner = value;
} else {
throw new Error(`Unknown --runner=${value}. Expected 'jest' or 'vitest'.`);
}
},
'--package-dir=': (opts, value) => {
opts.packageDir = value;
},
'--url=': (opts, value) => {
opts.url = value;
},
@ -204,6 +229,10 @@ function createDefaultOptions(): CliOptions {
shards: undefined,
shardIndex: undefined,
impact: false,
changedFiles: undefined,
runner: undefined,
packageDir: undefined,
passthroughArgs: [],
url: undefined,
};
}
@ -218,10 +247,29 @@ function parseSubcommand(args: string[]): { command: Command; startIdx: number }
return { command: 'analyze', startIdx: 0 };
}
/**
* For test-scoped, unrecognised flags must forward to the underlying runner
* (jest/vitest) they can't be silently dropped because consumers compose
* extra flags via turbo + npm script chains. For all other subcommands the
* passthrough list stays empty and unused.
*/
const PASSTHROUGH_COMMANDS = new Set<Command>(['test-scoped']);
function parseFlags(args: string[], startIdx: number, options: CliOptions): void {
const allowPassthrough = PASSTHROUGH_COMMANDS.has(options.command);
let passthroughActive = false;
for (let i = startIdx; i < args.length; i++) {
const arg = args[i];
if (passthroughActive) {
options.passthroughArgs.push(arg);
continue;
}
if (arg === '--') {
passthroughActive = true;
continue;
}
// Handle simple flags
const handler = FLAG_HANDLERS[arg];
if (handler) {
@ -230,13 +278,18 @@ function parseFlags(args: string[], startIdx: number, options: CliOptions): void
}
// Handle value flags
let matched = false;
for (const [prefix, valueHandler] of Object.entries(VALUE_FLAG_HANDLERS)) {
if (arg.startsWith(prefix)) {
const value = arg.slice(prefix.length);
valueHandler(options, value);
matched = true;
break;
}
}
if (!matched && allowPassthrough) {
options.passthroughArgs.push(arg);
}
}
}

View File

@ -210,6 +210,67 @@ Examples:
`);
}
// --changed-files accepts repo-root-relative paths, newline-separated OR
// comma-separated. Same format as ci-filter's `changed-files` output.
// Example: --changed-files="packages/cli/src/x.ts
// packages/workflow/src/y.ts"
export function showAffectedPackagesHelp(): void {
console.log(`
Affected Packages - List workspace packages affected by changed files
Walks the pnpm workspace dependency graph: directly-affected packages plus
everything transitively downstream. Outputs one package name per line.
Usage:
janitor affected-packages [--changed-files=<list>]
--changed-files: newline- OR comma-separated repo-root-relative paths.
Defaults to $CHANGED_FILES env var.
When neither --changed-files nor $CHANGED_FILES is set, returns ALL packages
(safe default for local dev).
Bailout triggers (return ALL packages): pnpm-lock.yaml, root package.json.
`);
}
export function showScopeHelp(): void {
console.log(`
Scope - Per-package jest/vitest scope from changed files
Usage:
janitor scope --runner=<jest|vitest> [--package-dir=<dir>] [--changed-files=<list>]
--package-dir: defaults to cwd (matches how pnpm/turbo invoke test scripts).
--changed-files: newline- OR comma-separated repo-root-relative paths.
Defaults to $CHANGED_FILES env var.
Output (single line on stdout):
SKIP No in-package files changed
RUN_FULL Config file changed, OR no CHANGED_FILES signal (local dev)
<files> Pass to jest --findRelatedTests / vitest related
`);
}
export function showTestScopedHelp(): void {
console.log(`
Test-Scoped - Compute scope and spawn jest/vitest with the right flags
Usage:
janitor test-scoped --runner=<jest|vitest> [--package-dir=<dir>] [--changed-files=<list>] [extra runner args]
--package-dir: defaults to cwd (matches how pnpm/turbo invoke test scripts).
--changed-files: newline- OR comma-separated repo-root-relative paths.
Defaults to $CHANGED_FILES env var.
Local dev (no $CHANGED_FILES set): runs the full suite.
CI: scopes via jest --findRelatedTests / vitest related --run, or skips
if the package wasn't touched. Unrecognised flags are forwarded to the
runner.
`);
}
export function showRulesHelp(): void {
console.log(`
Rules - Show detailed information about available rules

View File

@ -9,4 +9,7 @@ export {
showTcrHelp,
showDiscoverHelp,
showOrchestrateHelp,
showAffectedPackagesHelp,
showScopeHelp,
showTestScopedHelp,
} from './help.js';

View File

@ -0,0 +1,154 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { affectedPackages, findWorkspaceRoot } from './affected-packages-analyzer.js';
interface PackageSpec {
name: string;
deps?: string[];
}
interface TurboTaskSpec {
taskId: string;
inputs: string[];
}
function makeFixture(opts: {
patterns: string[];
packages: Record<string, PackageSpec>;
turboTasks?: TurboTaskSpec[];
}): string {
const root = join(tmpdir(), `janitor-affected-${Math.random().toString(36).slice(2)}`);
mkdirSync(root, { recursive: true });
writeFileSync(
join(root, 'pnpm-workspace.yaml'),
`packages:\n${opts.patterns.map((p) => ` - '${p}'`).join('\n')}\n`,
);
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'monorepo-root' }));
for (const [dir, spec] of Object.entries(opts.packages)) {
const pkgDir = join(root, dir);
mkdirSync(pkgDir, { recursive: true });
const pkg: Record<string, unknown> = { name: spec.name };
if (spec.deps && spec.deps.length > 0) {
pkg.dependencies = Object.fromEntries(spec.deps.map((d) => [d, 'workspace:*']));
}
writeFileSync(join(pkgDir, 'package.json'), JSON.stringify(pkg));
}
if (opts.turboTasks) {
writeFileSync(
join(root, 'turbo.json'),
JSON.stringify({
tasks: Object.fromEntries(opts.turboTasks.map((t) => [t.taskId, { inputs: t.inputs }])),
}),
);
}
return root;
}
describe('affectedPackages', () => {
it('returns all packages when CHANGED_FILES signal is missing', () => {
const rootDir = makeFixture({
patterns: ['packages/*'],
packages: { 'packages/a': { name: 'a' }, 'packages/b': { name: 'b' } },
});
expect(affectedPackages({ rootDir, changedFiles: null })).toEqual(['a', 'b']);
});
it('returns empty when changed-files list is explicitly empty', () => {
const rootDir = makeFixture({
patterns: ['packages/*'],
packages: { 'packages/a': { name: 'a' }, 'packages/b': { name: 'b' } },
});
expect(affectedPackages({ rootDir, changedFiles: [] })).toEqual([]);
});
it('returns just the directly-changed package when no deps', () => {
const rootDir = makeFixture({
patterns: ['packages/*'],
packages: { 'packages/a': { name: 'a' }, 'packages/b': { name: 'b' } },
});
expect(affectedPackages({ rootDir, changedFiles: ['packages/a/src/index.ts'] })).toEqual(['a']);
});
it('includes transitive downstream packages', () => {
const rootDir = makeFixture({
patterns: ['packages/*'],
packages: {
'packages/workflow': { name: 'workflow' },
'packages/core': { name: 'core', deps: ['workflow'] },
'packages/cli': { name: 'cli', deps: ['core'] },
'packages/unrelated': { name: 'unrelated' },
},
});
expect(affectedPackages({ rootDir, changedFiles: ['packages/workflow/src/index.ts'] })).toEqual(
['cli', 'core', 'workflow'],
);
});
it('expands all packages when pnpm-lock.yaml changes', () => {
const rootDir = makeFixture({
patterns: ['packages/*'],
packages: { 'packages/a': { name: 'a' }, 'packages/b': { name: 'b' } },
});
expect(affectedPackages({ rootDir, changedFiles: ['pnpm-lock.yaml'] })).toEqual(['a', 'b']);
});
it('expands all packages when root package.json changes', () => {
const rootDir = makeFixture({
patterns: ['packages/*'],
packages: { 'packages/a': { name: 'a' }, 'packages/b': { name: 'b' } },
});
expect(affectedPackages({ rootDir, changedFiles: ['package.json'] })).toEqual(['a', 'b']);
});
it('handles turbo extra-inputs pointing at another package', () => {
const rootDir = makeFixture({
patterns: ['packages/*'],
packages: {
'packages/cli': { name: 'n8n' },
'packages/nodes-base': { name: 'n8n-nodes-base' },
},
turboTasks: [
{ taskId: 'n8n-nodes-base#test', inputs: ['../cli/src/public-api/v1/**/*.yml'] },
],
});
expect(
affectedPackages({
rootDir,
changedFiles: ['packages/cli/src/public-api/v1/openapi.yml'],
}),
).toEqual(['n8n', 'n8n-nodes-base']);
});
it('matches nested workspace patterns (frontend/**)', () => {
const rootDir = makeFixture({
patterns: ['packages/frontend/**'],
packages: {
'packages/frontend/editor-ui': { name: 'editor-ui' },
'packages/frontend/@n8n/stores': { name: 'stores' },
},
});
expect(
affectedPackages({ rootDir, changedFiles: ['packages/frontend/@n8n/stores/src/auth.ts'] }),
).toEqual(['stores']);
});
});
describe('findWorkspaceRoot', () => {
it('walks up to find pnpm-workspace.yaml', () => {
const root = makeFixture({
patterns: ['packages/*'],
packages: { 'packages/a': { name: 'a' } },
});
expect(findWorkspaceRoot(join(root, 'packages', 'a'))).toBe(root);
});
it('throws when no workspace root above startDir', () => {
expect(() => findWorkspaceRoot('/')).toThrow(/Could not locate/);
});
});

View File

@ -0,0 +1,156 @@
/**
* Given a list of changed files, returns the workspace packages affected
* the package containing each file, plus everything transitively downstream.
* Pure-Node (no git); designed to be invoked once per CI run and passed to
* test jobs as filter args.
*/
import { globSync } from 'glob';
import { existsSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import { parse as parseYaml } from 'yaml';
import { findWorkspaceRoot, toPosix } from './path-utils.js';
function parseJsonFile<T>(path: string): T {
try {
return JSON.parse(readFileSync(path, 'utf-8')) as T;
} catch (cause) {
throw new Error(`Failed to parse ${path}: ${(cause as Error).message}`);
}
}
export { findWorkspaceRoot } from './path-utils.js';
interface WorkspacePackage {
name: string;
dir: string;
workspaceDeps: string[];
}
export interface AnalyzeOptions {
rootDir?: string;
/** Repo-root-relative, forward slashes. `null` = no signal → all packages. */
changedFiles: string[] | null;
}
const GLOBAL_TRIGGER_FILES = new Set(['pnpm-lock.yaml', 'package.json']);
function loadWorkspacePackages(rootDir: string): WorkspacePackage[] {
const wsFile = join(rootDir, 'pnpm-workspace.yaml');
if (!existsSync(wsFile)) throw new Error(`pnpm-workspace.yaml not found at ${wsFile}`);
const patterns =
(parseYaml(readFileSync(wsFile, 'utf-8')) as { packages?: string[] } | null)?.packages ?? [];
const dirs = new Set<string>();
for (const pattern of patterns) {
for (const pkgJson of globSync(`${pattern}/package.json`, {
cwd: rootDir,
ignore: '**/node_modules/**',
})) {
dirs.add(toPosix(pkgJson.replace(/\/package\.json$/, '')));
}
}
const known = new Set<string>();
const pkgs: Array<{ dir: string; pkg: Record<string, unknown> }> = [];
for (const dir of dirs) {
const pkg = parseJsonFile<Record<string, unknown>>(join(rootDir, dir, 'package.json'));
if (typeof pkg.name !== 'string') continue;
known.add(pkg.name);
pkgs.push({ dir, pkg });
}
return pkgs.map(({ dir, pkg }) => ({
name: pkg.name as string,
dir,
workspaceDeps: collectWorkspaceDeps(pkg, known),
}));
}
function collectWorkspaceDeps(pkg: Record<string, unknown>, known: Set<string>): string[] {
const deps = new Set<string>();
for (const field of ['dependencies', 'devDependencies'] as const) {
const block = pkg[field];
if (!block || typeof block !== 'object') continue;
for (const name of Object.keys(block as Record<string, string>)) {
if (known.has(name)) deps.add(name);
}
}
return [...deps];
}
interface TurboBinding {
packageName: string;
pathPrefix: string;
}
function loadTurboExtraInputs(rootDir: string, packages: WorkspacePackage[]): TurboBinding[] {
const turboFile = join(rootDir, 'turbo.json');
if (!existsSync(turboFile)) return [];
const parsed = parseJsonFile<{ tasks?: Record<string, { inputs?: string[] }> }>(turboFile);
const bindings: TurboBinding[] = [];
for (const [taskId, task] of Object.entries(parsed.tasks ?? {})) {
const hashIdx = taskId.indexOf('#');
if (hashIdx === -1) continue;
const packageName = taskId.slice(0, hashIdx);
const ownerPkg = packages.find((p) => p.name === packageName);
if (!ownerPkg) continue;
for (const input of task.inputs ?? []) {
if (!input.startsWith('../')) continue;
const repoRelative = toPosix(relative(rootDir, join(rootDir, ownerPkg.dir, input)));
// Strip glob suffixes to get a directory prefix we can prefix-match
// changed files against. The two branches cover:
// `/?**/...` — recursive globs (e.g. `../cli/src/**/*.yml`)
// `/*foo` — single-segment globs (e.g. `../cli/src/*.ts`)
// After stripping, e.g. `packages/cli/src/public-api/v1/**/*.yml`
// becomes `packages/cli/src/public-api/v1`.
const pathPrefix = repoRelative.replace(/\/?\*\*.*$|\/\*[^/]*$/g, '');
bindings.push({ packageName, pathPrefix });
}
}
return bindings;
}
export function affectedPackages(options: AnalyzeOptions): string[] {
const rootDir = options.rootDir ?? findWorkspaceRoot(process.cwd());
const packages = loadWorkspacePackages(rootDir);
const allNames = packages.map((p) => p.name).sort();
// No signal (local dev, missing env) → safest default: everything.
if (options.changedFiles === null) return allNames;
if (options.changedFiles.some((f) => GLOBAL_TRIGGER_FILES.has(f))) return allNames;
const direct = new Set<string>();
for (const file of options.changedFiles) {
const owner = packages.find((p) => file === p.dir || file.startsWith(`${p.dir}/`));
if (owner) direct.add(owner.name);
}
for (const { packageName, pathPrefix } of loadTurboExtraInputs(rootDir, packages)) {
if (options.changedFiles.some((f) => f === pathPrefix || f.startsWith(`${pathPrefix}/`))) {
direct.add(packageName);
}
}
const dependents = new Map<string, string[]>();
for (const pkg of packages) {
for (const dep of pkg.workspaceDeps) {
const list = dependents.get(dep) ?? [];
list.push(pkg.name);
dependents.set(dep, list);
}
}
const affected = new Set<string>();
const queue = [...direct];
while (queue.length > 0) {
const name = queue.shift()!;
if (affected.has(name)) continue;
affected.add(name);
queue.push(...(dependents.get(name) ?? []));
}
return [...affected].sort();
}

View File

@ -0,0 +1,19 @@
import { existsSync } from 'node:fs';
import { dirname, join, resolve, sep } from 'node:path';
export function toPosix(p: string): string {
return sep === '/' ? p : p.split(sep).join('/');
}
/** Walk up from startDir to the directory containing pnpm-workspace.yaml. */
export function findWorkspaceRoot(startDir: string): string {
let dir = resolve(startDir);
while (true) {
if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return dir;
const parent = dirname(dir);
if (parent === dir) {
throw new Error(`Could not locate pnpm-workspace.yaml walking up from ${startDir}`);
}
dir = parent;
}
}

View File

@ -0,0 +1,146 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { computeScope, formatScope } from './scope-analyzer.js';
function makePackageDir(name: string): string {
const root = join(tmpdir(), `janitor-scope-${Math.random().toString(36).slice(2)}`);
const pkgDir = join(root, name);
mkdirSync(pkgDir, { recursive: true });
writeFileSync(join(pkgDir, 'package.json'), JSON.stringify({ name: 'some-pkg' }));
return root;
}
describe('computeScope', () => {
it('returns full when CHANGED_FILES signal is missing (local dev)', () => {
const rootDir = makePackageDir('packages/cli');
const result = computeScope({
runner: 'jest',
packageDir: 'packages/cli',
rootDir,
changedFiles: null,
});
expect(result.kind).toBe('full');
expect(formatScope(result)).toBe('RUN_FULL');
});
it('returns skip when no in-package files changed', () => {
const rootDir = makePackageDir('packages/cli');
const result = computeScope({
runner: 'jest',
packageDir: 'packages/cli',
rootDir,
changedFiles: ['packages/other/src/x.ts'],
});
expect(result).toEqual({ kind: 'skip', reason: 'No changed files in package' });
expect(formatScope(result)).toBe('SKIP');
});
it('returns scoped list when only source files changed', () => {
const rootDir = makePackageDir('packages/cli');
const result = computeScope({
runner: 'jest',
packageDir: 'packages/cli',
rootDir,
changedFiles: ['packages/cli/src/a.ts', 'packages/cli/src/b.ts'],
});
expect(result).toEqual({
kind: 'scoped',
files: ['packages/cli/src/a.ts', 'packages/cli/src/b.ts'],
});
expect(formatScope(result)).toBe('packages/cli/src/a.ts packages/cli/src/b.ts');
});
it('bails to full on jest.config change for jest runner', () => {
const rootDir = makePackageDir('packages/cli');
const result = computeScope({
runner: 'jest',
packageDir: 'packages/cli',
rootDir,
changedFiles: ['packages/cli/jest.config.js'],
});
expect(result.kind).toBe('full');
expect(formatScope(result)).toBe('RUN_FULL');
});
it('bails to full on package.json change', () => {
const rootDir = makePackageDir('packages/cli');
const result = computeScope({
runner: 'vitest',
packageDir: 'packages/cli',
rootDir,
changedFiles: ['packages/cli/package.json'],
});
expect(result.kind).toBe('full');
});
it('bails to full on tsconfig change', () => {
const rootDir = makePackageDir('packages/cli');
const result = computeScope({
runner: 'vitest',
packageDir: 'packages/cli',
rootDir,
changedFiles: ['packages/cli/tsconfig.build.json'],
});
expect(result.kind).toBe('full');
});
it('ignores files outside the package even when configs match', () => {
const rootDir = makePackageDir('packages/cli');
const result = computeScope({
runner: 'jest',
packageDir: 'packages/cli',
rootDir,
changedFiles: ['packages/other/package.json'],
});
expect(result.kind).toBe('skip');
});
it('does NOT bail on vitest.config when runner is jest', () => {
const rootDir = makePackageDir('packages/cli');
const result = computeScope({
runner: 'jest',
packageDir: 'packages/cli',
rootDir,
changedFiles: ['packages/cli/vitest.config.ts'],
});
// vitest.config in a jest-tested package is not a runner bailout trigger.
// (It's unusual but the bailout is runner-specific.)
expect(result.kind).toBe('scoped');
});
it('bails to full on vite.config change for vitest runner (vitest reads vite.config)', () => {
const rootDir = makePackageDir('packages/frontend/editor-ui');
const result = computeScope({
runner: 'vitest',
packageDir: 'packages/frontend/editor-ui',
rootDir,
changedFiles: ['packages/frontend/editor-ui/vite.config.mts'],
});
expect(result.kind).toBe('full');
});
it('bails to full on src/__tests__/setup.ts change for vitest runner', () => {
const rootDir = makePackageDir('packages/frontend/editor-ui');
const result = computeScope({
runner: 'vitest',
packageDir: 'packages/frontend/editor-ui',
rootDir,
changedFiles: ['packages/frontend/editor-ui/src/__tests__/setup.ts'],
});
expect(result.kind).toBe('full');
});
it('bails to full on src/__tests__/setup.ts change for jest runner', () => {
const rootDir = makePackageDir('packages/nodes-base');
const result = computeScope({
runner: 'jest',
packageDir: 'packages/nodes-base',
rootDir,
changedFiles: ['packages/nodes-base/src/__tests__/setup.ts'],
});
expect(result.kind).toBe('full');
});
});

View File

@ -0,0 +1,94 @@
/**
* Filters CHANGED_FILES to a single package and detects config-file bailouts.
* Output is a shell sentinel (SKIP / RUN_FULL / file list). The actual
* import-graph walk is the runner's job (jest --findRelatedTests / vitest
* related).
*/
import { existsSync } from 'node:fs';
import { isAbsolute, relative, resolve } from 'node:path';
import { toPosix } from './path-utils.js';
export type Runner = 'jest' | 'vitest';
// Bailout patterns are centralised here (vs the original DEVP-194 spec's
// per-package `n8nTestChanged.inPackageBailouts` field) because the n8n
// workspace shares the same vitest config helpers + setup file layout via
// @n8n/vitest-config. If a package ever needs a custom bailout that doesn't
// fit these patterns, switch to per-package config rather than expanding
// these any further.
const COMMON_BAILOUT = [
/^package\.json$/,
/^tsconfig(\..+)?\.json$/,
/^\.swcrc$/,
/^babel\.config\.[cm]?[jt]s$/,
];
const JEST_BAILOUT = [
...COMMON_BAILOUT,
/^jest\.config\.[cm]?[jt]s$/,
/(?:^|\/)(?:jest|test)\.setup\.[cm]?[jt]s$/,
/(?:^|\/)__tests__\/setup\.[cm]?[jt]s$/,
];
// Frontend packages use vite.config.* for the vitest config too (vitest reads
// vite.config). Setup files live at src/__tests__/setup.ts per the shared
// @n8n/vitest-config convention.
const VITEST_BAILOUT = [
...COMMON_BAILOUT,
/^vite\.config\.[cm]?[jt]s$/,
/^vitest\.config\.[cm]?[jt]s$/,
/(?:^|\/)vitest\.setup\.[cm]?[jt]s$/,
/(?:^|\/)__tests__\/setup\.[cm]?[jt]s$/,
];
export interface ComputeScopeOptions {
runner: Runner;
packageDir: string;
rootDir: string;
/** `null` = no signal → RUN_FULL (local dev with unset env). */
changedFiles: string[] | null;
}
export type ScopeResult =
| { kind: 'skip'; reason: string }
| { kind: 'full'; reason: string; trigger?: string }
| { kind: 'scoped'; files: string[] };
export function computeScope(options: ComputeScopeOptions): ScopeResult {
if (options.changedFiles === null) {
return { kind: 'full', reason: 'No CHANGED_FILES signal (local dev)' };
}
const absolute = isAbsolute(options.packageDir)
? options.packageDir
: resolve(options.rootDir, options.packageDir);
if (!existsSync(absolute)) throw new Error(`Package directory not found: ${absolute}`);
const pkgPrefix = toPosix(relative(options.rootDir, absolute));
const pkgPrefixSlash = `${pkgPrefix}/`;
const inPackage = options.changedFiles.filter(
(f) => f === pkgPrefix || f.startsWith(pkgPrefixSlash),
);
if (inPackage.length === 0) return { kind: 'skip', reason: 'No changed files in package' };
const bailout = options.runner === 'jest' ? JEST_BAILOUT : VITEST_BAILOUT;
for (const file of inPackage) {
const relInPkg = file.slice(pkgPrefixSlash.length);
if (bailout.some((p) => p.test(relInPkg))) {
return { kind: 'full', reason: 'Config or package-level file changed', trigger: file };
}
}
return { kind: 'scoped', files: inPackage };
}
export function formatScope(result: ScopeResult): string {
switch (result.kind) {
case 'skip':
return 'SKIP';
case 'full':
return 'RUN_FULL';
case 'scoped':
return result.files.join(' ');
}
}

View File

@ -0,0 +1,61 @@
import { isAbsolute } from 'node:path';
import { describe, expect, it } from 'vitest';
import { buildRunnerArgs } from './test-scoped-runner.js';
const rootDir = '/repo/root';
describe('buildRunnerArgs', () => {
it('jest scoped: emits --findRelatedTests with absolute paths', () => {
const args = buildRunnerArgs(
'jest',
{ kind: 'scoped', files: ['packages/nodes-base/nodes/Foo.node.ts'] },
rootDir,
['--summarize'],
);
expect(args[0]).toBe('--findRelatedTests');
expect(isAbsolute(args[1])).toBe(true);
expect(args[1]).toBe('/repo/root/packages/nodes-base/nodes/Foo.node.ts');
expect(args[2]).toBe('--summarize');
});
it('vitest scoped: emits `related` with absolute paths and `--run` to avoid watch mode', () => {
const args = buildRunnerArgs(
'vitest',
{
kind: 'scoped',
files: ['packages/frontend/editor-ui/src/x.ts', 'packages/frontend/editor-ui/src/y.ts'],
},
rootDir,
['--shard=1/2'],
);
expect(args[0]).toBe('related');
expect(args.slice(1, 3).every(isAbsolute)).toBe(true);
// `--run` is required: `vitest related` defaults to watch mode without
// TTY-detection and would hang the CI runner forever.
expect(args).toContain('--run');
expect(args.at(-1)).toBe('--shard=1/2');
});
it('preserves already-absolute paths', () => {
const args = buildRunnerArgs(
'jest',
{ kind: 'scoped', files: ['/already/absolute/path.ts'] },
rootDir,
[],
);
expect(args).toEqual(['--findRelatedTests', '/already/absolute/path.ts']);
});
it('jest full: passes through args with no related-tests flag', () => {
expect(
buildRunnerArgs('jest', { kind: 'full', reason: 'config change' }, rootDir, ['--summarize']),
).toEqual(['--summarize']);
});
it('vitest full: prepends `run` subcommand', () => {
expect(
buildRunnerArgs('vitest', { kind: 'full', reason: 'no signal' }, rootDir, ['--coverage']),
).toEqual(['run', '--coverage']);
});
});

View File

@ -0,0 +1,64 @@
/** Compute per-package scope and dispatch to jest/vitest with the right flags. */
import { spawnSync } from 'node:child_process';
import { isAbsolute, resolve } from 'node:path';
import { computeScope, type Runner, type ScopeResult } from './scope-analyzer.js';
export interface TestScopedOptions {
runner: Runner;
packageDir: string;
rootDir: string;
changedFiles: string[] | null;
passthroughArgs: string[];
}
/**
* Build the runner argv from a scope result. Paths are resolved to absolute
* because pnpm/turbo runs `test:changed` with cwd=packageDir, while CHANGED_FILES
* is repo-root-relative handing a relative path to `jest --findRelatedTests` or
* `vitest related` from inside the package would silently match zero files and
* exit 0 with no tests run.
*/
export function buildRunnerArgs(
runner: Runner,
scope: Extract<ScopeResult, { kind: 'scoped' | 'full' }>,
rootDir: string,
passthroughArgs: string[],
): string[] {
if (scope.kind === 'full') {
return runner === 'vitest' ? ['run', ...passthroughArgs] : [...passthroughArgs];
}
const absoluteFiles = scope.files.map((f) => (isAbsolute(f) ? f : resolve(rootDir, f)));
// `vitest related` defaults to watch mode and does NOT TTY-detect, so it
// would hang the CI runner forever. `--run` forces a single-pass execution.
return runner === 'jest'
? ['--findRelatedTests', ...absoluteFiles, ...passthroughArgs]
: ['related', ...absoluteFiles, '--run', ...passthroughArgs];
}
export function runTestScoped(options: TestScopedOptions): number {
const scope = computeScope({
runner: options.runner,
packageDir: options.packageDir,
rootDir: options.rootDir,
changedFiles: options.changedFiles,
});
if (scope.kind === 'skip') {
console.log(`[janitor:test-scoped] ${scope.reason} → skipping`);
return 0;
}
if (scope.kind === 'full') {
console.log(`[janitor:test-scoped] ${scope.reason} → full suite`);
} else {
console.log(`[janitor:test-scoped] scoping to ${scope.files.length} file(s)`);
}
const args = buildRunnerArgs(options.runner, scope, options.rootDir, options.passthroughArgs);
// Pass cwd explicitly so an override via --package-dir is honoured
// (otherwise spawnSync inherits the caller's cwd and jest/vitest would
// resolve config + tests from the wrong project).
return spawnSync(options.runner, args, { stdio: 'inherit', cwd: options.packageDir }).status ?? 1;
}

View File

@ -3425,6 +3425,9 @@ importers:
'@n8n/eslint-config':
specifier: workspace:*
version: link:../../../@n8n/eslint-config
'@n8n/playwright-janitor':
specifier: workspace:*
version: link:../../../testing/janitor
'@n8n/stylelint-config':
specifier: workspace:*
version: link:../../../@n8n/stylelint-config
@ -3464,6 +3467,9 @@ importers:
'@n8n/eslint-config':
specifier: workspace:*
version: link:../../../@n8n/eslint-config
'@n8n/playwright-janitor':
specifier: workspace:*
version: link:../../../testing/janitor
'@n8n/typescript-config':
specifier: workspace:*
version: link:../../../@n8n/typescript-config
@ -3576,6 +3582,9 @@ importers:
'@n8n/eslint-config':
specifier: workspace:*
version: link:../../../@n8n/eslint-config
'@n8n/playwright-janitor':
specifier: workspace:*
version: link:../../../testing/janitor
'@n8n/stylelint-config':
specifier: workspace:*
version: link:../../../@n8n/stylelint-config
@ -3661,6 +3670,9 @@ importers:
'@n8n/eslint-config':
specifier: workspace:*
version: link:../../../@n8n/eslint-config
'@n8n/playwright-janitor':
specifier: workspace:*
version: link:../../../testing/janitor
'@n8n/typescript-config':
specifier: workspace:*
version: link:../../../@n8n/typescript-config
@ -3737,6 +3749,9 @@ importers:
'@n8n/i18n':
specifier: workspace:*
version: link:../i18n
'@n8n/playwright-janitor':
specifier: workspace:*
version: link:../../../testing/janitor
'@n8n/typescript-config':
specifier: workspace:*
version: link:../../../@n8n/typescript-config
@ -3771,6 +3786,9 @@ importers:
'@n8n/eslint-config':
specifier: workspace:*
version: link:../../../@n8n/eslint-config
'@n8n/playwright-janitor':
specifier: workspace:*
version: link:../../../testing/janitor
'@n8n/typescript-config':
specifier: workspace:*
version: link:../../../@n8n/typescript-config
@ -4202,6 +4220,9 @@ importers:
'@n8n/eslint-config':
specifier: workspace:*
version: link:../../@n8n/eslint-config
'@n8n/playwright-janitor':
specifier: workspace:*
version: link:../../testing/janitor
'@n8n/stylelint-config':
specifier: workspace:*
version: link:../../@n8n/stylelint-config
@ -4579,6 +4600,9 @@ importers:
'@n8n/eslint-plugin-community-nodes':
specifier: workspace:*
version: link:../@n8n/eslint-plugin-community-nodes
'@n8n/playwright-janitor':
specifier: workspace:*
version: link:../testing/janitor
'@n8n/typescript-config':
specifier: workspace:*
version: link:../@n8n/typescript-config
@ -4738,6 +4762,9 @@ importers:
glob:
specifier: 10.5.0
version: 10.5.0
yaml:
specifier: 'catalog:'
version: 2.8.3
devDependencies:
'@types/node':
specifier: ^20.17.50

View File

@ -39,7 +39,12 @@
"dependsOn": ["^build", "build"],
"inputs": ["$TURBO_DEFAULT$", "../cli/src/public-api/v1/**/*.yml"],
"outputs": ["coverage/**", "*.xml"],
"env": ["COVERAGE_ENABLED", "MERGE_BASE"]
"env": ["COVERAGE_ENABLED", "CHANGED_FILES"]
},
"test:changed": {
"dependsOn": ["^build", "build"],
"outputs": ["coverage/**", "*.xml"],
"env": ["COVERAGE_ENABLED", "CHANGED_FILES"]
},
"test:unit": {
"dependsOn": ["^build", "build"],