UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

277 lines (254 loc) • 10 kB
#!/usr/bin/env node // Unit lanes for CI. // // Measured on Node 24 (4 cores), one file at a time: engine.test.ts 174s, // permission.test.ts 74s, runtime.test.ts 53s. Those files run their tests // sequentially, and running them in one process pool stretched the rest of // the suite to 268s. engine.test.ts is split by test name across two jobs. // permission and runtime run one file at a time in a serial job so they do // not steal cores from each other. // // package-install.test.ts is serial as well. On the Windows light lane for // PRs #361, #362, and #363 it took 171s (67s on Linux) and was the file that // finished immediately before that lane stopped reporting tests. The job // then sat until the 20-minute cancel. npm pack/install stays off the light // pool. The other light files are round-robin split into two shards so a // Windows job is not one 120-file process. // // Dynamic `test(\`...\${...}\`)` names stay together as one pattern so a // loop is not dropped. Run via `npm run test:ci-shard` so npm_execpath is // set for the packed-install test: `engine <index> <total>`, `serial`, // `light`, or `light-<index>` (two shards). import { spawn, spawnSync } from "node:child_process"; import { globSync, readFileSync } from "node:fs"; import { join } from "node:path"; export const SHARD_TOTAL = 2; export const LIGHT_SHARD_TOTAL = 2; export const ENGINE_FILE = "test/core/engine.test.ts"; // Solo Node 24 timings: permission 74s, runtime 53s. package-install is 171s // on Windows CI and 67s on Linux. One file at a time. export const SERIAL_FILES = [ "test/core/permission.test.ts", "test/core/runtime.test.ts", "test/core/package-install.test.ts", ]; const LIGHT_CONCURRENCY = 4; function escapeRegExp(value) { return value.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"); } // Node's glob returns backslashes on Windows. Lane membership compares posix paths. export function toPosixPath(file) { return file.replaceAll("\\", "/"); } export function normalizeUnitFiles(files) { return files.map((file) => toPosixPath(file)).sort(); } export function planUnitFiles(files) { const normalized = normalizeUnitFiles(files); if (!normalized.includes(ENGINE_FILE)) throw new Error(`${ENGINE_FILE} is missing`); for (const file of SERIAL_FILES) { if (!normalized.includes(file)) throw new Error(`${file} is missing`); } const reserved = new Set([ENGINE_FILE, ...SERIAL_FILES]); const light = normalized.filter((file) => !reserved.has(file)); return { files: normalized, serial: [...SERIAL_FILES], light }; } export function listUnitFiles(root) { return normalizeUnitFiles([ ...globSync("test/core/*.test.ts", { cwd: root }), ...globSync("packages/core/*/tests/unit/*.test.ts", { cwd: root }), ]); } export function extractTestPatterns(source) { const patterns = []; const seen = new Set(); const re = /^[ \t]*test\(\s*(["'`])([\s\S]*?)\1/gm; for (const match of source.matchAll(re)) { const raw = match[2]; let sourcePattern; let dynamic = false; let name; if (raw.includes("${")) { dynamic = true; const marked = raw.replace(/\$\{[^}]*\}/g, "\0"); sourcePattern = `^${escapeRegExp(marked).replaceAll("\0", ".*?")}$`; } else { name = raw.replace(/\\(["'`\\])/g, "$1"); sourcePattern = `^${escapeRegExp(name)}$`; } if (seen.has(sourcePattern)) continue; seen.add(sourcePattern); patterns.push({ dynamic, name, source: sourcePattern }); } return patterns; } function conflicts(patterns) { const exact = patterns.filter((pattern) => !pattern.dynamic); const dynamic = patterns.filter((pattern) => pattern.dynamic); const hits = []; for (const pattern of dynamic) { const regex = new RegExp(pattern.source); for (const item of exact) { if (regex.test(item.name)) hits.push(`${item.name} matches ${pattern.source}`); } } return hits; } function enginePatterns(root) { const patterns = extractTestPatterns(readFileSync(join(root, ENGINE_FILE), "utf8")); if (patterns.length === 0) throw new Error(`${ENGINE_FILE} has no recognizable test() names`); const overlapped = conflicts(patterns); if (overlapped.length > 0) { throw new Error(`${ENGINE_FILE} dynamic test names also match exact tests:\n${overlapped.join("\n")}`); } return patterns; } export function planEngineShard(root, index, total = SHARD_TOTAL) { if (!Number.isInteger(index) || !Number.isInteger(total) || index < 1 || index > total) { throw new Error(`shard ${index}/${total} is outside 1..${total}`); } const patterns = enginePatterns(root).filter((_, patternIndex) => patternIndex % total === index - 1); if (patterns.length === 0) throw new Error(`engine shard ${index}/${total} assigned no tests`); return { file: ENGINE_FILE, patterns }; } export function planSerial(root) { return planUnitFiles(listUnitFiles(root)).serial; } export function planLight(root) { const { light } = planUnitFiles(listUnitFiles(root)); if (light.length === 0) throw new Error("light lane has no unit files"); return light; } export function planLightShard(root, index, total = LIGHT_SHARD_TOTAL) { if (!Number.isInteger(index) || !Number.isInteger(total) || index < 1 || index > total) { throw new Error(`light shard ${index}/${total} is outside 1..${total}`); } const shard = planLight(root).filter((_, fileIndex) => fileIndex % total === index - 1); if (shard.length === 0) throw new Error(`light shard ${index}/${total} assigned no unit files`); return shard; } export function coverageOfShards(root, total = SHARD_TOTAL) { const heavy = new Map(); for (let index = 1; index <= total; index += 1) { const plan = planEngineShard(root, index, total); for (const pattern of plan.patterns) heavy.set(pattern.source, (heavy.get(pattern.source) ?? 0) + 1); } const lanes = planUnitFiles(listUnitFiles(root)); if (lanes.light.length === 0) throw new Error("light lane has no unit files"); return { files: lanes.files, serial: lanes.serial, light: lanes.light, heavy }; } function runNode(args, label, children) { return new Promise((resolve) => { const child = spawn(process.execPath, args, { env: { ...process.env, // Git Credential Manager on windows-latest can wait on a prompt // until the job is cancelled. Refuse that wait for every lane. GIT_TERMINAL_PROMPT: process.env.GIT_TERMINAL_PROMPT || "0", GCM_INTERACTIVE: process.env.GCM_INTERACTIVE || "Never", }, }); children.add(child); let stdout = ""; let stderr = ""; const flush = (buffer, stream, chunk) => { buffer += chunk.toString(); const lines = buffer.split("\n"); const rest = lines.pop() ?? ""; for (const line of lines) stream.write(`[${label}] ${line}\n`); return rest; }; child.stdout.on("data", (chunk) => { stdout = flush(stdout, process.stdout, chunk); }); child.stderr.on("data", (chunk) => { stderr = flush(stderr, process.stderr, chunk); }); child.on("close", (code) => { children.delete(child); if (stdout) process.stdout.write(`[${label}] ${stdout}\n`); if (stderr) process.stderr.write(`[${label}] ${stderr}\n`); resolve(code ?? 1); }); child.on("error", (error) => { children.delete(child); process.stderr.write(`[${label}] ${error.message}\n`); resolve(1); }); }); } function testArgs(concurrency, files, patterns) { const args = [ "--disable-warning=ExperimentalWarning", "--experimental-strip-types", "--test", "--test-force-exit", `--test-concurrency=${concurrency}`, ]; for (const pattern of patterns ?? []) args.push("--test-name-pattern", pattern.source); args.push(...files); return args; } async function runPool(tasks, limit) { const pending = [...tasks]; const children = new Set(); let failed = false; const workers = Array.from({ length: Math.min(limit, pending.length) }, async () => { while (pending.length > 0 && !failed) { const task = pending.shift(); const code = await task(children); if (code !== 0) { failed = true; for (const child of children) child.kill("SIGTERM"); } } }); await Promise.all(workers); return failed ? 1 : 0; } function npmBuild() { const npmCli = process.env.npm_execpath; if (!npmCli) { process.stderr.write("npm_execpath is required; run via npm run test:ci-shard\n"); return 1; } const result = spawnSync(process.execPath, [npmCli, "run", "build"], { stdio: "inherit", env: process.env, }); return result.status ?? 1; } async function main() { const mode = process.argv[2]; const root = process.cwd(); const built = npmBuild(); if (built !== 0) process.exit(built); if (mode === "serial") { const files = planSerial(root); process.exit(await runPool([ (children) => runNode(testArgs(1, files), "serial", children), ], 1)); } const lightShard = /^light-(\d+)$/.exec(mode); if (mode === "light" || lightShard) { const files = lightShard ? planLightShard(root, Number(lightShard[1]), LIGHT_SHARD_TOTAL) : planLight(root); const label = lightShard ? mode : "light"; process.exit(await runPool([ (children) => runNode(testArgs(LIGHT_CONCURRENCY, files), label, children), ], 1)); } if (mode === "engine") { const index = Number(process.argv[3]); const total = Number(process.argv[4] ?? SHARD_TOTAL); const plan = planEngineShard(root, index, total); process.exit(await runPool([ (children) => runNode(testArgs(1, [plan.file], plan.patterns), "engine", children), ], 1)); } process.stderr.write("usage: ci-unit-shard.mjs <serial|light|light-<index>|engine> [index total]\n"); process.exit(2); } if (process.argv[1] && process.argv[1].endsWith("ci-unit-shard.mjs")) { main().catch((error) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exit(1); }); }