mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-30 18:01:23 +08:00
228 lines
8.4 KiB
JavaScript
228 lines
8.4 KiB
JavaScript
#!/usr/bin/env node
|
|
// Per-package build/typecheck timing for the TS 6 -> 7 migration.
|
|
//
|
|
// Run it before switching compilers (writes a results file), switch the
|
|
// compiler, then run it again — the second run appends to the same file and
|
|
// prints a before/after delta table. Migration is per-package, so this always
|
|
// targets a single workspace package.
|
|
//
|
|
// Usage:
|
|
// node scripts/typescript-migration/benchmark.mjs <package> [options]
|
|
//
|
|
// Options:
|
|
// --label=<name> Key the run is stored under. Defaults to the detected
|
|
// TypeScript version (e.g. "ts-6.0.2"), so before/after
|
|
// with different compilers auto-separate. Re-running a
|
|
// label overwrites it.
|
|
// --runs=<n> Repetitions per task (default 3). Reports min/median/mean.
|
|
// --tasks=a,b Subset of "typecheck,build" (default both). Missing
|
|
// scripts are skipped with a warning.
|
|
// --no-prepare Skip the untimed dependency prebuild.
|
|
//
|
|
// Example:
|
|
// node scripts/typescript-migration/benchmark.mjs @n8n/config --label=before
|
|
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
const REPO_ROOT = resolve(SCRIPT_DIR, '..', '..');
|
|
const RESULTS_DIR = join(SCRIPT_DIR, 'results');
|
|
const DEFAULT_TASKS = ['typecheck', 'build'];
|
|
|
|
function parseArgs(argv) {
|
|
const opts = { runs: 3, tasks: DEFAULT_TASKS, prepare: true, label: undefined };
|
|
const positionals = [];
|
|
for (const arg of argv) {
|
|
if (arg === '--no-prepare') opts.prepare = false;
|
|
else if (arg.startsWith('--label=')) opts.label = arg.slice('--label='.length);
|
|
else if (arg.startsWith('--runs=')) opts.runs = Number(arg.slice('--runs='.length));
|
|
else if (arg.startsWith('--tasks=')) opts.tasks = arg.slice('--tasks='.length).split(',').map((t) => t.trim()).filter(Boolean);
|
|
else if (arg.startsWith('--')) fail(`Unknown option: ${arg}`);
|
|
else positionals.push(arg);
|
|
}
|
|
if (positionals.length !== 1) fail('Expected exactly one <package> argument.');
|
|
if (!Number.isInteger(opts.runs) || opts.runs < 1) fail('--runs must be a positive integer.');
|
|
opts.package = positionals[0];
|
|
return opts;
|
|
}
|
|
|
|
function fail(message) {
|
|
console.error(`\n✖ ${message}\n`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Resolve a workspace package name or directory to { name, dir, scripts }.
|
|
function resolvePackage(nameOrDir) {
|
|
// A directory path was given directly.
|
|
const asDir = resolve(REPO_ROOT, nameOrDir);
|
|
if (existsSync(join(asDir, 'package.json'))) {
|
|
const pkg = JSON.parse(readFileSync(join(asDir, 'package.json'), 'utf8'));
|
|
return { name: pkg.name, dir: asDir, scripts: pkg.scripts ?? {} };
|
|
}
|
|
// Otherwise treat it as a workspace package name and ask pnpm where it lives.
|
|
let out;
|
|
try {
|
|
out = execFileSync('pnpm', ['--filter', nameOrDir, 'exec', 'node', '-p', 'process.cwd()'], {
|
|
cwd: REPO_ROOT,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
} catch {
|
|
fail(`Could not resolve package "${nameOrDir}" — not a directory and pnpm --filter found no match.`);
|
|
}
|
|
const dir = out.trim().split('\n').filter(Boolean).pop();
|
|
if (!dir || !existsSync(join(dir, 'package.json'))) fail(`Resolved an invalid directory for "${nameOrDir}".`);
|
|
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
return { name: pkg.name, dir, scripts: pkg.scripts ?? {} };
|
|
}
|
|
|
|
function detectTsVersion(pkgName) {
|
|
const res = spawnSync('pnpm', ['--filter', pkgName, 'exec', 'tsc', '--version'], {
|
|
cwd: REPO_ROOT,
|
|
encoding: 'utf8',
|
|
});
|
|
const match = /Version\s+([\d.]+)/.exec(res.stdout ?? '');
|
|
return match ? match[1] : 'unknown';
|
|
}
|
|
|
|
// Wipe compiled output + incremental caches so every timed run is cold.
|
|
function cleanBuildArtifacts(pkgDir) {
|
|
rmSync(join(pkgDir, 'dist'), { recursive: true, force: true });
|
|
rmSync(join(pkgDir, 'tsconfig.tsbuildinfo'), { force: true });
|
|
}
|
|
|
|
function timeTask(pkgName, pkgDir, task) {
|
|
cleanBuildArtifacts(pkgDir);
|
|
const start = process.hrtime.bigint();
|
|
const res = spawnSync('pnpm', ['--filter', pkgName, 'run', task], {
|
|
cwd: REPO_ROOT,
|
|
stdio: 'inherit',
|
|
});
|
|
const ms = Number(process.hrtime.bigint() - start) / 1e6;
|
|
if (res.status !== 0) fail(`Task "${task}" failed (exit ${res.status}); aborting so timings aren't polluted.`);
|
|
return ms;
|
|
}
|
|
|
|
function summarize(runsMs) {
|
|
const sorted = [...runsMs].sort((a, b) => a - b);
|
|
const mid = Math.floor(sorted.length / 2);
|
|
const median = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
const mean = runsMs.reduce((a, b) => a + b, 0) / runsMs.length;
|
|
return {
|
|
runsMs: runsMs.map((n) => Math.round(n)),
|
|
min: Math.round(sorted[0]),
|
|
median: Math.round(median),
|
|
mean: Math.round(mean),
|
|
};
|
|
}
|
|
|
|
function fmtMs(ms) {
|
|
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
|
|
}
|
|
|
|
// Baseline-first ordering: "before" is always the baseline and "after" always
|
|
// last regardless of recording order; anything else falls between by timestamp.
|
|
function orderLabels(runs) {
|
|
const rank = (l) => (/^before$/i.test(l) ? -1 : /^after$/i.test(l) ? 1 : 0);
|
|
return Object.keys(runs).sort((a, b) => {
|
|
if (rank(a) !== rank(b)) return rank(a) - rank(b);
|
|
return (runs[a].timestamp ?? '').localeCompare(runs[b].timestamp ?? '');
|
|
});
|
|
}
|
|
|
|
// Print a comparison table across every stored label, deltas vs the baseline.
|
|
function printComparison(data) {
|
|
const labels = orderLabels(data.runs);
|
|
if (labels.length < 2) {
|
|
console.log(`\nStored 1 run ("${labels[0]}"). Run again with a second --label to see a delta.\n`);
|
|
return;
|
|
}
|
|
const base = labels[0];
|
|
console.log(`\n=== ${data.package} — median times (Δ vs "${base}") ===`);
|
|
const tasks = new Set();
|
|
for (const l of labels) for (const t of Object.keys(data.runs[l].tasks)) tasks.add(t);
|
|
for (const task of tasks) {
|
|
console.log(`\n${task}:`);
|
|
const baseMedian = data.runs[base].tasks[task]?.median;
|
|
for (const label of labels) {
|
|
const stat = data.runs[label].tasks[task];
|
|
if (!stat) {
|
|
console.log(` ${label.padEnd(16)} (not measured)`);
|
|
continue;
|
|
}
|
|
let delta = '';
|
|
if (label !== base && baseMedian != null) {
|
|
const diff = stat.median - baseMedian;
|
|
const pct = ((diff / baseMedian) * 100).toFixed(1);
|
|
const sign = diff <= 0 ? '' : '+';
|
|
delta = ` ${sign}${fmtMs(diff)} (${sign}${pct}%)`;
|
|
}
|
|
console.log(` ${label.padEnd(16)} ${fmtMs(stat.median).padStart(9)}${delta}`);
|
|
}
|
|
}
|
|
console.log('');
|
|
}
|
|
|
|
function main() {
|
|
const opts = parseArgs(process.argv.slice(2));
|
|
const pkg = resolvePackage(opts.package);
|
|
const tsVersion = detectTsVersion(pkg.name);
|
|
const label = opts.label ?? `ts-${tsVersion}`;
|
|
|
|
const tasks = opts.tasks.filter((task) => {
|
|
if (pkg.scripts[task]) return true;
|
|
console.warn(`⚠ Package "${pkg.name}" has no "${task}" script — skipping.`);
|
|
return false;
|
|
});
|
|
if (tasks.length === 0) fail('No runnable tasks for this package.');
|
|
|
|
console.log(`\nBenchmarking ${pkg.name}`);
|
|
console.log(` label: ${label}`);
|
|
console.log(` tsc: ${tsVersion}`);
|
|
console.log(` node: ${process.version}`);
|
|
console.log(` runs/task: ${opts.runs}`);
|
|
console.log(` tasks: ${tasks.join(', ')}\n`);
|
|
|
|
if (opts.prepare) {
|
|
console.log('Prebuilding dependencies (untimed)…');
|
|
const prep = spawnSync('pnpm', ['build', `--filter=${pkg.name}^...`], {
|
|
cwd: REPO_ROOT,
|
|
stdio: 'inherit',
|
|
});
|
|
if (prep.status !== 0) fail('Dependency prebuild failed. Fix the build or pass --no-prepare.');
|
|
}
|
|
|
|
const taskStats = {};
|
|
for (const task of tasks) {
|
|
const runsMs = [];
|
|
for (let i = 0; i < opts.runs; i++) {
|
|
console.log(`\n▶ ${task} — run ${i + 1}/${opts.runs}`);
|
|
runsMs.push(timeTask(pkg.name, pkg.dir, task));
|
|
}
|
|
taskStats[task] = summarize(runsMs);
|
|
console.log(` ↳ ${task}: min ${fmtMs(taskStats[task].min)}, median ${fmtMs(taskStats[task].median)}, mean ${fmtMs(taskStats[task].mean)}`);
|
|
}
|
|
|
|
mkdirSync(RESULTS_DIR, { recursive: true });
|
|
const resultsFile = join(RESULTS_DIR, `${pkg.name.replace(/[@/]/g, '_')}.json`);
|
|
const data = existsSync(resultsFile)
|
|
? JSON.parse(readFileSync(resultsFile, 'utf8'))
|
|
: { package: pkg.name, runs: {} };
|
|
data.runs[label] = {
|
|
label,
|
|
tsVersion,
|
|
node: process.version,
|
|
timestamp: new Date().toISOString(),
|
|
tasks: taskStats,
|
|
};
|
|
writeFileSync(resultsFile, `${JSON.stringify(data, null, 2)}\n`);
|
|
console.log(`\n✔ Wrote ${resultsFile}`);
|
|
|
|
printComparison(data);
|
|
}
|
|
|
|
main();
|