mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
Merge current master for native recovery qualification
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
5ea45f8d17
322 files changed
+21745
-1361
No files matched your search
@@ -249,7 +249,7 @@ test("the trusted PR workflow limits full CI to merge-relevant stack layers", ()
|
||||
]) {
|
||||
assert.match(
|
||||
jobs.get(jobId),
|
||||
/^ {4}if: \$\{\{ needs\.gate\.outputs\.full_ci == 'true' && /m,
|
||||
/^ {4}if: \$\{\{ (?:always\(\) && !cancelled\(\) && needs\.gate\.result == 'success' && )?needs\.gate\.outputs\.full_ci == 'true' && /m,
|
||||
`${jobId} must run only when the gate selects full CI`,
|
||||
);
|
||||
}
|
||||
@@ -263,10 +263,14 @@ test("the trusted PR workflow limits full CI to merge-relevant stack layers", ()
|
||||
const verify = jobs.get("verify");
|
||||
assert.match(
|
||||
verify,
|
||||
/^ {4}needs: \[gate, policy, typecheck_release_registry, general_tests, verify_paperclip_runner, build, docker_context_integrity, verify_serialized_server, canary_dry_run\]$/m,
|
||||
/^ {4}needs: \[gate, test_selection, policy, typecheck_release_registry, general_tests, verify_paperclip_runner, build, docker_context_integrity, verify_serialized_server, canary_dry_run\]$/m,
|
||||
);
|
||||
assert.equal(runAggregate(verify, true).status, 0);
|
||||
assert.equal(runAggregate(verify, false).status, 0);
|
||||
for (const result of ["failure", "cancelled", "skipped"]) {
|
||||
assert.equal(runAggregate(verify, true, { test_selection: result }).status, 0,
|
||||
"an optional selector cannot block the successful original matrix");
|
||||
}
|
||||
for (const job of ["gate", "policy", "typecheck_release_registry", "general_tests", "verify_paperclip_runner",
|
||||
"build", "docker_context_integrity", "verify_serialized_server", "canary_dry_run"]) {
|
||||
for (const result of ["failure", "cancelled", "skipped"]) {
|
||||
|
||||
@@ -579,6 +579,7 @@ test("Runner eval workflows pin actions and gate paid live execution", () => {
|
||||
readWorkflow("runner-full-stack-e2e.yml"),
|
||||
readWorkflow("e2e.yml"),
|
||||
readWorkflow("runner-protocol-live-evals.yml"),
|
||||
readWorkflow("chat-feature-testops.yml"),
|
||||
];
|
||||
|
||||
for (const workflow of actionPinWorkflows) {
|
||||
@@ -618,6 +619,7 @@ test("Runner eval workflows pin actions and gate paid live execution", () => {
|
||||
"runner-full-stack-e2e.yml",
|
||||
"runner-live-evals.yml",
|
||||
"runner-protocol-live-evals.yml",
|
||||
"chat-feature-testops.yml",
|
||||
];
|
||||
const paidWorkflowNameSet = new Set(paidWorkflowNames);
|
||||
const providerSecretReference =
|
||||
@@ -650,6 +652,38 @@ test("Runner eval workflows pin actions and gate paid live execution", () => {
|
||||
(block) => [...block.matchAll(providerSecretReference)].length > 0,
|
||||
);
|
||||
assert.ok(providerJobs.length > 0, `${name} needs a provider-secret job`);
|
||||
if (name === "chat-feature-testops.yml") {
|
||||
assert.equal(providerJobs.length, 1);
|
||||
const assertBoundary = (block) => {
|
||||
assert.match(block, /^ chat:\n/);
|
||||
assert.match(block, /\n needs: \[authorize, select, build\]\n/);
|
||||
assert.match(block, /\n if: needs\.authorize\.outputs\.operation == 'evaluate'\n/);
|
||||
assert.match(block, /\n environment: runner-e2e-private-paid\n/);
|
||||
assert.match(block, /\n - \*checkout\n - \*node\n - \*guard\n/);
|
||||
assert.match(block, /testops-chat-reporter\.mjs verify-selection chat-testops-selection\/testops-selection\.json/);
|
||||
assert.match(block, /OPENROUTER_API_KEY: \$\{\{ secrets\.OPENROUTER_API_KEY \}\}/);
|
||||
assert.match(block, /--id chat-features\.runner-opencode-deepseek-1017\.local\.create-backlog --max-parallel 1 --max-automatic-retries 0/);
|
||||
assert.doesNotMatch(block, /ALLURE_TOKEN/);
|
||||
assert.equal([...block.matchAll(providerSecretReference)].length, 1);
|
||||
assert.ok(block.indexOf(" - *guard") < block.search(providerSecretReference));
|
||||
assert.ok(block.indexOf("testops-chat-reporter.mjs verify-selection") < block.search(providerSecretReference));
|
||||
};
|
||||
const block = providerJobs[0];
|
||||
assertBoundary(block);
|
||||
assert.equal([...workflow.matchAll(providerSecretReference)].length, 1);
|
||||
assert.match(workflow, /ref: \$\{\{ github\.sha \}\}/);
|
||||
assert.match(workflow, /run: node tests\/runner-e2e\/testops-chat-dispatch\.ts/);
|
||||
assert.match(workflow, /TESTOPS_ALLOWED_ACTOR_IDS: \$\{\{ vars\.RUNNER_E2E_ALLOWED_ACTOR_IDS \}\}/);
|
||||
for (const mutant of [
|
||||
block.replace("needs: [authorize, select, build]", "needs: [authorize, build]"),
|
||||
block.replace("environment: runner-e2e-private-paid", "environment: unprotected"),
|
||||
block.replace(" - *guard\n", ""),
|
||||
block.replace("testops-chat-reporter.mjs verify-selection", "testops-chat-reporter.mjs unverified-selection"),
|
||||
block.replace("OPENROUTER_API_KEY:", "ALLURE_TOKEN:"),
|
||||
block.replace("--id chat-features.runner-opencode-deepseek-1017.local.create-backlog", "--id chat-features.runner-opencode.local.create-backlog"),
|
||||
]) assert.throws(() => assertBoundary(mutant));
|
||||
continue;
|
||||
}
|
||||
for (const block of providerJobs) {
|
||||
if (name === "runner-protocol-live-evals.yml") {
|
||||
assert.ok(block.includes("name: ${{ needs.authorize.outputs.accounting_mode == 'controlled' && 'runner-e2e-private-paid' || 'runner-e2e-paid' }}"));
|
||||
|
||||
@@ -320,6 +320,7 @@ test("the real serialized shard partition is complete, non-overlapping and durat
|
||||
const chatSuitePath = "server/src/__tests__/chat-channels.integration.test.ts";
|
||||
const nativeRunnerSuitePath =
|
||||
"server/src/services/native-runtime/native-codex-runner.integration.test.ts";
|
||||
const usageDeliverySuitePath = "server/src/services/native-runtime/native-usage-delivery.integration.test.ts";
|
||||
const dotRunnerSuitePath = "server/src/__tests__/dot-runner.test.ts";
|
||||
const restartRecoverySuitePath =
|
||||
"server/src/services/native-runtime/native-runner-restart-recovery.integration.test.ts";
|
||||
@@ -363,7 +364,8 @@ test("12 PR without-chat shards plus the dedicated chat and native-runner lanes
|
||||
assert.ok(!files.includes(nativeRunnerSuitePath));
|
||||
assert.ok(!files.includes(dotRunnerSuitePath));
|
||||
assert.ok(!files.includes(restartRecoverySuitePath));
|
||||
assert.deepEqual([...files, chatSuitePath, nativeRunnerSuitePath, dotRunnerSuitePath, restartRecoverySuitePath, "server/src/services/native-runtime/plugin-runner-probe.test.ts"].sort(), full.selectedGeneralServerSuites.sort());
|
||||
assert.ok(!files.includes(usageDeliverySuitePath));
|
||||
assert.deepEqual([...files, chatSuitePath, nativeRunnerSuitePath, dotRunnerSuitePath, restartRecoverySuitePath, usageDeliverySuitePath, "server/src/services/native-runtime/plugin-runner-probe.test.ts"].sort(), full.selectedGeneralServerSuites.sort());
|
||||
assert.equal(new Set(files).size, files.length);
|
||||
const defaultRun = dryRunJson([], prEnv);
|
||||
assert.ok(defaultRun.generalServerSuiteCount === full.generalServerSuiteCount);
|
||||
@@ -412,6 +414,7 @@ test("the native-runner lane runs exactly the cargo-dependent suites", () => {
|
||||
"server/src/services/native-runtime/native-codex-runner.integration.test.ts",
|
||||
dotRunnerSuitePath,
|
||||
restartRecoverySuitePath,
|
||||
usageDeliverySuitePath,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,63 @@ import { executionPlan, sourceFiles } from "./execution-plan.mjs";
|
||||
const repositoryRoot = new URL("../../", import.meta.url);
|
||||
const turbo = process.env.PAPERCLIP_CI_TEST_TURBO ?? new URL("node_modules/.bin/turbo", repositoryRoot).pathname;
|
||||
|
||||
test("real Turbo keeps full runs and different selected test sets in separate cache entries", () => {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "ci-selection-cache-"));
|
||||
const write = (file, value) => writeFileSync(path.join(directory, file), value);
|
||||
const json = (file, value) => write(file, JSON.stringify(value));
|
||||
const git = (...args) => execFileSync("git", args, { cwd: directory, encoding: "utf8" }).trim();
|
||||
try {
|
||||
git("init", "-q");
|
||||
for (const folder of ["scripts/ci", "server/src", "packages/fixture", "bin", ".ci-test-selection"]) mkdirSync(path.join(directory, folder), { recursive: true });
|
||||
for (const file of ["run-task.mjs", "execution-plan.mjs", "test-selection-manifest.mjs"]) {
|
||||
copyFileSync(new URL(`scripts/ci/${file}`, repositoryRoot), path.join(directory, "scripts/ci", file));
|
||||
}
|
||||
mkdirSync(path.join(directory, ".github/scripts"), { recursive: true });
|
||||
copyFileSync(new URL(".github/scripts/ci-inputs.mjs", repositoryRoot), path.join(directory, ".github/scripts/ci-inputs.mjs"));
|
||||
write(".gitignore", ".turbo/\n.ci-test-selection/\nresult.txt\ncalls.txt\n");
|
||||
json("package.json", { name: "selection-cache-proof", private: true, packageManager: "npm@10.0.0", workspaces: ["packages/*"], scripts: { "test:run:general": "node check.cjs" } });
|
||||
json("packages/fixture/package.json", { name: "fixture", private: true });
|
||||
json("package-lock.json", { name: "selection-cache-proof", lockfileVersion: 3, packages: { "": { name: "selection-cache-proof" } } });
|
||||
json("turbo.json", { globalDependencies: ["doc/**", "docs/**"], globalEnv: ["CI_TASK_CACHE_SCOPE", "PAPERCLIP_*"], tasks: {
|
||||
"//#ci:server": { inputs: ["check.cjs", "server/**"], outputs: ["result.txt"], cache: true },
|
||||
} });
|
||||
mkdirSync(path.join(directory, "doc/plans"), { recursive: true });
|
||||
mkdirSync(path.join(directory, "docs/api"), { recursive: true });
|
||||
write("doc/DEVELOPING.md", "before"); write("doc/plans/example.md", "before");
|
||||
write("docs/api/issues.md", "before");
|
||||
write("server/src/a.test.ts", "// a"); write("server/src/b.test.ts", "// b");
|
||||
write("check.cjs", `const fs=require('node:fs');fs.appendFileSync('calls.txt','call\\n');const file=process.env.PAPERCLIP_CI_TEST_SELECTION;fs.writeFileSync('result.txt',file?JSON.parse(fs.readFileSync(file)).lanes['general-server-without-chat'].shards[0]['@paperclipai/server'].join(','):'full');`);
|
||||
write("bin/pnpm", `#!${process.execPath}\nconst {spawnSync}=require('node:child_process');const args=process.argv.slice(2);if(args[0]==='--version')console.log('9.15.4');else{const r=spawnSync(${JSON.stringify(turbo)},args.slice(2),{stdio:'inherit'});process.exit(r.status??1);}`);
|
||||
chmodSync(path.join(directory, "bin/pnpm"), 0o755);
|
||||
git("add", ".");
|
||||
git("-c", "user.name=CI", "-c", "user.email=ci@example.invalid", "commit", "-qm", "fixture");
|
||||
const head = git("rev-parse", "HEAD");
|
||||
const calls = () => readFileSync(path.join(directory, "calls.txt"), "utf8").trim().split("\n").length;
|
||||
const run = (testFile) => {
|
||||
const selection = path.join(directory, ".ci-test-selection/manifest.json");
|
||||
json(".ci-test-selection/manifest.json", { version: 1, head, lanes: {
|
||||
"general-server-without-chat": { shards: [{ "@paperclipai/server": [testFile ?? "server/src/a.test.ts"] }] },
|
||||
} });
|
||||
const result = spawnSync(process.execPath, [path.join(directory, "scripts/ci/run-task.mjs")], {
|
||||
cwd: directory, encoding: "utf8", env: { ...process.env, PATH: `${directory}/bin:${process.env.PATH}`,
|
||||
CI_TASK: "ci:server", CI_TASK_ARGS: "[]", CI_FORCE_TASKS: "false", CI_TASK_DRY_RUN: "false",
|
||||
PAPERCLIP_CI_TEST_SELECTION: testFile ? selection : "", PAPERCLIP_CI_SELECTION_HASH: "",
|
||||
TURBO_TOKEN: "", TURBO_TEAM: "", TURBO_TELEMETRY_DISABLED: "1" },
|
||||
});
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
assert.equal(readFileSync(path.join(directory, "result.txt"), "utf8"), testFile ?? "full");
|
||||
};
|
||||
run(); run(); assert.equal(calls(), 1);
|
||||
write("doc/DEVELOPING.md", "after"); write("doc/plans/example.md", "after");
|
||||
run(); assert.equal(calls(), 1, "audited prose does not invalidate product test results");
|
||||
run("server/src/a.test.ts"); run("server/src/a.test.ts"); assert.equal(calls(), 2);
|
||||
run("server/src/b.test.ts"); assert.equal(calls(), 3);
|
||||
run(); assert.equal(calls(), 3, "the original full result is reusable without accepting a subset result");
|
||||
write("docs/api/issues.md", "after");
|
||||
run(); assert.equal(calls(), 4, "runtime documentation must invalidate results");
|
||||
} finally { rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("every cached lane maps to an existing package script with complete inputs", () => {
|
||||
const config = JSON.parse(readFileSync(new URL("turbo.json", repositoryRoot), "utf8"));
|
||||
for (const [name, definition] of Object.entries(config.tasks).filter(([, task]) => task.cache)) {
|
||||
@@ -35,6 +92,8 @@ test("real Turbo executes existing suite scripts and invalidates source, argumen
|
||||
const json = (name, value) => write(name, JSON.stringify(value));
|
||||
try {
|
||||
execFileSync("git", ["init", "-q"], { cwd: directory });
|
||||
mkdirSync(path.join(directory, ".github/scripts"), { recursive: true });
|
||||
copyFileSync(new URL(".github/scripts/ci-inputs.mjs", repositoryRoot), path.join(directory, ".github/scripts/ci-inputs.mjs"));
|
||||
write(".gitignore", ".turbo/\ngenerated/\nresult.txt\ncalls.txt\n");
|
||||
mkdirSync(path.join(directory, "packages/fixture"), { recursive: true });
|
||||
json("packages/fixture/package.json", { name: "fixture", private: true });
|
||||
@@ -83,9 +142,11 @@ test("the task launcher bypasses cache reads and writes when execution is forced
|
||||
mkdirSync(path.join(directory, "scripts/ci"), { recursive: true });
|
||||
mkdirSync(path.join(directory, "bin"));
|
||||
mkdirSync(path.join(directory, "packages/fixture"), { recursive: true });
|
||||
for (const name of ["run-task.mjs", "execution-plan.mjs"]) {
|
||||
for (const name of ["run-task.mjs", "execution-plan.mjs", "test-selection-manifest.mjs"]) {
|
||||
copyFileSync(new URL(`scripts/ci/${name}`, repositoryRoot), path.join(directory, "scripts/ci", name));
|
||||
}
|
||||
mkdirSync(path.join(directory, ".github/scripts"), { recursive: true });
|
||||
copyFileSync(new URL(".github/scripts/ci-inputs.mjs", repositoryRoot), path.join(directory, ".github/scripts/ci-inputs.mjs"));
|
||||
write(".gitignore", ".turbo/\ncalls.txt\nresult.txt\nbin/\n");
|
||||
json("packages/fixture/package.json", { name: "fixture", private: true });
|
||||
json("package.json", { name: "forced-cache-proof", private: true, packageManager: "npm@10.0.0",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { matchesGlob } from "node:path";
|
||||
import { productInputs } from "../../.github/scripts/ci-inputs.mjs";
|
||||
|
||||
export function sourceFiles(cwd = process.cwd()) {
|
||||
return [...new Set(execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
|
||||
@@ -30,7 +31,7 @@ export function executionPlan(config, task, args, files = sourceFiles()) {
|
||||
target = "@paperclipai/paperclip-runner#check:runner";
|
||||
commandArgs = args;
|
||||
} else throw new Error(`Missing task command: ${task}`);
|
||||
const select = (globs) => files.filter((file) => globs.some((glob) => matchesGlob(file, glob))).map(literalGlob);
|
||||
const select = (globs) => productInputs(files).filter((file) => globs.some((glob) => matchesGlob(file, glob))).map(literalGlob);
|
||||
const selectedInputs = select(definition.inputs);
|
||||
const inputs = target.startsWith("//#") ? selectedInputs
|
||||
: selectedInputs.map((input) => `$TURBO_ROOT$/${input}`);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { createVitest, parseCLI } from "vitest/node";
|
||||
|
||||
// Positional Vitest CLI filters are substrings: foo.test.ts also selects
|
||||
// foo.test.tsx. Run the exact discovered specifications assigned to this shard.
|
||||
const { filter, options } = parseCLI(["vitest", "run", ...process.argv.slice(2)]);
|
||||
assert.ok(filter.length > 0, "Selected execution requires explicit test files");
|
||||
const expected = new Set(filter.map((file) => path.resolve(file)));
|
||||
assert.equal(expected.size, filter.length, "Duplicate selected file");
|
||||
process.env.TEST = "true";
|
||||
process.env.VITEST = "true";
|
||||
process.env.NODE_ENV ??= "test";
|
||||
// Match the CLI's additive --exclude semantics without replacing project rules.
|
||||
options.cliExclude = options.exclude;
|
||||
delete options.exclude;
|
||||
const ctx = await createVitest({ ...options, watch: false });
|
||||
try {
|
||||
await ctx.standalone();
|
||||
const specifications = (await ctx.globTestSpecifications()).filter((spec) => expected.has(spec.moduleId));
|
||||
assert.deepEqual(specifications.map((spec) => spec.moduleId).sort(), [...expected].sort(),
|
||||
"Selected files must match exactly one discovered specification in this project");
|
||||
await ctx.runTestSpecifications(specifications);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await ctx.exit();
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from
|
||||
import { createHash } from "node:crypto";
|
||||
import { matchesGlob } from "node:path";
|
||||
import { executionPlan, sourceFiles } from "./execution-plan.mjs";
|
||||
import { loadSelection, selectionHash } from "./test-selection-manifest.mjs";
|
||||
import { productInputs } from "../../.github/scripts/ci-inputs.mjs";
|
||||
|
||||
const task = process.env.CI_TASK;
|
||||
if (!/^ci:[a-z-]+$/.test(task ?? "")) throw new Error("Invalid CI task");
|
||||
@@ -15,13 +17,18 @@ const version = (command, argv) => {
|
||||
const config = JSON.parse(readFileSync("turbo.json", "utf8"));
|
||||
const files = sourceFiles();
|
||||
const execution = executionPlan(config, task, args, files);
|
||||
// Validate before Turbo can return a cache hit. The hash separates selected
|
||||
// executions from full executions and from every different selected file set.
|
||||
if (process.env.PAPERCLIP_CI_TEST_SELECTION) {
|
||||
process.env.PAPERCLIP_CI_SELECTION_HASH = selectionHash(loadSelection());
|
||||
}
|
||||
mkdirSync(".turbo/task-configs", { recursive: true });
|
||||
const runtimeConfig = `.turbo/task-configs/${task.replace(":", "-")}.json`;
|
||||
writeFileSync(runtimeConfig, JSON.stringify(execution.config));
|
||||
const classified = [...config.globalDependencies, ...Object.values(config.tasks).flatMap((entry) => entry.inputs)];
|
||||
const unknownInputs = createHash("sha256");
|
||||
let uncacheable = false;
|
||||
for (const file of files) {
|
||||
for (const file of productInputs(files)) {
|
||||
if (classified.some((glob) => matchesGlob(file, glob))) continue;
|
||||
unknownInputs.update(JSON.stringify(file));
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { appendFileSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { refineTestPlan, selectionDurations } from "./test-selection-plan.mjs";
|
||||
import { productInputs } from "../../.github/scripts/ci-inputs.mjs";
|
||||
import { mergeDiff } from "../../.github/scripts/ci-diff.mjs";
|
||||
|
||||
export function selectionEligibility(plan) {
|
||||
if (plan?.version !== 1 || plan.mode !== "enabled" || plan.forceReason) return "Full/shadow/forced task plan";
|
||||
if (!Array.isArray(plan.changed) || !plan.changed.length) return "Incomplete comparison";
|
||||
const changed = productInputs(plan.changed);
|
||||
if (!changed.length) return "Documentation-only diff";
|
||||
for (const file of changed) {
|
||||
if (!/^(?:ui\/src|server\/src|cli\/src|packages\/.+\/src)\/.+\.[cm]?[jt]sx?$/.test(file)
|
||||
|| /(?:config|setup)|\.d\.[cm]?ts$/i.test(file) || file.split("/").includes("..")) return `Broad input: ${file}`;
|
||||
try {
|
||||
if (!lstatSync(file).isFile()) return `Non-regular source: ${file}`;
|
||||
} catch { return `Deleted or missing source: ${file}`; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function runnerInventory(group) {
|
||||
const args = group === "serialized" ? ["--mode", "serialized", "--shard-index", "0", "--shard-count", "1"]
|
||||
: ["--mode", "general", "--group", group,
|
||||
...(group === "general-server-without-chat" ? ["--shard-index", "0", "--shard-count", "1"] : [])];
|
||||
const env = { ...process.env, GITHUB_WORKFLOW: "PR" };
|
||||
delete env.PAPERCLIP_CI_TEST_SELECTION;
|
||||
return JSON.parse(execFileSync(process.execPath, ["scripts/run-vitest-stable.mjs", ...args, "--dry-run"],
|
||||
{ env, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }));
|
||||
}
|
||||
|
||||
export async function createTestSelection(plan, head) {
|
||||
const reason = selectionEligibility(plan);
|
||||
if (reason) throw new Error(reason);
|
||||
const groups = new Set();
|
||||
for (const job of ["general_tests", "verify_serialized_server"]) {
|
||||
if (plan.jobs[job].run) for (const entry of plan.jobs[job].matrix.include) groups.add(entry.group ?? "serialized");
|
||||
}
|
||||
groups.delete("general-chat"); // Keep its line sharding and DB ownership intact.
|
||||
const owners = {}, projects = new Set();
|
||||
for (const group of groups) {
|
||||
const inventory = runnerInventory(group);
|
||||
if (group === "serialized" || group === "general-server-without-chat") {
|
||||
owners[group] = { "@paperclipai/server": group === "serialized"
|
||||
? inventory.selectedSerializedSuites : inventory.selectedGeneralServerSuites };
|
||||
projects.add("@paperclipai/server");
|
||||
} else {
|
||||
owners[group] = Object.fromEntries(inventory.workspaceProjects.map((project) => [project, null]));
|
||||
for (const project of inventory.workspaceProjects) projects.add(project);
|
||||
}
|
||||
}
|
||||
const { selectProjectTests } = await import("./vitest-selection-graph.mjs");
|
||||
const graphs = [];
|
||||
const changed = productInputs(plan.changed);
|
||||
for (const project of projects) {
|
||||
const graph = await selectProjectTests({ project, changed, onlyOwnChanges: true });
|
||||
graphs.push(graph);
|
||||
console.log(`${project}: ${graph.selected.length}/${graph.inventory.length} files, ${graph.alwaysRun.length} retained conservatively, ${graph.seconds.toFixed(1)}s`);
|
||||
for (const owner of Object.values(owners)) if (owner[project] === null) owner[project] = graph.inventory;
|
||||
}
|
||||
const covered = new Set(graphs.flatMap((graph) => graph.coveredChanges));
|
||||
for (const file of changed) assert.ok(covered.has(file), `Change outside the resolved test graphs: ${file}`);
|
||||
const server = graphs.find((graph) => graph.project === "@paperclipai/server");
|
||||
if (server) {
|
||||
const general = runnerInventory("general-server-without-chat");
|
||||
const native = runnerInventory("general-server-native-runner");
|
||||
assert.deepEqual([...general.selectedGeneralServerSuites, ...general.selectedSerializedSuites,
|
||||
...native.selectedGeneralServerSuites, "server/src/__tests__/chat-channels.integration.test.ts"].sort(),
|
||||
server.inventory, "Server inventory must be completely covered by existing lane ownership");
|
||||
}
|
||||
return { ...refineTestPlan({ plan, head, owners, graphs, durations: selectionDurations() }), graphs };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const plan = JSON.parse(process.env.CI_PLAN || "null");
|
||||
const output = (key, value) => {
|
||||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`);
|
||||
};
|
||||
if (process.argv.includes("--eligible")) {
|
||||
const reason = selectionEligibility(plan);
|
||||
console.log(reason ?? "Source diff is eligible for graph selection");
|
||||
output("enabled", !reason);
|
||||
return;
|
||||
}
|
||||
const directory = ".ci-test-selection";
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const git = (...args) => execFileSync("git", args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }).trim();
|
||||
let result;
|
||||
try {
|
||||
const head = git("rev-parse", "HEAD");
|
||||
assert.equal(head, process.env.GITHUB_SHA, "Selection must describe the tested merge commit");
|
||||
assert.ok(plan.diff, "Missing trusted merge diff");
|
||||
const local = mergeDiff(head, plan.diff.prHead);
|
||||
assert.deepEqual(local.diff, plan.diff, "Selection must use the trusted merge parents");
|
||||
assert.deepEqual(local.changed, [...new Set(plan.changed)].sort(), "Local diff disagrees with the trusted routing diff");
|
||||
assert.equal(git("status", "--porcelain", "--untracked-files=normal"), "", "Source inputs changed during dependency setup");
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.VITEST = "true";
|
||||
result = await createTestSelection(plan, head);
|
||||
writeFileSync(`${directory}/manifest.json`, JSON.stringify(result.manifest));
|
||||
writeFileSync(`${directory}/audit.json`, JSON.stringify(result.graphs, null, 2));
|
||||
output("selected", "true");
|
||||
} catch (error) {
|
||||
console.warn(`Using the complete task plan: ${error.message}`);
|
||||
result = { plan: { ...plan, testSelectionFallback: error.message } };
|
||||
output("selected", "false");
|
||||
}
|
||||
writeFileSync(`${directory}/plan.json`, JSON.stringify(result.plan));
|
||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||
const stats = result.plan.testSelection?.stats;
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, stats
|
||||
? ["### Vitest test selection", "", "| Lane | Test files | Jobs |", "| --- | ---: | ---: |",
|
||||
...stats.map((s) => `| ${s.group} | ${s.selected}/${s.total} | ${s.jobs}/${s.beforeJobs} |`), "",
|
||||
"Filesystem/process/opaque consumers are retained. Chat and native Runner lanes keep their existing ownership.", ""].join("\n")
|
||||
: `### Vitest test selection\n\nFull task plan: ${result.plan.testSelectionFallback}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
|
||||
export const selectableGroups = ["general-server-without-chat", "general-workspaces-a",
|
||||
"general-workspaces-b", "general-workspaces-c", "serialized"];
|
||||
|
||||
export function selectionHash(manifest) {
|
||||
return createHash("sha256").update(JSON.stringify({ version: manifest.version, lanes: manifest.lanes })).digest("hex");
|
||||
}
|
||||
|
||||
export function validateSelection(manifest, { head, hash } = {}) {
|
||||
assert.equal(manifest.version, 1, "Unsupported test selection");
|
||||
assert.match(manifest.head, /^[a-f0-9]{40}$/);
|
||||
if (head) assert.equal(manifest.head, head, "Selection belongs to another checkout");
|
||||
assert.ok(manifest.lanes && typeof manifest.lanes === "object" && !Array.isArray(manifest.lanes));
|
||||
const seen = new Set();
|
||||
for (const [group, lane] of Object.entries(manifest.lanes)) {
|
||||
assert.ok(selectableGroups.includes(group), `Unknown selection group: ${group}`);
|
||||
assert.ok(Array.isArray(lane.shards) && lane.shards.length > 0 && lane.shards.length <= 12);
|
||||
for (const shard of lane.shards) {
|
||||
assert.ok(shard && typeof shard === "object" && !Array.isArray(shard));
|
||||
let count = 0;
|
||||
for (const [project, files] of Object.entries(shard)) {
|
||||
assert.ok(project && Array.isArray(files) && files.length > 0);
|
||||
for (const file of files) {
|
||||
assert.equal(typeof file, "string");
|
||||
assert.ok(/^(ui|server|cli|packages)\//.test(file) && !/[\0\r\n\\:*?\[\]{}]/.test(file)
|
||||
&& !file.split("/").some((part) => ["", ".", ".."].includes(part)), `Invalid selected path: ${file}`);
|
||||
const key = `${project}:${file}`;
|
||||
assert.ok(!seen.has(key), `Test assigned twice: ${key}`);
|
||||
seen.add(key);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
assert.ok(count > 0, "Empty selection shard");
|
||||
}
|
||||
}
|
||||
if (hash) assert.equal(selectionHash(manifest), hash, "Selection artifact does not match its plan");
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function loadSelection(file = process.env.PAPERCLIP_CI_TEST_SELECTION) {
|
||||
const head = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
||||
const manifest = validateSelection(JSON.parse(readFileSync(file, "utf8")), {
|
||||
head, hash: process.env.PAPERCLIP_CI_SELECTION_HASH,
|
||||
});
|
||||
for (const lane of Object.values(manifest.lanes)) {
|
||||
for (const shard of lane.shards) for (const files of Object.values(shard)) {
|
||||
for (const file of files) assert.ok(statSync(file).isFile(), `Selected test missing: ${file}`);
|
||||
}
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function selectedShard(manifest, options, projects) {
|
||||
const group = options.mode === "serialized" ? "serialized" : options.group;
|
||||
const lane = manifest.lanes[group];
|
||||
assert.ok(lane, `Missing selection for ${group}`);
|
||||
assert.equal(lane.shards.length, options.shardCount ?? 1, "Selection shard count mismatch");
|
||||
const shard = lane.shards[options.shardIndex ?? 0];
|
||||
assert.ok(shard, "Missing selection shard");
|
||||
for (const project of Object.keys(shard)) assert.ok(projects.includes(project), `Wrong project for ${group}: ${project}`);
|
||||
return shard;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { loadShardDurations, partitionGeneralServerSuites } from "../general-server-shard.mjs";
|
||||
import { selectionHash, validateSelection } from "./test-selection-manifest.mjs";
|
||||
|
||||
// Preserve the existing per-job budget. Small selections use fewer runners;
|
||||
// large selections retain the existing parallelism and serialized ownership.
|
||||
export function partitionSelection(inventory, selected, maxShards, durations = {}) {
|
||||
const totalWeight = partitionGeneralServerSuites(inventory, 1, durations)[0].totalWeight;
|
||||
const selectedWeight = partitionGeneralServerSuites(selected, 1, durations)[0].totalWeight;
|
||||
const budget = totalWeight / maxShards || 1;
|
||||
const count = Math.min(maxShards, selected.length, Math.max(1, Math.ceil(selectedWeight / budget)));
|
||||
return selected.length ? partitionGeneralServerSuites(selected, count, durations).map((shard) => shard.files) : [];
|
||||
}
|
||||
|
||||
export function refineTestPlan({ plan, head, owners, graphs, durations = {} }) {
|
||||
const result = structuredClone(plan);
|
||||
const manifest = { version: 1, head, lanes: {} };
|
||||
const stats = [];
|
||||
const byProject = new Map(graphs.map((graph) => [graph.project, graph]));
|
||||
for (const job of ["general_tests", "verify_serialized_server"]) {
|
||||
if (!plan.jobs[job].run) continue;
|
||||
const entries = plan.jobs[job].matrix.include;
|
||||
const groups = [...new Set(entries.map((entry) => entry.group ?? "serialized"))];
|
||||
const matrix = [];
|
||||
for (const group of groups) {
|
||||
const original = entries.filter((entry) => (entry.group ?? "serialized") === group);
|
||||
if (group === "general-chat") { matrix.push(...original); continue; }
|
||||
const owned = owners[group];
|
||||
assert.ok(owned, `Missing inventory owner: ${group}`);
|
||||
const projectByFile = new Map();
|
||||
const selected = [];
|
||||
for (const [project, files] of Object.entries(owned)) {
|
||||
const graph = byProject.get(project);
|
||||
assert.ok(graph, `Missing project graph: ${project}`);
|
||||
const inventory = new Set(graph.inventory);
|
||||
const affected = new Set(graph.selected);
|
||||
for (const file of files) {
|
||||
assert.ok(inventory.has(file), `Runner/Vitest inventory disagreement: ${file}`);
|
||||
assert.ok(!projectByFile.has(file), `Ambiguous owner: ${file}`);
|
||||
projectByFile.set(file, project);
|
||||
if (affected.has(file)) selected.push(file);
|
||||
}
|
||||
}
|
||||
const partitions = partitionSelection([...projectByFile.keys()], selected, original.length, durations[group]);
|
||||
if (partitions.length) {
|
||||
const shards = partitions.map((files) => {
|
||||
const shard = {};
|
||||
for (const file of files) (shard[projectByFile.get(file)] ??= []).push(file);
|
||||
return shard;
|
||||
});
|
||||
manifest.lanes[group] = { shards };
|
||||
for (let index = 0; index < shards.length; index++) {
|
||||
const entry = { ...original[0], selection: true };
|
||||
if (original[0].shard_count) {
|
||||
Object.assign(entry, { shard_index: index, shard_count: shards.length });
|
||||
if (entry.shard_label) entry.shard_label = `${index + 1}/${shards.length}`;
|
||||
if (entry.group_label) entry.group_label = entry.group_label.replace(/\(\d+\/\d+\)/, `(${index + 1}/${shards.length})`);
|
||||
}
|
||||
matrix.push(entry);
|
||||
}
|
||||
}
|
||||
stats.push({ group, total: projectByFile.size, selected: selected.length, beforeJobs: original.length, jobs: partitions.length });
|
||||
}
|
||||
result.jobs[job] = { ...result.jobs[job], run: matrix.length > 0, selected: matrix.length, proposed: matrix.length,
|
||||
matrix: { include: matrix.length ? matrix : entries } };
|
||||
}
|
||||
validateSelection(manifest);
|
||||
result.testSelection = { version: 1, head, hash: selectionHash(manifest), stats };
|
||||
return { plan: result, manifest };
|
||||
}
|
||||
|
||||
export function selectionDurations() {
|
||||
return {
|
||||
"general-server-without-chat": loadShardDurations("scripts/general-server-shard-durations.json"),
|
||||
serialized: loadShardDurations("scripts/serialized-shard-durations.json"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { selectProjectTests } from "./vitest-selection-graph.mjs";
|
||||
import { selectionHash } from "./test-selection-manifest.mjs";
|
||||
|
||||
test("production runner catches the filesystem regression that raw Vitest selection misses", async (t) => {
|
||||
const repo = process.cwd();
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "ci-vr-")));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
for (const directory of ["ui/src", "server/src/__tests__", "server/scripts"]) mkdirSync(path.join(root, directory), { recursive: true });
|
||||
symlinkSync(path.join(repo, "node_modules"), path.join(root, "node_modules"), "dir");
|
||||
const write = (file, body) => writeFileSync(path.join(root, file), body);
|
||||
const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
|
||||
write(".gitignore", "node_modules/\nselection.json\n");
|
||||
write("package.json", '{"name":"selection-regression","private":true,"type":"module"}');
|
||||
write("vitest.config.mjs", 'export default {test:{projects:["ui"]}}');
|
||||
write("ui/vitest.config.mjs", 'export default {test:{name:"@paperclipai/ui",include:["src/**/*.test.mjs"]}}');
|
||||
write("ui/src/leaf.mjs", "export const value = 42;\n");
|
||||
write("ui/src/import.test.mjs", 'import {test,expect} from "vitest"; import {value} from "./leaf.mjs"; test("value",()=>expect(value).toBe(42));');
|
||||
write("ui/src/import.test.mjs.test.mjs", 'throw new Error("UNSELECTED_FILE: substring filters must not execute this file");');
|
||||
write("ui/src/contract.test.mjs", 'import {test,expect} from "vitest"; import {readFileSync} from "node:fs"; test("filesystem contract",()=>expect(readFileSync(new URL("./leaf.mjs",import.meta.url),"utf8")).not.toContain("FORBIDDEN_MARKER"));');
|
||||
git("init", "-q"); git("add", ".");
|
||||
const commit = () => git("-c", "user.name=CI", "-c", "user.email=ci@example.invalid", "commit", "-qam", "fixture");
|
||||
commit();
|
||||
const run = (files) => {
|
||||
const manifest = { version: 1, head: git("rev-parse", "HEAD"), lanes: {
|
||||
"general-workspaces-a": { shards: [{ "@paperclipai/ui": files }] },
|
||||
} };
|
||||
write("selection.json", JSON.stringify(manifest));
|
||||
return spawnSync(process.execPath, [path.join(repo, "scripts/run-vitest-stable.mjs"), "--mode", "general", "--group", "general-workspaces-a", "--shard-index", "0", "--shard-count", "1"], {
|
||||
cwd: root, encoding: "utf8", timeout: 60000,
|
||||
env: { ...process.env, PAPERCLIP_CI_TEST_SELECTION: path.join(root, "selection.json"),
|
||||
PAPERCLIP_CI_SELECTION_HASH: selectionHash(manifest) },
|
||||
});
|
||||
};
|
||||
const baseline = run(["ui/src/import.test.mjs", "ui/src/contract.test.mjs"]);
|
||||
assert.equal(baseline.status, 0, baseline.stdout + baseline.stderr);
|
||||
const missing = run(["ui/src/leaf.mjs"]);
|
||||
assert.equal(missing.status, 1, "an existing source file is not an executable test specification");
|
||||
assert.match(missing.stdout + missing.stderr, /Selected files must match/);
|
||||
write("ui/src/leaf.mjs", `${readFileSync(path.join(root, "ui/src/leaf.mjs"), "utf8")}// FORBIDDEN_MARKER\n`);
|
||||
commit();
|
||||
const graph = await selectProjectTests({ root, project: "@paperclipai/ui", changed: ["ui/src/leaf.mjs"] });
|
||||
assert.deepEqual(graph.related, ["ui/src/import.test.mjs"]);
|
||||
const raw = run(graph.related);
|
||||
assert.equal(raw.status, 0, raw.stdout + raw.stderr);
|
||||
assert.ok(graph.selected.includes("ui/src/contract.test.mjs"));
|
||||
const guarded = run(graph.selected);
|
||||
assert.equal(guarded.status, 1, guarded.stdout + guarded.stderr);
|
||||
assert.match(guarded.stdout + guarded.stderr, /filesystem contract/);
|
||||
assert.match(guarded.stdout + guarded.stderr, /FORBIDDEN_MARKER/);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
import test from "node:test";
|
||||
import { planTasks } from "../../.github/scripts/ci-plan.mjs";
|
||||
|
||||
const workflow = readFileSync(".github/workflows/pr-trusted.yml", "utf8");
|
||||
const block = (job) => workflow.split(`\n ${job}:\n`)[1].split(/\n [a-z0-9_]+:\n/)[0];
|
||||
const plan = planTasks({ config: JSON.parse(readFileSync("turbo.json", "utf8")),
|
||||
lanes: JSON.parse(readFileSync(".github/ci-task-matrix.json", "utf8")), paths: ["ui/src/pages/Inbox.tsx"] });
|
||||
const evaluate = (expression, needs, cancelled = false) => vm.runInNewContext(expression, {
|
||||
needs, fromJSON: JSON.parse, toJSON: JSON.stringify, always: () => true, cancelled: () => cancelled,
|
||||
});
|
||||
|
||||
test("failed, timed-out, skipped and unavailable selectors leave the original matrix runnable", () => {
|
||||
for (const result of ["failure", "cancelled", "skipped", "success"]) {
|
||||
const needs = { gate: { result: "success", outputs: { full_ci: "true", plan: JSON.stringify(plan) } },
|
||||
test_selection: { result, outputs: { plan: "" } } };
|
||||
const job = block("general_tests");
|
||||
const condition = job.match(/if: \$\{\{ (.*) \}\}/)[1];
|
||||
const matrix = job.match(/matrix: \$\{\{ (.*) \}\}/)[1];
|
||||
assert.equal(evaluate(condition, needs), true, result);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(evaluate(matrix, needs))), plan.jobs.general_tests.matrix);
|
||||
assert.equal(evaluate(condition, needs, true), false, "cancellation must not start more runners");
|
||||
}
|
||||
});
|
||||
|
||||
test("a published selection controls the matrix and the required aggregate", () => {
|
||||
const selected = structuredClone(plan);
|
||||
selected.testSelection = { version: 1, hash: "test" };
|
||||
selected.jobs.general_tests.matrix.include = [selected.jobs.general_tests.matrix.include[0]];
|
||||
selected.jobs.general_tests.selected = 1;
|
||||
const needs = { gate: { result: "success", outputs: { full_ci: "true", plan: JSON.stringify(plan) } },
|
||||
test_selection: { result: "success", outputs: { plan: JSON.stringify(selected) } } };
|
||||
const matrix = block("general_tests").match(/matrix: \$\{\{ (.*) \}\}/)[1];
|
||||
assert.equal(evaluate(matrix, needs).include.length, 1);
|
||||
const aggregate = block("verify");
|
||||
const names = aggregate.match(/needs: \[([^\]]+)\]/)[1].split(", ");
|
||||
const code = aggregate.split(" node --input-type=module <<'NODE'\n")[1].split("\n NODE")[0]
|
||||
.split("\n").map((line) => line.slice(10)).join("\n");
|
||||
const results = Object.fromEntries(names.map((name) => [name, { result:
|
||||
["gate", "policy", "test_selection"].includes(name) || selected.jobs[name]?.run ? "success" : "skipped" }]));
|
||||
const run = () => spawnSync(process.execPath, ["--input-type=module", "-e", code], { encoding: "utf8",
|
||||
env: { ...process.env, FULL_CI: "true", CI_PLAN: JSON.stringify(selected), CI_RESULTS: JSON.stringify(results) } });
|
||||
assert.equal(run().status, 0);
|
||||
for (const job of ["general_tests", "test_selection"]) {
|
||||
for (const result of ["failure", "cancelled", "skipped"]) {
|
||||
results[job].result = result;
|
||||
assert.notEqual(run().status, 0, `${job}: ${result}`);
|
||||
}
|
||||
results[job].result = "success";
|
||||
}
|
||||
selected.jobs.general_tests.run = false;
|
||||
results.general_tests.result = "skipped";
|
||||
assert.equal(run().status, 0, "an explicitly empty selected lane may be skipped");
|
||||
assert.ok(aggregate.includes("needs.test_selection.outputs.plan || needs.gate.outputs.plan"));
|
||||
});
|
||||
|
||||
test("publication follows successful upload and manifests are downloaded without ignoring failures", () => {
|
||||
const selection = block("test_selection");
|
||||
assert.ok(selection.indexOf("Upload validated selection") < selection.indexOf("Publish the executable plan"));
|
||||
assert.match(selection, /timeout-minutes: 5/);
|
||||
assert.match(selection, /vars.CI_VITEST_SELECTION == '' \|\| vars.CI_VITEST_SELECTION == 'enabled'/);
|
||||
for (const job of ["general_tests", "verify_serialized_server"]) {
|
||||
assert.match(block(job), /PAPERCLIP_CI_SELECTION_HASH:.*testSelection.hash/);
|
||||
assert.match(block(job), /Download selected test files\n if: matrix.selection\n uses: actions\/download-artifact@/);
|
||||
assert.doesNotMatch(block(job), /continue-on-error: true\n.*download-artifact/);
|
||||
}
|
||||
});
|
||||
|
||||
test("failed-job retries use the original selector artifact, not the retry attempt", (t) => {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "ci-retry-"));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
mkdirSync(path.join(directory, ".ci-test-selection"));
|
||||
const output = path.join(directory, "output");
|
||||
const publication = block("test_selection").split(" - name: Publish the executable plan")[1];
|
||||
assert.match(publication, /ARTIFACT_ID: \$\{\{ steps.upload_selection.outputs.artifact-id \}\}/);
|
||||
const code = publication.split(" node --input-type=module <<'NODE'\n")[1].split("\n NODE")[0]
|
||||
.split("\n").map((line) => line.slice(10)).join("\n");
|
||||
const selected = { ...plan, testSelection: { version: 1, hash: "test" } };
|
||||
const run = (value, artifactId) => {
|
||||
writeFileSync(path.join(directory, ".ci-test-selection/plan.json"), JSON.stringify(value));
|
||||
writeFileSync(output, "");
|
||||
return spawnSync(process.execPath, ["--input-type=module", "-e", code], {
|
||||
cwd: directory, encoding: "utf8", env: { ...process.env, ARTIFACT_ID: artifactId, GITHUB_OUTPUT: output },
|
||||
});
|
||||
};
|
||||
assert.equal(run(selected, "12345").status, 0);
|
||||
const savedPlan = readFileSync(output, "utf8").trim().slice("plan=".length);
|
||||
for (const job of ["general_tests", "verify_serialized_server"]) {
|
||||
const expression = block(job).match(/artifact-ids: \$\{\{ (.*) \}\}/)[1];
|
||||
assert.equal(vm.runInNewContext(expression, { fromJSON: JSON.parse, github: { run_attempt: 2 },
|
||||
needs: { test_selection: { outputs: { plan: savedPlan } } } }), "12345");
|
||||
}
|
||||
assert.notEqual(run(selected, "").status, 0, "a selection cannot be published without its uploaded artifact");
|
||||
assert.equal(run(plan, "").status, 0, "fallback plans need no selection artifact");
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { selectProjectTests } from "./vitest-selection-graph.mjs";
|
||||
import { partitionSelection, refineTestPlan } from "./test-selection-plan.mjs";
|
||||
import { selectionHash, selectedShard, validateSelection } from "./test-selection-manifest.mjs";
|
||||
import { selectionEligibility } from "./select-tests.mjs";
|
||||
import { mergeDiff } from "../../.github/scripts/ci-diff.mjs";
|
||||
|
||||
const root = process.cwd();
|
||||
const head = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
||||
|
||||
test("real graph retains direct and indirect filesystem, process and opaque dependencies", async (t) => {
|
||||
const directory = realpathSync(mkdtempSync(path.join(tmpdir(), "ci-vg-")));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
mkdirSync(path.join(directory, "ui/src"), { recursive: true });
|
||||
mkdirSync(path.join(directory, "ui/src/glob"));
|
||||
symlinkSync(path.join(root, "node_modules"), path.join(directory, "node_modules"), "dir");
|
||||
const write = (file, text) => writeFileSync(path.join(directory, file), text);
|
||||
write("vitest.config.mjs", 'export default {test:{projects:["ui"]}}');
|
||||
write("ui/vitest.config.mjs", `export default {resolve:{alias:{"@leaf":${JSON.stringify(path.join(directory, "ui/src/leaf.ts"))}}},test:{name:"fixture",include:["src/**/*.test.ts"],setupFiles:["./setup.ts"]}}`);
|
||||
write("ui/setup.ts", 'import "./src/environment.ts";');
|
||||
write("ui/src/environment.ts", "export const environment = true;");
|
||||
write("ui/src/leaf.ts", "export const value = 42;");
|
||||
write("ui/src/bridge.ts", 'export {value} from "@leaf";');
|
||||
write("ui/src/helper.ts", 'import {readFileSync} from "node:fs"; export const read = (file: string) => readFileSync(file, "utf8");');
|
||||
write("ui/src/import.test.ts", 'import {value} from "./bridge"; console.log(value); throw new Error("selection must not execute tests");');
|
||||
write("ui/src/reader.test.ts", 'import {readFileSync} from "node:fs"; readFileSync(new URL("./leaf.ts", import.meta.url));');
|
||||
write("ui/src/indirect.test.ts", 'import {read} from "./helper"; read("input"); throw new Error("selection must not execute tests");');
|
||||
write("ui/src/process.test.ts", 'import {execFileSync} from "node:child_process"; execFileSync("must-not-execute");');
|
||||
write("ui/src/dynamic.test.ts", 'const load = (file: string) => import(file);');
|
||||
write("ui/src/cjs.test.ts", 'const load = require;');
|
||||
write("ui/src/builtin-loader.test.ts", 'process.getBuiltinModule("fs").readFileSync("input");');
|
||||
write("ui/src/opaque.test.ts", 'import {parse} from "yaml"; console.log(parse("value: true"));');
|
||||
write("ui/src/independent.test.ts", 'export const independent = true;');
|
||||
write("ui/src/mocked.test.ts", 'import {vi} from "vitest"; vi.mock("./helper",()=>({read:()=>"mock"})); import {read} from "./helper"; read("input");');
|
||||
write("ui/src/partial.test.ts", 'import {vi} from "vitest"; vi.mock("./helper",async (original)=>await original()); import {read} from "./helper"; read("input");');
|
||||
write("ui/src/automock.test.ts", 'import {vi} from "vitest"; vi.mock("./helper"); import {read} from "./helper"; read("input");');
|
||||
write("ui/src/bypass.test.ts", 'import {vi} from "vitest"; vi.mock("./helper",()=>({read:()=>"mock"})); await vi.importActual("./helper");');
|
||||
write("ui/src/spy.test.ts", 'import {vi} from "vitest"; vi.mock("./helper",{spy:true}); import {read} from "./helper"; read("input");');
|
||||
write("ui/src/remock.test.ts", 'import {vi} from "vitest"; vi.mock("./helper",()=>({read:()=>"mock"})); vi.doMock("./helper",async original=>await original());');
|
||||
write("ui/src/arguments.test.ts", 'import {vi} from "vitest"; vi.mock("./helper",function(){return arguments[0]();});');
|
||||
write("ui/src/glob/one.ts", "export const value = 1;");
|
||||
write("ui/src/globber.ts", 'export const modules = import.meta.glob("./glob/*.ts", {eager:true});');
|
||||
write("ui/src/glob.test.ts", 'import {modules} from "./globber"; console.log(modules);');
|
||||
execFileSync("git", ["init", "-q"], { cwd: directory });
|
||||
execFileSync("git", ["add", "."], { cwd: directory });
|
||||
const select = (changed) => selectProjectTests({ root: directory, project: "fixture", changed });
|
||||
const result = await select(["ui/src/leaf.ts"]);
|
||||
assert.deepEqual(result.related, ["ui/src/import.test.ts"]);
|
||||
for (const file of ["reader", "indirect", "process", "dynamic", "cjs", "builtin-loader", "opaque", "partial", "automock", "bypass", "spy", "remock", "arguments"]) {
|
||||
assert.ok(result.alwaysRun.includes(`ui/src/${file}.test.ts`), `${file} must never be dropped`);
|
||||
}
|
||||
assert.ok(!result.selected.includes("ui/src/independent.test.ts"));
|
||||
assert.ok(!result.alwaysRun.includes("ui/src/mocked.test.ts"), "a completely replaced local dependency performs no real IO");
|
||||
assert.ok(!result.alwaysRun.includes("ui/src/glob.test.ts"), "literal Vite globs use the real dependency graph");
|
||||
assert.ok((await select(["ui/src/glob/one.ts"])).related.includes("ui/src/glob.test.ts"));
|
||||
write("ui/src/glob/two.ts", "export const value = 2;");
|
||||
execFileSync("git", ["add", "."], { cwd: directory });
|
||||
assert.ok((await select(["ui/src/glob/two.ts"])).related.includes("ui/src/glob.test.ts"), "new glob members cannot be omitted");
|
||||
const setup = await select(["ui/src/environment.ts"]);
|
||||
assert.equal(setup.setupChanged, true);
|
||||
assert.deepEqual(setup.selected, setup.inventory, "transitive setup edits affect every test");
|
||||
write("ui/src/broken.test.ts", 'import "./missing";');
|
||||
const broken = await select(["ui/src/leaf.ts"]);
|
||||
assert.ok(broken.selected.includes("ui/src/broken.test.ts"), "an unresolved dependency must retain its test");
|
||||
write("ui/src/generated.ts", "export const generated = 42;");
|
||||
write("ui/src/generated.test.ts", 'import {generated} from "./generated"; console.log(generated);');
|
||||
execFileSync("git", ["add", "ui/src/generated.test.ts"], { cwd: directory });
|
||||
assert.ok((await select(["ui/src/leaf.ts"])).alwaysRun.includes("ui/src/generated.test.ts"),
|
||||
"generated inputs have no trustworthy source-to-output graph and retain their consumers");
|
||||
write("ui/src/environment.ts", 'import {readFileSync} from "node:fs"; readFileSync(new URL("./leaf.ts", import.meta.url));');
|
||||
await assert.rejects(select(["ui/src/leaf.ts"]), /Opaque setup builtin/, "setup filesystem reads can affect every test outside the import graph");
|
||||
mkdirSync(path.join(directory, "server/src/__tests__/helpers"), { recursive: true });
|
||||
for (const file of ["server/src/__tests__/setup-supertest.ts", "server/src/__tests__/helpers/isolated-test-home.ts"]) {
|
||||
write(file, readFileSync(path.join(root, file), "utf8"));
|
||||
}
|
||||
write("ui/setup.ts", 'import "../server/src/__tests__/setup-supertest.ts";');
|
||||
const reviewed = await select(["ui/src/leaf.ts"]);
|
||||
assert.ok(!reviewed.selected.includes("ui/src/independent.test.ts"), "reviewed isolated setup permits file selection");
|
||||
const helper = "server/src/__tests__/helpers/isolated-test-home.ts";
|
||||
const changedSetup = await select([helper]);
|
||||
assert.deepEqual(changedSetup.selected, changedSetup.inventory, "reviewed helpers still invalidate all tests when changed");
|
||||
write(helper, `${readFileSync(path.join(directory, helper), "utf8")}\n// setup edited since review\n`);
|
||||
await assert.rejects(select(["ui/src/leaf.ts"]), /Opaque setup/, "a stale review cannot authorize new setup effects");
|
||||
});
|
||||
|
||||
test("partitioning reduces runners and preserves an exact, non-overlapping file cover", () => {
|
||||
const all = Array.from({ length: 120 }, (_, index) => `server/src/${index}.test.ts`);
|
||||
const selected = all.slice(0, 15);
|
||||
const shards = partitionSelection(all, selected, 12);
|
||||
assert.equal(shards.length, 2);
|
||||
assert.deepEqual(shards.flat().sort(), selected.sort());
|
||||
assert.equal(new Set(shards.flat()).size, selected.length);
|
||||
assert.deepEqual(partitionSelection(all, [], 12), []);
|
||||
assert.equal(partitionSelection(all, all, 12).length, 12);
|
||||
const durations = Object.fromEntries(all.map((file, index) => [file, index === 0 ? 1000 : 1]));
|
||||
assert.equal(partitionSelection(all, [all[0]], 12, durations).length, 1);
|
||||
assert.equal(partitionSelection(all, [all[0]], 12, Object.fromEntries(all.map((file) => [file, 0]))).length, 1);
|
||||
});
|
||||
|
||||
function fixturePlan() {
|
||||
const files = ["ui/src/pages/Inbox.test.tsx", "ui/src/pages/apps/chat/chat-ui-contract.test.ts"];
|
||||
const entries = [0, 1].map((shard_index) => ({ group: "general-workspaces-a", group_label: `workspaces-a (${shard_index + 1}/2)`, shard_index, shard_count: 2, task: "ci:workspaces-a" }));
|
||||
const plan = { version: 1, mode: "enabled", forceReason: null, changed: ["ui/src/pages/Inbox.tsx"], jobs: {
|
||||
general_tests: { run: true, matrix: { include: entries }, selected: 2, total: 2 },
|
||||
verify_serialized_server: { run: false, matrix: { include: [{ task: "ci:serialized" }] } },
|
||||
} };
|
||||
const owners = { "general-workspaces-a": { "@paperclipai/ui": files } };
|
||||
const graphs = [{ project: "@paperclipai/ui", inventory: files, selected: [files[1]] }];
|
||||
return { files, ...refineTestPlan({ plan, head, owners, graphs }) };
|
||||
}
|
||||
|
||||
test("runner consumes the exact manifest, and rejects stale, missing, or mismatched manifests", (t) => {
|
||||
const directory = realpathSync(mkdtempSync(path.join(tmpdir(), "ci-vm-")));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const { manifest, plan } = fixturePlan();
|
||||
assert.equal(plan.jobs.general_tests.selected, 1);
|
||||
const file = path.join(directory, "selection.json");
|
||||
const run = (selection = manifest, hash = selectionHash(manifest)) => {
|
||||
writeFileSync(file, JSON.stringify(selection));
|
||||
return spawnSync(process.execPath, ["scripts/run-vitest-stable.mjs", "--mode", "general", "--group", "general-workspaces-a", "--shard-index", "0", "--shard-count", "1", "--dry-run"], {
|
||||
cwd: root, encoding: "utf8", env: { ...process.env, PAPERCLIP_CI_TEST_SELECTION: file, PAPERCLIP_CI_SELECTION_HASH: hash },
|
||||
});
|
||||
};
|
||||
const result = run();
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout).selection, manifest.lanes["general-workspaces-a"].shards[0]);
|
||||
assert.notEqual(run({ ...manifest, head: "0".repeat(40) }).status, 0);
|
||||
assert.notEqual(run(manifest, "bad-hash").status, 0);
|
||||
assert.throws(() => selectedShard(manifest, { mode: "general", group: "general-workspaces-a", shardCount: 2 }, ["@paperclipai/ui"]));
|
||||
assert.throws(() => selectedShard(manifest, { mode: "general", group: "general-workspaces-a", shardCount: 1 }, ["@paperclipai/server"]));
|
||||
const duplicate = structuredClone(manifest);
|
||||
duplicate.lanes["general-workspaces-a"].shards.push(duplicate.lanes["general-workspaces-a"].shards[0]);
|
||||
assert.throws(() => validateSelection(duplicate), /assigned twice/);
|
||||
const wrongLane = { version: 1, head, lanes: { "general-server-without-chat": {
|
||||
shards: [{ "@paperclipai/server": ["server/src/__tests__/issues-service.test.ts"] }],
|
||||
} } };
|
||||
writeFileSync(file, JSON.stringify(wrongLane));
|
||||
const misplaced = spawnSync(process.execPath, ["scripts/run-vitest-stable.mjs", "--mode", "general", "--group", "general-server-without-chat", "--shard-index", "0", "--shard-count", "1", "--dry-run"], {
|
||||
cwd: root, encoding: "utf8", env: { ...process.env, PAPERCLIP_CI_TEST_SELECTION: file, PAPERCLIP_CI_SELECTION_HASH: selectionHash(wrongLane) },
|
||||
});
|
||||
assert.notEqual(misplaced.status, 0);
|
||||
assert.match(misplaced.stderr, /belongs to another server lane/);
|
||||
});
|
||||
|
||||
test("forced modes, config, fixtures, deletions and unknown inputs cannot narrow CI", () => {
|
||||
const plan = fixturePlan().plan;
|
||||
assert.equal(selectionEligibility(plan), null);
|
||||
for (const changed of [["ui/vitest.setup.ts"], ["ui/vite.config.ts"], ["package.json"], ["pnpm-lock.yaml"],
|
||||
["docs/api/issues.md"], ["ui/src/missing-deleted.ts"], ["server/fixtures/input.json"], []]) {
|
||||
assert.ok(selectionEligibility({ ...plan, changed }), JSON.stringify(changed));
|
||||
}
|
||||
for (const mode of ["full", "shadow"]) assert.ok(selectionEligibility({ ...plan, mode }));
|
||||
assert.ok(selectionEligibility({ ...plan, forceReason: "full-ci" }));
|
||||
});
|
||||
|
||||
test("the CLI publishes the complete coarse plan if setup mutates tracked inputs", (t) => {
|
||||
const directory = realpathSync(mkdtempSync(path.join(tmpdir(), "ci-vd-")));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
mkdirSync(path.join(directory, "ui/src"), { recursive: true });
|
||||
const file = path.join(directory, "ui/src/input.ts");
|
||||
const git = (...args) => execFileSync("git", args, { cwd: directory, encoding: "utf8" }).trim();
|
||||
const commit = () => git("-c", "user.name=CI", "-c", "user.email=ci@example.invalid", "commit", "-qam", "fixture");
|
||||
git("init", "-q"); writeFileSync(file, "export const value = 1;"); git("add", "."); commit();
|
||||
const base = git("rev-parse", "HEAD");
|
||||
git("checkout", "-qb", "pr");
|
||||
writeFileSync(file, "export const value = 2;"); commit();
|
||||
const prHead = git("rev-parse", "HEAD");
|
||||
git("checkout", "--detach", base); git("-c", "user.name=CI", "-c", "user.email=ci@example.invalid", "merge", "--no-ff", "pr", "-m", "merge");
|
||||
const merge = git("rev-parse", "HEAD");
|
||||
writeFileSync(file, "export const value = 3; // generated during setup");
|
||||
const plan = { version: 1, mode: "enabled", forceReason: null, ...mergeDiff(merge, prHead, directory),
|
||||
jobs: { general_tests: { run: true, selected: 2, matrix: { include: [{ shard_index: 0 }, { shard_index: 1 }] } } } };
|
||||
const result = spawnSync(process.execPath, [path.join(root, "scripts/ci/select-tests.mjs")], {
|
||||
cwd: directory, encoding: "utf8", env: { ...process.env, CI_PLAN: JSON.stringify(plan), BASE_SHA: base,
|
||||
GITHUB_SHA: merge, GITHUB_OUTPUT: "", GITHUB_STEP_SUMMARY: "" },
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const fallback = JSON.parse(readFileSync(path.join(directory, ".ci-test-selection/plan.json"), "utf8"));
|
||||
assert.match(fallback.testSelectionFallback, /Source inputs changed/);
|
||||
assert.deepEqual(fallback.jobs, plan.jobs);
|
||||
assert.equal(fallback.testSelection, undefined);
|
||||
});
|
||||
|
||||
|
||||
test("mixed audited documentation stays graph eligible and large complete source diffs are allowed", () => {
|
||||
const plan = fixturePlan().plan;
|
||||
for (const doc of ["doc/DEVELOPING.md", "doc/DATABASE.md", "doc/plans/2026-10-30-example.md"]) {
|
||||
assert.equal(selectionEligibility({ ...plan, changed: [...plan.changed, doc] }), null);
|
||||
}
|
||||
assert.equal(selectionEligibility({ ...plan, changed: Array(350).fill(plan.changed[0]) }), null);
|
||||
assert.ok(selectionEligibility({ ...plan, changed: ["doc/DEVELOPING.md"] }));
|
||||
assert.ok(selectionEligibility({ ...plan, changed: [...plan.changed, "docs/api/issues.md"] }));
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { measureVitestImpact } from "./vitest-impact-report.mjs";
|
||||
|
||||
test("real Vitest graph follows aliases/transitive imports but misses filesystem contracts", async (t) => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "vitest-impact-"));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
mkdirSync(path.join(root, "unit"));
|
||||
const write = (file, text) => writeFileSync(path.join(root, file), text);
|
||||
write("vitest.config.mjs", 'export default { test: { projects: ["unit"] } };');
|
||||
write("unit/vitest.config.mjs", `export default {
|
||||
resolve: { alias: { "@leaf": ${JSON.stringify(path.join(root, "unit/leaf.mjs"))} } },
|
||||
test: { name: "fixture", include: ["**/*.test.mjs"] }
|
||||
};`);
|
||||
write("unit/leaf.mjs", "export const value = 42;");
|
||||
write("unit/bridge.mjs", 'export { value } from "@leaf";');
|
||||
// The graph must be collected without executing the test modules.
|
||||
write("unit/related.test.mjs", 'import { value } from "./bridge.mjs"; throw new Error("must not execute");');
|
||||
write("unit/dynamic.test.mjs", 'const load = () => import("./leaf.mjs"); throw new Error("must not execute");');
|
||||
write("unit/independent.test.mjs", 'throw new Error("must not execute");');
|
||||
write("unit/reader.test.mjs", 'import { readFileSync } from "node:fs"; const body = readFileSync(new URL("./leaf.mjs", import.meta.url), "utf8");');
|
||||
|
||||
const report = await measureVitestImpact({ root, projects: ["fixture"], changedFiles: ["unit/leaf.mjs"] });
|
||||
assert.equal(report.mode, "report-only");
|
||||
assert.equal(report.projects[0].totalFiles, 4);
|
||||
assert.deepEqual(report.projects[0].selectedTests, ["unit/dynamic.test.mjs", "unit/related.test.mjs"]);
|
||||
assert.deepEqual(report.projects[0].omittedTests, ["unit/independent.test.mjs", "unit/reader.test.mjs"]);
|
||||
assert.ok(report.limitations.some((message) => message.includes("filesystem")));
|
||||
|
||||
const direct = await measureVitestImpact({ root, projects: ["fixture"], changedFiles: ["unit/independent.test.mjs"] });
|
||||
assert.deepEqual(direct.projects[0].selectedTests, ["unit/independent.test.mjs"]);
|
||||
const unmapped = await measureVitestImpact({ root, projects: ["fixture"], changedFiles: ["unmapped.md"] });
|
||||
assert.equal(unmapped.projects[0].selectedFiles, 0);
|
||||
assert.equal(unmapped.mode, "report-only", "an empty graph is not permission to skip CI");
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { realpathSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
function repoPath(root, file) {
|
||||
const relative = path.relative(root, path.resolve(root, file));
|
||||
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||
throw new Error(`Expected a file inside the checkout: ${file}`);
|
||||
}
|
||||
return relative.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
// This is an experiment report, deliberately not a CI execution plan. Import
|
||||
// graphs cannot describe filesystem reads, subprocesses, or external services.
|
||||
export async function measureVitestImpact({ root = process.cwd(), projects, changedFiles }) {
|
||||
root = realpathSync(root);
|
||||
if (!projects?.length || !changedFiles?.length) {
|
||||
throw new Error("Specify at least one project and one changed file");
|
||||
}
|
||||
const files = [...new Set(changedFiles.map((file) => repoPath(root, file)))].sort();
|
||||
const { createVitest } = await import("vitest/node");
|
||||
const results = [];
|
||||
// Each project gets its own graph to bound memory usage. Cross-package imports
|
||||
// are still followed, but projects omitted by the caller are NOT assessed.
|
||||
for (const project of new Set(projects)) {
|
||||
const started = performance.now();
|
||||
const ctx = await createVitest({
|
||||
root,
|
||||
watch: false,
|
||||
project: [project],
|
||||
exclude: ["**/dist/**"],
|
||||
reporters: [],
|
||||
related: files.map((file) => path.resolve(root, file)),
|
||||
});
|
||||
try {
|
||||
if (ctx.projects.length !== 1 || ctx.projects[0].name !== project) {
|
||||
throw new Error(`Expected exactly one project named ${project}`);
|
||||
}
|
||||
const inventory = await ctx.globTestSpecifications();
|
||||
if (!inventory.length) throw new Error(`No test files found for ${project}`);
|
||||
const ready = performance.now();
|
||||
const selected = await ctx.getRelevantTestSpecifications();
|
||||
const finished = performance.now();
|
||||
const selectedTests = selected.map((spec) => repoPath(root, spec.moduleId)).sort();
|
||||
const selectedSet = new Set(selectedTests);
|
||||
results.push({
|
||||
project,
|
||||
totalFiles: inventory.length,
|
||||
selectedFiles: selectedTests.length,
|
||||
startupSeconds: (ready - started) / 1000,
|
||||
selectionSeconds: (finished - ready) / 1000,
|
||||
selectedTests,
|
||||
omittedTests: inventory.map((spec) => repoPath(root, spec.moduleId))
|
||||
.filter((file) => !selectedSet.has(file)).sort(),
|
||||
});
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: "report-only",
|
||||
changedFiles: files,
|
||||
limitations: [
|
||||
"Only the explicitly requested projects were assessed.",
|
||||
"Selection follows imports, not filesystem reads, subprocesses, or external services.",
|
||||
"Zero selected files is not proof that a change needs no tests.",
|
||||
"This report must not be used to omit required CI jobs.",
|
||||
],
|
||||
projects: results,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { values } = parseArgs({ options: {
|
||||
project: { type: "string", multiple: true },
|
||||
file: { type: "string", multiple: true },
|
||||
base: { type: "string" },
|
||||
output: { type: "string" },
|
||||
} });
|
||||
if (!values.project?.length || (!!values.base === !!values.file?.length)) {
|
||||
throw new Error("Usage: node scripts/ci/vitest-impact-report.mjs --project NAME [--project NAME] (--file PATH [--file PATH] | --base REF) [--output FILE]");
|
||||
}
|
||||
const git = (...args) => execFileSync("git", args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
||||
const head = git("rev-parse", "--verify", "HEAD^{commit}").trim();
|
||||
let comparison = { kind: "hypothetical", head };
|
||||
let changedFiles = values.file;
|
||||
if (values.base) {
|
||||
// Vitest reads the working tree, so do not label dirty sources as HEAD.
|
||||
if (git("status", "--porcelain", "--untracked-files=no").trim()) {
|
||||
throw new Error("--base requires a clean tracked working tree; use --file for a hypothetical change");
|
||||
}
|
||||
const base = git("rev-parse", "--verify", "--end-of-options", `${values.base}^{commit}`).trim();
|
||||
// Compare the actual trees, including both sides of a rename. The caller
|
||||
// supplies the event base; HEAD should be the tested PR merge commit in CI.
|
||||
changedFiles = git("diff", "--name-only", "--no-renames", "-z", base, head, "--")
|
||||
.split("\0").filter(Boolean);
|
||||
comparison = { kind: "git-diff", base, head };
|
||||
}
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.VITEST = "true";
|
||||
// Config/plugins are executable code. Run this only in a test job, never in
|
||||
// the trusted metadata-only CI routing gate. Bound a failed graph traversal.
|
||||
const timeout = setTimeout(() => {
|
||||
console.error("Vitest impact analysis exceeded five minutes; report unavailable");
|
||||
process.exit(1);
|
||||
}, 300_000);
|
||||
try {
|
||||
const report = await measureVitestImpact({ projects: values.project, changedFiles });
|
||||
const json = `${JSON.stringify({ ...report, comparison }, null, 2)}\n`;
|
||||
if (values.output) writeFileSync(values.output, json);
|
||||
else process.stdout.write(json);
|
||||
for (const result of report.projects) {
|
||||
console.error(`${result.project}: ${result.selectedFiles}/${result.totalFiles} files; `
|
||||
+ `${result.selectionSeconds.toFixed(2)}s graph + ${result.startupSeconds.toFixed(2)}s startup`);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
# Vitest dependency selection experiment
|
||||
|
||||
The production selector is documented in `doc/DEVELOPING.md`. This probe remains
|
||||
a diagnostic for the raw import graph, without the production retention rules.
|
||||
|
||||
This probe measures Vitest's real import graph against the current checkout.
|
||||
It does not change the CI matrix or authorize skipping tests. It loads Vitest
|
||||
configurations and Vite plugins, so run it in a normal test environment, never
|
||||
in the trusted metadata-only routing job.
|
||||
|
||||
Install the workspace dependencies and prepare the same shared build outputs
|
||||
used by CI:
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
node scripts/ensure-plugin-build-deps.mjs
|
||||
node --test scripts/ci/vitest-impact-report.integration.test.mjs
|
||||
```
|
||||
|
||||
Probe a hypothetical change (the file need not actually be modified):
|
||||
|
||||
```sh
|
||||
node scripts/ci/vitest-impact-report.mjs \
|
||||
--project @paperclipai/ui --file ui/src/pages/Inbox.tsx \
|
||||
--output /tmp/inbox-impact.json
|
||||
```
|
||||
|
||||
Or compare a base revision with the checked-out HEAD. This requires a clean
|
||||
tracked working tree. For a PR, check out the tested merge commit and supply
|
||||
the event's base SHA, as the existing CI planner does:
|
||||
|
||||
```sh
|
||||
node scripts/ci/vitest-impact-report.mjs \
|
||||
--project @paperclipai/ui --project @paperclipai/server \
|
||||
--base BASE_SHA --output /tmp/pr-impact.json
|
||||
```
|
||||
|
||||
Every requested project is analyzed separately to bound memory usage. Imports
|
||||
across package boundaries are followed within each graph, but unrequested
|
||||
projects are **not assessed**. Runner's separate Vitest configurations, browser
|
||||
E2E, Rust, typechecking, and builds are outside this experiment. The JSON records
|
||||
selected and omitted test files, project inventory, startup and graph times,
|
||||
and the comparison revisions. A failed or timed-out probe is not an empty plan.
|
||||
|
||||
## Initial observations
|
||||
|
||||
Measured on `5c807d11fa` with Vitest 5.0.3, macOS, and Node 26.4.0. These are
|
||||
hypothetical single-file changes against that revision, not replays of historical
|
||||
PR checkouts. CI uses Node 24 and different hardware.
|
||||
|
||||
| Changed file | Assessed project | Selected / total test files |
|
||||
| --- | --- | ---: |
|
||||
| `ui/src/pages/Inbox.tsx` | UI | 6 / 844 |
|
||||
| `ui/src/components/MarkdownBody.tsx` | UI | 135 / 844 |
|
||||
| `server/src/middleware/auth.ts` | Server | 45 / 1,428 |
|
||||
| `server/src/services/run-failure-report.ts` | Server | 787 / 1,428 |
|
||||
| `packages/shared/src/issue-references.ts` | Shared only | 5 / 96 |
|
||||
| `docs/api/issues.md` | Server | 0 / 1,428 — known false negative |
|
||||
|
||||
Cold graph construction took 27.43 seconds for UI, 25.68 seconds for server,
|
||||
and 2.79 seconds for shared. Subsequent queries on the same graph took about
|
||||
0.05 seconds; fresh CI jobs must budget for cold construction. The six Inbox
|
||||
files ran 81 assertions successfully in 20.92 seconds with four workers.
|
||||
These figures exclude dependency installation and shared package builds.
|
||||
|
||||
For a local execution comparison, the complete UI project passed all 10,043
|
||||
assertions in 844 files in 361.50 seconds with the same four-worker setting.
|
||||
The initial Inbox graph plus selected execution took about 48 seconds, versus
|
||||
six minutes for the full project. This is a local experiment, not a measured
|
||||
change in GitHub CI latency. Later runs benefit from filesystem/transform caches.
|
||||
|
||||
The CLI's Git-diff path was also exercised against the actual `HEAD^..HEAD`
|
||||
change for PR #18321. Across its eleven changed files, the server graph selected
|
||||
1,138 / 1,428 files and the shared graph selected 3 / 96. Other projects were
|
||||
not assessed. Broad shared/service imports can therefore preserve most of a
|
||||
suite; the Inbox reduction should not be generalized to every PR.
|
||||
|
||||
## Why the raw graph is not yet a safe execution plan
|
||||
|
||||
There are concrete missing edges in this repository:
|
||||
|
||||
- `ui/src/pages/apps/chat/chat-ui-contract.test.ts` reads `Inbox.tsx` through
|
||||
`readFileSync`. It is absent from the six-file selection even though changes
|
||||
to Inbox can break its assertions.
|
||||
- `server/src/__tests__/paperclip-skill-utils.test.ts` reads `docs/api/issues.md`
|
||||
through `fs.readFile`. Changing that document selects zero server tests.
|
||||
|
||||
A deliberate negative control confirmed the first missing edge: temporarily
|
||||
append a comment containing `reason: "retry_failed_run"` to `Inbox.tsx`, which
|
||||
violates the contract test's source assertion without changing runtime behavior.
|
||||
The fresh graph still selected six files. Running those six plus the omitted
|
||||
contract produced six passing files and exactly one failing assertion, in
|
||||
`chat-ui-contract.test.ts` (92 assertions passed, one failed). The original source
|
||||
was restored immediately afterward. The full baseline had passed this contract.
|
||||
|
||||
The integration test uses actual Vitest graph traversal to verify transitive
|
||||
imports, aliases, literal dynamic imports, direct test edits, and the missing
|
||||
filesystem edge. Test modules deliberately throw if executed: graph discovery
|
||||
must not execute the tests. Passing this test documents the limitation; it does
|
||||
not prove that skipped repository tests are irrelevant.
|
||||
|
||||
A production rollout needs explicit dependencies or an always-run lane for
|
||||
filesystem/subprocess contract tests; broad fallbacks for configuration, setup,
|
||||
lockfiles, deleted files, unknown inputs, and selector failures; and comparison
|
||||
against full-suite failures. Source-only changes also need those protections:
|
||||
the Inbox example is not a documentation-only edge case. Once qualified, compute
|
||||
the selected test list before constructing the matrix and partition that list,
|
||||
preserving the existing serialized, chat, and native-runner ownership rules.
|
||||
|
||||
Verification for this experiment covers the real-graph integration test, CLI
|
||||
probes, the complete UI project, and the deliberate negative control. Repository
|
||||
wide typechecking, build, server execution, and the complete `test:run` suite
|
||||
were not run; this is a report-only experiment, not a production CI rollout.
|
||||
@@ -0,0 +1,264 @@
|
||||
import { readFileSync, realpathSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { isBuiltin } from "node:module";
|
||||
import { parseSync } from "oxc-parser";
|
||||
import { createVitest } from "vitest/node";
|
||||
|
||||
// Everything else is opaque by default. Do not add IO clients, module loaders,
|
||||
// filesystem helpers or process launchers here to improve selection counts.
|
||||
const purePackages = new Set([
|
||||
"vitest", "@vitest/expect", "@testing-library/react", "@testing-library/dom",
|
||||
"@testing-library/user-event", "@testing-library/jest-dom", "react", "react-dom",
|
||||
"react-router", "react-router-dom", "@tanstack/react-query", "@tanstack/query-core",
|
||||
"zod", "lodash", "lodash-es", "date-fns", "clsx", "tailwind-merge", "class-variance-authority",
|
||||
"lucide-react", "immer", "nanoid", "uuid", "semver",
|
||||
"radix-ui", "motion", "@dnd-kit/core", "@dnd-kit/sortable", "@dnd-kit/utilities",
|
||||
"i18next", "react-i18next",
|
||||
]);
|
||||
const pureBuiltins = new Set(["assert", "assert/strict", "path", "path/posix", "path/win32",
|
||||
"url", "util", "util/types", "buffer", "events", "string_decoder", "crypto"]);
|
||||
// Reviewed setup performs only temporary-home IO and Supertest socket setup,
|
||||
// not reads of arbitrary repository inputs. Pin contents, not just paths: any
|
||||
// edit disables this exception until its effects are reviewed again. Continue
|
||||
// following local imports even for these files; helper changes affect all tests.
|
||||
const reviewedSetup = new Map([
|
||||
["server/src/__tests__/setup-supertest.ts", {
|
||||
hash: "99d7193927468e98b41255ea9a5e37b1a681f02b259c304f3af1a52304eed2bb",
|
||||
external: ["supertest/lib/test.js"],
|
||||
}],
|
||||
["server/src/__tests__/helpers/isolated-test-home.ts", {
|
||||
hash: "bd5645dc590087f26b473beab06d5c3ddd7956a79949f0b3e9713f11897a0c1b",
|
||||
external: [],
|
||||
}],
|
||||
]);
|
||||
const packageName = (specifier) => specifier.startsWith("@")
|
||||
? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0];
|
||||
const inside = (root, file) => file.startsWith(`${root}${path.sep}`) && !file.includes(`${path.sep}node_modules${path.sep}`);
|
||||
|
||||
// Parse source, including imports erased by mocks/transforms. Unknown loaders
|
||||
// retain their consumers. This is intentionally conservative, not a regex-based
|
||||
// promise that arbitrary JavaScript has no effects.
|
||||
export function sourceDependencies(file, source) {
|
||||
const ast = parseSync(file, source);
|
||||
const imports = new Set();
|
||||
const reasons = new Set();
|
||||
const mockBindings = new Set(ast.program.body.filter((node) => node.type === "ImportDeclaration" && node.source.value === "vitest")
|
||||
.flatMap((node) => node.specifiers.filter((s) => s.type === "ImportSpecifier" && ["vi", "vitest"].includes(s.imported.name)).map((s) => s.local.name)));
|
||||
const hoistedBindings = new Set(ast.program.body.filter((node) => node.type === "ImportDeclaration" && node.source.value === "vitest")
|
||||
.flatMap((node) => node.specifiers.filter((s) => s.type === "ImportSpecifier" && ["vi", "vitest"].includes(s.imported.name)
|
||||
&& s.local.name === s.imported.name).map((s) => s.local.name)));
|
||||
const mocks = [];
|
||||
if (ast.errors.length) reasons.add("unparsed source");
|
||||
const literal = (node) => node?.type === "Literal" && typeof node.value === "string";
|
||||
const importSource = (node) => {
|
||||
if (literal(node)) imports.add(node.value);
|
||||
else reasons.add("dynamic module name");
|
||||
};
|
||||
for (const statement of ast.program.body) {
|
||||
const call = statement.type === "ExpressionStatement" ? statement.expression : null;
|
||||
const callee = call?.type === "CallExpression" ? call.callee : null;
|
||||
const factory = call?.arguments?.[1];
|
||||
if (callee?.type === "MemberExpression" && !callee.computed && callee.property.name === "mock"
|
||||
&& hoistedBindings.has(callee.object.name) && literal(call.arguments[0])
|
||||
&& ["ArrowFunctionExpression", "FunctionExpression"].includes(factory?.type) && factory.params.length === 0) {
|
||||
mocks.push(call.arguments[0].value);
|
||||
}
|
||||
}
|
||||
function visit(node, parent = null) {
|
||||
if (!node || typeof node !== "object") return;
|
||||
if ((node.type === "ImportDeclaration" && node.importKind !== "type")
|
||||
|| (["ExportNamedDeclaration", "ExportAllDeclaration"].includes(node.type) && node.exportKind !== "type")) {
|
||||
if (node.source) importSource(node.source);
|
||||
}
|
||||
if (node.type === "ImportExpression") importSource(node.source);
|
||||
if (node.type === "TSImportEqualsDeclaration") reasons.add("CommonJS import");
|
||||
if (node.type === "Identifier" && /^(require|fetch|eval|Function|WebSocket|XMLHttpRequest|Worker)$/.test(node.name)) {
|
||||
reasons.add("external input or dynamic code");
|
||||
}
|
||||
if ((node.type === "Identifier" && node.name === "getBuiltinModule")
|
||||
|| (literal(node) && node.value === "getBuiltinModule")) reasons.add("dynamic builtin loader");
|
||||
if ((node.type === "Identifier" && /^(importActual|unmock|doUnmock|doMock|arguments)$/.test(node.name))
|
||||
|| (literal(node) && /^(importActual|unmock|doUnmock|doMock)$/.test(node.value))) reasons.add("mock bypass");
|
||||
if (node.type === "Identifier" && mockBindings.has(node.name) && parent
|
||||
&& parent.type !== "ImportSpecifier"
|
||||
&& !(parent.type === "MemberExpression" && parent.object === node && !parent.computed)) {
|
||||
reasons.add("escaped or dynamic mocking API");
|
||||
}
|
||||
if (node.type === "CallExpression") {
|
||||
const name = source.slice(node.callee.start, node.callee.end);
|
||||
if (/^(?:vi|vitest)\.(?:mock|doMock|importActual|importMock)$/.test(name)) {
|
||||
importSource(node.arguments[0]);
|
||||
}
|
||||
if (/\brequire\b/.test(name)) {
|
||||
reasons.add("CommonJS loader");
|
||||
importSource(node.arguments[0]);
|
||||
}
|
||||
const viteGlob = name === "import.meta.glob" && (literal(node.arguments[0])
|
||||
|| (node.arguments[0]?.type === "ArrayExpression" && node.arguments[0].elements.every(literal)));
|
||||
// Vite expands literal globs into dependency edges. New files appear in
|
||||
// that expansion; deleted/renamed and non-source inputs force full lanes.
|
||||
if (!viteGlob && /\b(?:fetch|eval|Function|glob|globSync)\b/.test(name)) reasons.add("external input or dynamic code");
|
||||
}
|
||||
for (const value of Object.values(node)) {
|
||||
if (Array.isArray(value)) value.forEach((child) => visit(child, node));
|
||||
else if (value && typeof value === "object" && value.type) visit(value, node);
|
||||
}
|
||||
}
|
||||
visit(ast.program);
|
||||
return { imports: [...imports], reasons: [...reasons], mocks };
|
||||
}
|
||||
|
||||
export async function selectProjectTests({ root = process.cwd(), project, changed, onlyOwnChanges = false }) {
|
||||
root = realpathSync(root);
|
||||
const started = performance.now();
|
||||
const absoluteChanges = changed.map((file) => path.resolve(root, file));
|
||||
const ctx = await createVitest({ root, watch: false, project: [project],
|
||||
exclude: ["**/dist/**"], reporters: [], related: absoluteChanges });
|
||||
try {
|
||||
if (ctx.projects.length !== 1 || ctx.projects[0].name !== project) throw new Error(`Unknown project: ${project}`);
|
||||
const owner = ctx.projects[0];
|
||||
const environment = owner.vite.environments.ssr;
|
||||
const inventory = await ctx.globTestSpecifications();
|
||||
if (!inventory.length) throw new Error(`Empty inventory: ${project}`);
|
||||
const relative = (spec) => path.relative(root, spec.moduleId).split(path.sep).join("/");
|
||||
if (onlyOwnChanges && !absoluteChanges.some((file) => file.startsWith(`${owner.config.root}${path.sep}`))) {
|
||||
// Keep coarse dependency coverage, but avoid spending a graph traversal
|
||||
// on an unchanged consumer project merely to retain most of its IO tests.
|
||||
const files = inventory.map(relative).sort();
|
||||
return { project, inventory: files, selected: files, related: [], alwaysRun: files, coveredChanges: [],
|
||||
opaqueModules: {}, retainedReason: "Unchanged project retains complete lane coverage", seconds: (performance.now() - started) / 1000 };
|
||||
}
|
||||
const tracked = new Set(execFileSync("git", ["ls-files", "--cached", "-z"], {
|
||||
cwd: root, encoding: "utf8", maxBuffer: 16 * 1024 * 1024,
|
||||
}).split("\0").filter(Boolean));
|
||||
const audit = new Map();
|
||||
const parsed = new Map();
|
||||
const parse = (file) => {
|
||||
if (!parsed.has(file)) parsed.set(file, sourceDependencies(file, readFileSync(file, "utf8")));
|
||||
return parsed.get(file);
|
||||
};
|
||||
const resolve = async (specifier, importer) => {
|
||||
try {
|
||||
const resolved = await environment.pluginContainer.resolveId(specifier, importer);
|
||||
if (!resolved || resolved.external || !path.isAbsolute(resolved.id) || resolved.id.includes("?")) return null;
|
||||
return realpathSync(resolved.id);
|
||||
} catch { return null; } // A conditional/require-only export is opaque.
|
||||
};
|
||||
// Setup dependencies are not test-import edges. A change anywhere in their
|
||||
// local import closure invalidates the entire project, including helpers.
|
||||
const setup = new Set();
|
||||
async function setupClosure(file) {
|
||||
if (setup.has(file)) return;
|
||||
setup.add(file);
|
||||
const source = parse(file);
|
||||
const review = reviewedSetup.get(path.relative(root, file).split(path.sep).join("/"));
|
||||
const reviewed = review?.hash === createHash("sha256").update(readFileSync(file)).digest("hex");
|
||||
if (!reviewed && source.reasons.length) throw new Error(`Opaque setup dependency: ${file}`);
|
||||
for (const specifier of source.imports) {
|
||||
if (isBuiltin(specifier)) {
|
||||
if (!reviewed && !pureBuiltins.has(specifier.replace(/^node:/, ""))) throw new Error(`Opaque setup builtin: ${specifier}`);
|
||||
continue;
|
||||
}
|
||||
if (reviewed && review.external.includes(specifier)) continue;
|
||||
const dependency = await resolve(specifier, file);
|
||||
if (dependency && inside(root, dependency)) await setupClosure(dependency);
|
||||
else if (!dependency) throw new Error(`Unresolved setup dependency: ${specifier}`);
|
||||
else if (!purePackages.has(packageName(specifier))) throw new Error(`Opaque setup package: ${specifier}`);
|
||||
}
|
||||
}
|
||||
for (const file of [...(owner.config.setupFiles ?? []), ...(owner.config.globalSetup ?? []),
|
||||
...(owner.config.snapshotSerializers ?? [])]) {
|
||||
const resolved = path.isAbsolute(file) ? file : await resolve(file, owner.vite.config.configFile);
|
||||
if (resolved && inside(root, resolved)) await setupClosure(resolved);
|
||||
else if (!resolved) throw new Error(`Unresolved setup module: ${file}`);
|
||||
}
|
||||
for (const file of owner.vite.config.configFileDependencies ?? []) setup.add(realpathSync(file));
|
||||
const setupChanged = absoluteChanges.some((file) => setup.has(file));
|
||||
const raw = await ctx.getRelevantTestSpecifications();
|
||||
const modules = environment.moduleGraph.idToModuleMap;
|
||||
const resolvedDependencies = new Map();
|
||||
const localImports = new Map();
|
||||
const dependencyPath = (dep) => {
|
||||
if (!resolvedDependencies.has(dep)) {
|
||||
const file = dep.startsWith("/@fs/") ? dep.slice(process.platform === "win32" ? 5 : 4)
|
||||
: path.join(owner.config.root, dep);
|
||||
try { resolvedDependencies.set(dep, realpathSync(file)); }
|
||||
catch { resolvedDependencies.set(dep, null); }
|
||||
}
|
||||
return resolvedDependencies.get(dep);
|
||||
};
|
||||
for (const [id, module] of modules) {
|
||||
if (!inside(root, id) || !/\.[cm]?[jt]sx?$/.test(id)) continue;
|
||||
const source = parse(id);
|
||||
const reasons = [...source.reasons];
|
||||
if (!tracked.has(path.relative(root, id).split(path.sep).join("/"))) reasons.push("generated or untracked source");
|
||||
const local = [];
|
||||
localImports.set(id, local);
|
||||
// Use the same resolved runtime edges as Vitest, including literal dynamic
|
||||
// imports. Resolving the original syntax again uses the wrong conditions
|
||||
// for require-only exports and invents edges for erased type imports.
|
||||
const transformed = module.transformResult;
|
||||
if (!Array.isArray(transformed?.deps)) reasons.push("unknown transform shape");
|
||||
for (const dependencyId of [...(transformed?.deps ?? []), ...(transformed?.dynamicDeps ?? [])]) {
|
||||
if (isBuiltin(dependencyId)) {
|
||||
if (!pureBuiltins.has(dependencyId.replace(/^node:/, ""))) reasons.push(`builtin ${dependencyId}`);
|
||||
continue;
|
||||
}
|
||||
const dependency = dependencyPath(dependencyId);
|
||||
if (dependency && inside(root, dependency)) {
|
||||
local.push(dependency);
|
||||
// Vitest intentionally skips some edges (e.g. CJS, externalized
|
||||
// workspaces). Never regard an untracked local edge as independent.
|
||||
if (!modules.get(dependency)?.transformResult) reasons.push(`untracked dependency ${path.relative(root, dependency)}`);
|
||||
} else {
|
||||
const external = dependency?.split(`${path.sep}node_modules${path.sep}`).at(-1).split(path.sep).join("/");
|
||||
if (!dependency || !dependency.includes(`${path.sep}node_modules${path.sep}`) || !purePackages.has(packageName(external))) {
|
||||
reasons.push(`opaque dependency ${external ? packageName(external) : dependencyId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!module.transformResult) reasons.push("untransformed module");
|
||||
if (reasons.length) audit.set(id, [...new Set(reasons)]);
|
||||
}
|
||||
const retained = [];
|
||||
for (const spec of inventory) {
|
||||
const mocked = new Set();
|
||||
// Honor only unconditional, explicitly supplied mock factories without an
|
||||
// importOriginal parameter. Automocks, spies, unmock/importActual, dynamic
|
||||
// mocking and escaped vi bindings stay conservative. The related-file
|
||||
// graph remains uncut: edits to a mocked module can still select its tests.
|
||||
if (owner.config.isolate !== false) {
|
||||
for (const name of parse(spec.moduleId).mocks) {
|
||||
const target = await resolve(name, spec.moduleId);
|
||||
if (target && inside(root, target)) mocked.add(target);
|
||||
}
|
||||
}
|
||||
const queue = [spec.moduleId], visited = new Set();
|
||||
let opaque = setupChanged || owner.config.isolate === false || !localImports.has(spec.moduleId);
|
||||
while (queue.length && !opaque) {
|
||||
const file = queue.pop();
|
||||
if (visited.has(file)) continue;
|
||||
visited.add(file);
|
||||
if (audit.has(file)) { opaque = true; break; }
|
||||
for (const dependency of localImports.get(file) ?? []) if (!mocked.has(dependency)) queue.push(dependency);
|
||||
}
|
||||
if (opaque) retained.push(spec);
|
||||
}
|
||||
const selected = [...new Set([...raw, ...retained].map(relative))].sort();
|
||||
return {
|
||||
project,
|
||||
inventory: inventory.map(relative).sort(),
|
||||
selected,
|
||||
related: raw.map(relative).sort(),
|
||||
alwaysRun: retained.map(relative).sort(),
|
||||
coveredChanges: changed.filter((file, index) => modules.has(absoluteChanges[index]) || setup.has(absoluteChanges[index])),
|
||||
opaqueModules: Object.fromEntries([...audit].map(([file, reasons]) => [path.relative(root, file), reasons])),
|
||||
setupChanged,
|
||||
seconds: (performance.now() - started) / 1000,
|
||||
};
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { classifyRecoveryEntry, cutoverStatus, recoveryKinds } from "./recovery-cutover-policy.mjs";
|
||||
|
||||
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
// Inspect every ancestor: lstat on only the final component would follow an
|
||||
// intermediate symlink. No directory/file names or OS errors enter the report.
|
||||
async function directoryState(directory, io) {
|
||||
const parsed = path.parse(directory);
|
||||
let current = parsed.root;
|
||||
for (const part of directory.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
|
||||
current = path.join(current, part);
|
||||
try {
|
||||
const stat = await io.lstat(current);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) return "unsafe";
|
||||
} catch (error) {
|
||||
return error?.code === "ENOENT" ? "absent" : "unreadable";
|
||||
}
|
||||
}
|
||||
return "directory";
|
||||
}
|
||||
|
||||
/** Read-only, bounded entry inventory. Run with producers stopped on the retained
|
||||
* volume. This is not an atomic snapshot and does not establish DB settlement.
|
||||
* io exists for deterministic fault tests; production uses the actual filesystem. */
|
||||
export async function inventoryRecoveryFiles({ instanceRoot, orphanDirectory, maxEntries = 10_000 }, io = fs) {
|
||||
if (!path.isAbsolute(instanceRoot) || (orphanDirectory !== undefined && !path.isAbsolute(orphanDirectory))) {
|
||||
throw new Error("Recovery inventory requires absolute directories");
|
||||
}
|
||||
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1 || maxEntries > 100_000) {
|
||||
throw new Error("Entry limit must be between 1 and 100000");
|
||||
}
|
||||
const root = path.resolve(instanceRoot);
|
||||
const rootState = await directoryState(root, io);
|
||||
const directories = [path.join(root, "accounting-receipts"), path.join(root, "quota-credential-recovery"),
|
||||
orphanDirectory === undefined ? path.join(root, "data", "sandbox-orphan-cleanup") : path.resolve(orphanDirectory)];
|
||||
let remaining = maxEntries;
|
||||
const summaries = [];
|
||||
for (const [index, directory] of directories.entries()) {
|
||||
const summary = { kind: recoveryKinds[index], presence: "unknown", observedEntries: 0,
|
||||
unverifiedFiles: 0, unknownEntries: 0, readFailures: 0, complete: true, status: "unknown" };
|
||||
if (rootState !== "directory") {
|
||||
summary.complete = false;
|
||||
} else {
|
||||
const state = await directoryState(directory, io);
|
||||
if (state === "absent") summary.presence = "absent";
|
||||
else if (state !== "directory") {
|
||||
summary.unknownEntries++;
|
||||
summary.complete = false;
|
||||
if (state === "unreadable") summary.readFailures++;
|
||||
} else {
|
||||
summary.presence = "present";
|
||||
async function scan(target, depth) {
|
||||
let handle;
|
||||
try {
|
||||
handle = await io.opendir(target);
|
||||
while (true) {
|
||||
// Read one sentinel entry to distinguish exactly-at-limit from
|
||||
// truncated scans. The sentinel is not included in observed counts.
|
||||
const entry = await handle.read();
|
||||
if (entry === null) break;
|
||||
if (remaining === 0) { summary.complete = false; break; }
|
||||
remaining--;
|
||||
summary.observedEntries++;
|
||||
const entryKind = entry.isFile() ? "file"
|
||||
: entry.isDirectory() && uuid.test(entry.name) ? "company-directory" : "other";
|
||||
const classification = classifyRecoveryEntry(summary.kind, depth, entryKind);
|
||||
if (classification === "descend") {
|
||||
const child = path.join(target, entry.name);
|
||||
// Recheck the entry before traversing; never intentionally follow
|
||||
// links. Operators must quiesce writers for the snapshot to hold.
|
||||
if (await directoryState(child, io) !== "directory") {
|
||||
summary.unknownEntries++;
|
||||
summary.complete = false;
|
||||
} else await scan(child, depth + 1);
|
||||
} else summary[classification]++;
|
||||
if (!summary.complete && remaining === 0) break;
|
||||
}
|
||||
} catch {
|
||||
summary.readFailures++;
|
||||
summary.complete = false;
|
||||
} finally {
|
||||
if (handle) {
|
||||
try { await handle.close(); }
|
||||
catch { summary.readFailures++; summary.complete = false; }
|
||||
}
|
||||
}
|
||||
}
|
||||
await scan(directory, 0);
|
||||
}
|
||||
}
|
||||
summary.status = cutoverStatus(summary);
|
||||
summaries.push(summary);
|
||||
}
|
||||
return { schema: "paperclip/recovery-cutover-inventory/v1", status:
|
||||
summaries.some(s => s.status === "unknown") ? "unknown" : summaries.some(s => s.status === "blocked") ? "blocked" : "clear",
|
||||
instanceStatus: rootState, contentInspection: "none", counts: "observed-lower-bounds", maxEntries, directories: summaries };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** File contents belong to each recovery owner. Inventory never parses them:
|
||||
* even an apparently valid file cannot establish that its obligation settled. */
|
||||
export const recoveryKinds = ["accounting-receipts", "quota-credential-recovery", "sandbox-orphan-cleanup"];
|
||||
|
||||
export function classifyRecoveryEntry(kind, depth, entry) {
|
||||
if (entry === "file") return "unverifiedFiles";
|
||||
if (entry === "company-directory" && kind === "quota-credential-recovery" && depth === 0) return "descend";
|
||||
return "unknownEntries";
|
||||
}
|
||||
|
||||
export function cutoverStatus(summary) {
|
||||
if (!summary.complete || summary.unknownEntries > 0 || summary.readFailures > 0) return "unknown";
|
||||
return summary.unverifiedFiles > 0 ? "blocked" : "clear";
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
import { inventoryRecoveryFiles } from "./lib/recovery-cutover-inventory.mjs";
|
||||
|
||||
const usage = "Usage: node scripts/recovery-cutover.mjs --instance-root /absolute/instance [--orphan-directory /absolute/override] [--max-entries 10000] [--check]";
|
||||
const args = process.argv.slice(2);
|
||||
try {
|
||||
let instanceRoot;
|
||||
let orphanDirectory = process.env.SANDBOX_ORPHAN_CLEANUP_SPOOL_DIR;
|
||||
let maxEntries = 10_000;
|
||||
let check = false;
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const argument = args[i];
|
||||
if (seen.has(argument)) throw new Error("duplicate");
|
||||
seen.add(argument);
|
||||
if (argument === "--check") check = true;
|
||||
else if (["--instance-root", "--orphan-directory", "--max-entries"].includes(argument)) {
|
||||
const value = args[++i];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error("missing");
|
||||
if (argument === "--instance-root") instanceRoot = value;
|
||||
else if (argument === "--orphan-directory") orphanDirectory = value;
|
||||
else maxEntries = Number(value);
|
||||
} else throw new Error("unknown");
|
||||
}
|
||||
if (!instanceRoot) throw new Error("missing");
|
||||
const report = await inventoryRecoveryFiles({ instanceRoot, orphanDirectory, maxEntries });
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
if (check && report.status !== "clear") process.exitCode = 1;
|
||||
} catch {
|
||||
// Never echo args, filesystem errors or paths: they may contain secrets.
|
||||
process.stderr.write(`Recovery inventory could not run. ${usage}\n`);
|
||||
process.exitCode = 2;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { afterEach, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { inventoryRecoveryFiles } from "./lib/recovery-cutover-inventory.mjs";
|
||||
import { classifyRecoveryEntry, cutoverStatus } from "./lib/recovery-cutover-policy.mjs";
|
||||
|
||||
const cli = fileURLToPath(new URL("./recovery-cutover.mjs", import.meta.url));
|
||||
const roots = [];
|
||||
const company = "00000000-0000-4000-8000-000000000001";
|
||||
const grant = "00000000-0000-4000-8000-000000000002";
|
||||
async function fixture() {
|
||||
// Canonicalize macOS /var -> /private/var, since the check rejects links.
|
||||
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "cutover-")));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
async function write(root, relative, content = "secret-token-must-never-appear") {
|
||||
const file = path.join(root, relative);
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(file, content);
|
||||
return file;
|
||||
}
|
||||
function run(root, args = [], env = {}) {
|
||||
return spawnSync(process.execPath, [cli, "--instance-root", root, ...args], {
|
||||
encoding: "utf8", env: { ...process.env, SANDBOX_ORPHAN_CLEANUP_SPOOL_DIR: "", ...env },
|
||||
});
|
||||
}
|
||||
// An unset override differs from an invalid empty override.
|
||||
function cleanRun(root, args = []) {
|
||||
const env = { ...process.env };
|
||||
delete env.SANDBOX_ORPHAN_CLEANUP_SPOOL_DIR;
|
||||
return spawnSync(process.execPath, [cli, "--instance-root", root, ...args], { encoding: "utf8", env });
|
||||
}
|
||||
afterEach(async () => { await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); });
|
||||
|
||||
test("pure policy never clears files or unknown entries", () => {
|
||||
assert.equal(classifyRecoveryEntry("accounting-receipts", 0, "file"), "unverifiedFiles");
|
||||
assert.equal(classifyRecoveryEntry("quota-credential-recovery", 0, "company-directory"), "descend");
|
||||
assert.equal(classifyRecoveryEntry("quota-credential-recovery", 1, "company-directory"), "unknownEntries");
|
||||
assert.equal(cutoverStatus({ complete: false, unknownEntries: 0, readFailures: 0, unverifiedFiles: 0 }), "unknown");
|
||||
});
|
||||
|
||||
test("CLI: absent and empty legacy layouts clear without migration", async () => {
|
||||
const root = await fixture();
|
||||
let result = cleanRun(root, ["--check"]);
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(JSON.parse(result.stdout).status, "clear");
|
||||
for (const directory of ["accounting-receipts", `quota-credential-recovery/${company}`, "data/sandbox-orphan-cleanup"]) {
|
||||
await fs.mkdir(path.join(root, directory), { recursive: true });
|
||||
}
|
||||
result = cleanRun(root, ["--check"]);
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(JSON.parse(result.stdout).status, "clear");
|
||||
});
|
||||
|
||||
test("CLI: real pending legacy files block, preserve bytes and reveal no secrets or names", async () => {
|
||||
const root = await fixture();
|
||||
const receipt = JSON.stringify({ schema: "paperclip/accounting-receipt/v1", id: grant, companyId: company,
|
||||
runId: company, sourceId: company, sequence: 1, receivedAt: "2026-10-30T00:00:00.000Z", adapterType: "codex_local",
|
||||
receipt: { complete: true, usage: { inputTokens: 1, outputTokens: 1 } } });
|
||||
const files = await Promise.all([
|
||||
write(root, `accounting-receipts/${grant}.json`, receipt),
|
||||
write(root, `quota-credential-recovery/${company}/${grant}.json`, '{"ciphertext":"secret-token-must-never-appear"}'),
|
||||
write(root, "data/sandbox-orphan-cleanup/secret-filename.json", JSON.stringify({ companyId: company,
|
||||
environmentId: company, executionWorkspaceId: null, issueId: null, heartbeatRunId: null, provider: "daytona",
|
||||
providerLeaseId: "private-resource", metadata: {} })),
|
||||
]);
|
||||
const before = await Promise.all(files.map(file => fs.readFile(file)));
|
||||
const result = cleanRun(root, ["--check"]);
|
||||
assert.equal(result.status, 1);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.equal(report.status, "blocked");
|
||||
assert.deepEqual(report.directories.map(s => s.unverifiedFiles), [1, 1, 1]);
|
||||
assert.doesNotMatch(result.stdout + result.stderr, /secret-token|secret-filename|private-resource|ciphertext|00000000-/);
|
||||
assert.deepEqual(await Promise.all(files.map(file => fs.readFile(file))), before);
|
||||
assert.equal(cleanRun(root).status, 0, "report-only mode does not fail on obligations");
|
||||
});
|
||||
|
||||
test("truncated, temporary, and unreadable files are unverified, never clear", async () => {
|
||||
const root = await fixture();
|
||||
await write(root, "accounting-receipts/truncated.json", '{"schema":');
|
||||
const file = await write(root, `quota-credential-recovery/${company}/${grant}.json.probe`);
|
||||
await fs.chmod(file, 0);
|
||||
const report = await inventoryRecoveryFiles({ instanceRoot: root });
|
||||
assert.equal(report.status, "blocked");
|
||||
assert.deepEqual(report.directories.map(s => s.unverifiedFiles), [1, 1, 0]);
|
||||
});
|
||||
|
||||
test("unknown nesting and symlinks block without traversing outside the chosen roots", async () => {
|
||||
const root = await fixture();
|
||||
const outside = await fixture();
|
||||
await write(outside, "do-not-read.json");
|
||||
await fs.mkdir(path.join(root, "accounting-receipts"));
|
||||
await fs.symlink(outside, path.join(root, "accounting-receipts", "link"));
|
||||
await fs.mkdir(path.join(root, "quota-credential-recovery", company, "unexpected"), { recursive: true });
|
||||
const report = await inventoryRecoveryFiles({ instanceRoot: root });
|
||||
assert.equal(report.status, "unknown");
|
||||
assert.deepEqual(report.directories.map(s => s.unknownEntries), [1, 1, 0]);
|
||||
assert.deepEqual(report.directories.map(s => s.unverifiedFiles), [0, 0, 0]);
|
||||
await fs.symlink(outside, path.join(root, "data"));
|
||||
assert.equal((await inventoryRecoveryFiles({ instanceRoot: root })).directories[2].status, "unknown");
|
||||
await fs.symlink(outside, path.join(root, "instance-link"));
|
||||
assert.equal((await inventoryRecoveryFiles({ instanceRoot: path.join(root, "instance-link") })).status, "unknown");
|
||||
});
|
||||
|
||||
test("entry budget gives incomplete lower-bound counts and never clears omitted work", async () => {
|
||||
const root = await fixture();
|
||||
for (let i = 0; i < 4; i++) await write(root, `accounting-receipts/${i}.json`);
|
||||
const report = await inventoryRecoveryFiles({ instanceRoot: root, maxEntries: 2 });
|
||||
assert.equal(report.status, "unknown");
|
||||
assert.equal(report.directories[0].observedEntries, 2);
|
||||
assert.equal(report.directories[0].complete, false);
|
||||
assert.equal(report.directories[0].unverifiedFiles, 2);
|
||||
const exact = await inventoryRecoveryFiles({ instanceRoot: root, maxEntries: 4 });
|
||||
assert.equal(exact.directories[0].complete, true);
|
||||
});
|
||||
|
||||
test("CLI respects configured orphan override and explicit override precedence", async () => {
|
||||
const root = await fixture();
|
||||
const override = await fixture();
|
||||
await write(override, "pending.json");
|
||||
let result = run(root, ["--check"], { SANDBOX_ORPHAN_CLEANUP_SPOOL_DIR: override });
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(JSON.parse(result.stdout).directories[2].unverifiedFiles, 1);
|
||||
const emptyOverride = await fixture();
|
||||
result = run(root, ["--check", "--orphan-directory", emptyOverride], { SANDBOX_ORPHAN_CLEANUP_SPOOL_DIR: override });
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(JSON.parse(result.stdout).status, "clear");
|
||||
});
|
||||
|
||||
test("fault boundary reports stat, directory read and close failures without error data", async () => {
|
||||
const root = await fixture();
|
||||
await fs.mkdir(path.join(root, "accounting-receipts"));
|
||||
for (const operation of ["lstat", "opendir", "read", "close"]) {
|
||||
const sensitiveError = Object.assign(new Error("secret-token-must-never-appear"), { code: "EACCES" });
|
||||
const io = { ...fs };
|
||||
if (operation === "lstat" || operation === "opendir") io[operation] = async () => { throw sensitiveError; };
|
||||
else io.opendir = async () => ({
|
||||
read: async () => { if (operation === "read") throw sensitiveError; return null; },
|
||||
close: async () => { if (operation === "close") throw sensitiveError; },
|
||||
});
|
||||
const report = await inventoryRecoveryFiles({ instanceRoot: root }, io);
|
||||
assert.equal(report.status, "unknown");
|
||||
if (operation === "lstat") assert.equal(report.instanceStatus, "unreadable");
|
||||
else assert.ok(report.directories[0].readFailures > 0);
|
||||
assert.doesNotMatch(JSON.stringify(report), /secret-token|EACCES/);
|
||||
}
|
||||
});
|
||||
|
||||
test("CLI fails closed for missing instance, unsafe roots and bad arguments", async () => {
|
||||
const root = await fixture();
|
||||
assert.equal(cleanRun(path.join(root, "absent"), ["--check"]).status, 1);
|
||||
for (const args of [["--max-entries", "0"], ["--max-entries", "100001"], ["--max-entries", "NaN"],
|
||||
["--orphan-directory", "relative-secret"], ["--bad-secret"], ["--check", "--check"]]) {
|
||||
const result = cleanRun(root, args);
|
||||
assert.equal(result.status, 2);
|
||||
assert.doesNotMatch(result.stdout + result.stderr, /relative-secret|bad-secret/);
|
||||
}
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { loadShardDurations, selectGeneralServerShard } from "./general-server-s
|
||||
import { invocationEnv } from "./ci-profile/record.mjs";
|
||||
|
||||
import { assertSelectedTests, partitionTestLines } from "./test-line-shard.mjs";
|
||||
import { loadSelection, selectedShard } from "./ci/test-selection-manifest.mjs";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const scriptsDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -91,6 +92,7 @@ const nativeRunnerSuites = [
|
||||
"server/src/__tests__/dot-runner.test.ts",
|
||||
// These process cases otherwise skip in server shards without Runner binaries.
|
||||
"server/src/services/native-runtime/native-runner-restart-recovery.integration.test.ts",
|
||||
"server/src/services/native-runtime/native-usage-delivery.integration.test.ts",
|
||||
];
|
||||
// In the PR workflow (pr.yml, the caller of pr-trusted.yml — reusable
|
||||
// workflows inherit the caller's GITHUB_WORKFLOW), the last Verify Paperclip
|
||||
@@ -378,7 +380,7 @@ function selectSerializedSuites(routeTests, shardIndex, shardCount) {
|
||||
return shardFiles.map((file) => byRepoPath.get(file));
|
||||
}
|
||||
|
||||
async function runVitest(args, label, testShard = null) {
|
||||
async function runVitest(args, label, testShard = null, exactFiles = false) {
|
||||
console.log(`\n[test:run] ${label}`);
|
||||
invocationIndex += 1;
|
||||
const tempRootParent = process.platform === "win32" ? os.tmpdir() : "/tmp";
|
||||
@@ -436,7 +438,9 @@ async function runVitest(args, label, testShard = null) {
|
||||
args = [...args.filter((arg) => arg !== chatSuite), ...runFilters, "--allowOnly=false"];
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn("pnpm", ["exec", "vitest", "run", ...sourceOnlyVitestArgs, ...args], {
|
||||
const command = exactFiles ? process.execPath : "pnpm";
|
||||
const prefix = exactFiles ? [path.join(scriptsDir, "ci/run-selected-vitest.mjs")] : ["exec", "vitest", "run"];
|
||||
const child = spawn(command, [...prefix, ...sourceOnlyVitestArgs, ...args], {
|
||||
cwd: repoRoot,
|
||||
env: invocationEnv("vitest", env),
|
||||
stdio: "inherit",
|
||||
@@ -615,6 +619,24 @@ const generalServerTestFiles = [
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const options = parseCliOptions(process.argv.slice(2));
|
||||
const selectionProjects = options.mode === serializedModeName || generalServerGroups.includes(options.group)
|
||||
? ["@paperclipai/server"]
|
||||
: options.group === generalWorkspacesAGroupName ? generalWorkspacesAProjects
|
||||
: options.group === generalWorkspacesBGroupName ? generalWorkspacesBProjects
|
||||
: options.group === generalWorkspacesCGroupName ? generalWorkspacesCProjects : [];
|
||||
const selection = process.env.PAPERCLIP_CI_TEST_SELECTION
|
||||
? selectedShard(loadSelection(), options, selectionProjects) : null;
|
||||
if (selection?.["@paperclipai/server"]) {
|
||||
const owned = new Set(options.mode === serializedModeName ? routeTests.map((test) => test.repoPath)
|
||||
: generalServerTestFiles.filter((file) => !excludedServerSuites(options.group).includes(file)));
|
||||
for (const file of selection["@paperclipai/server"]) {
|
||||
if (!owned.has(file)) fail(`Selected test belongs to another server lane: ${file}`);
|
||||
}
|
||||
}
|
||||
if (selection && options.dryRun) {
|
||||
writeFileSync(1, JSON.stringify({ selection }));
|
||||
process.exit(0);
|
||||
}
|
||||
if (options.dryRun) {
|
||||
const serializedSuites =
|
||||
options.mode === serializedModeName
|
||||
@@ -670,7 +692,16 @@ if (options.dryRun) {
|
||||
}
|
||||
|
||||
try {
|
||||
if (options.mode === generalModeName || options.mode === allModeName) {
|
||||
if (selection) {
|
||||
for (const [project, files] of Object.entries(selection)) {
|
||||
const serverArgs = project !== "@paperclipai/server" ? []
|
||||
: options.mode === serializedModeName || process.env.PAPERCLIP_TEST_DATABASE_URL
|
||||
? ["--pool=forks", "--isolate", ...serializedServerVitestArgs]
|
||||
: ["--pool=forks", "--isolate", "--fileParallelism", "--maxWorkers=2"];
|
||||
await runVitest(["--project", project, ...serverArgs, ...files],
|
||||
`selected ${project}: ${files.length} files`, null, true);
|
||||
}
|
||||
} else if (options.mode === generalModeName || options.mode === allModeName) {
|
||||
if (options.group) {
|
||||
await runGeneralGroup(routeTests, options.group, options.shardIndex, options.shardCount);
|
||||
} else {
|
||||
@@ -678,7 +709,7 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
if (options.mode === serializedModeName || options.mode === allModeName) {
|
||||
if (!selection && (options.mode === serializedModeName || options.mode === allModeName)) {
|
||||
await runSerializedSuites(routeTests, options.shardIndex ?? 0, options.shardCount ?? 1);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
Reference in new issue
Block a user