mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 11:46:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Contributors wait for PR CI before merge, so CI wall-clock time sets the pace of work > - The Runner package Vitest lane is the PR CI critical path. It finishes a median 15.3 minutes after the push > - That lane runs all 311 package test files in one job with 2 workers: 1,381 seconds of serial work and 727 seconds of wall time > - Ten files account for 61% of the time. Vitest's `--shard` splits by file count, not by duration, so it cannot balance this work > - This pull request splits the package files into three lanes balanced by duration, and runs the native server group in its own lane > - The benefit is about 232 seconds of tests per package lane instead of 727 seconds ## Linked Issues or Issue Description **What existing behavior does this improve?** The time that PR CI needs to run the Runner package Vitest tests. **Subsystem affected** CI: the `Verify Paperclip Runner` Vitest lanes in `pr-trusted.yml`, and `packages/paperclip-runner/scripts/run-pr-vitest-lane.mjs`. **Current behavior** The `vitest package` lane takes a median of 13.6 minutes. It is the last job to finish in most PR runs. The lane keeps a 2-worker limit so that Runner and sidecar processes can start within their deadlines. Average CPU use is 29%. **Proposed behavior** Three package lanes each run about one third of the files, balanced by measured duration. Each lane keeps the 2-worker limit. The native server group runs in its own lane, as before. **Reason and benefit** The Runner lane stops being the critical path. More workers per lane would add CPU contention and timeouts, so this change adds lanes instead. The cost is about 3 runner-minutes per run for the extra setup. **Breaking changes** None. The `master` ruleset requires only `ci / verify` and `ci / e2e`, so the new job names do not change required checks. ## What Changed - Add `packages/paperclip-runner/scripts/vitest-package-lanes.mjs`. - It gets the file inventory from `vitest list --filesOnly --json`, so it follows `vitest.config.ts`. - It partitions the files with the `partitionGeneralServerSuites` function from `scripts/general-server-shard.mjs`. - Add `packages/paperclip-runner/scripts/vitest-lane-durations.json`. - It uses the same schema as `scripts/general-server-shard-durations.json`. - It is seeded from CI run 42290920882. It measures 310 of the 311 files. - `run-pr-vitest-lane.mjs` accepts two new arguments: - `--package-lane=K/N` runs one partition. - `--native-server` runs only the native server group. - The `--shard=1/2` and `--shard=2/2` arguments keep their exact behavior for the matrix that master still sends. - `.github/ci-task-matrix.json` and the fallback matrix in `pr-trusted.yml` have three `vitest package (K/3)` lanes and the `vitest native server` lane. The labels keep the `vitest` prefix because `scripts/ci-profile/trace.mjs` matches `Verify Paperclip Runner (vitest`. - Tests: - the lanes cover the inventory exactly once, checked with real `vitest list`; - the old `--shard` commands plan the same commands as before; - the manifest schema is correct; - bad lane arguments are refused. - Update the Runner Vitest lane paragraph in `doc/DEVELOPING.md`. ## Verification - `node --test scripts/__tests__/run-vitest-stable-shard.test.mjs`: 44/44 pass. - `node --test scripts/__tests__/runner-release-crate-cache.test.mjs .github/scripts/tests/ci-plan.test.mjs`, together with the file above: 71/71 pass. - Related workflow, cache, routing and profile tests: 446/446, 153/153 and 14/14 pass. - Local end-to-end run of `GITHUB_WORKFLOW=PR node scripts/run-pr-vitest-lane.mjs --package-lane=3/3` on macOS: - the prep steps ran, and the lane ran 104 of 311 files in 123 seconds; - 2 tests failed: a macOS `kill EPERM` and a 5-second timeout under full local load without the CI worker limit; - both files pass when run alone (35/35 tests). - This PR's own CI runs the matrix from master, so it uses the old `--shard` commands. The new lanes run in CI only after merge. Reviewers can compare the job durations of the first PR runs after the merge. | Lane | Files | Serial estimate | Wall estimate with 2 workers | | --- | --- | --- | --- | | 1/3 | 103 | 464.9 s | ~232 s | | 2/3 | 104 | 465.0 s | ~232 s | | 3/3 | 104 | 465.0 s | ~232 s | ## Risks - The weights come from one CI log and do not include collection or setup time. The balance can be uneven until a refresh measures real lane runs. - Each lane passes its file paths to Vitest as filters, and Vitest matches a filter as a substring of the path. A path that is a substring of another path would run a file in two lanes. No current path does this. If one does, the script prints a warning, and no file is dropped. - Each package lane runs `ensure:eval-build-deps` and `build:rust` again, which adds about 3 runner-minutes per run. - A comment in `scripts/run-vitest-stable.mjs` (about line 110) still says that the last Runner shard runs the native group. A follow-up will correct it. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use and code execution. A Claude sub-agent wrote the change, and the parent session reviewed it and ran the local lane check. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
845 lines
45 KiB
JavaScript
845 lines
45 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
defaultSuiteWeight,
|
|
loadShardDurations,
|
|
partitionGeneralServerSuites,
|
|
} from "../general-server-shard.mjs";
|
|
|
|
import { assertSelectedTests, partitionTestLines } from "../test-line-shard.mjs";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
const script = path.join(repoRoot, "scripts", "run-vitest-stable.mjs");
|
|
const durationsManifest = path.join(repoRoot, "scripts", "general-server-shard-durations.json");
|
|
const serializedDurationsManifest = path.join(
|
|
repoRoot,
|
|
"scripts",
|
|
"serialized-shard-durations.json",
|
|
);
|
|
|
|
// Membership of general-server-without-chat depends on GITHUB_WORKFLOW (see
|
|
// prWorkflowName in run-vitest-stable.mjs), so strip the ambient value and
|
|
// make every test pin the caller it mirrors explicitly — these tests
|
|
// themselves run inside a workflow on CI.
|
|
function workflowEnv(envOverrides = {}) {
|
|
const env = { ...process.env, ...envOverrides };
|
|
if (!("GITHUB_WORKFLOW" in envOverrides)) {
|
|
delete env.GITHUB_WORKFLOW;
|
|
}
|
|
return env;
|
|
}
|
|
|
|
function dryRun(args, envOverrides = {}) {
|
|
const result = spawnSync(process.execPath, [script, ...args, "--dry-run"], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
env: workflowEnv(envOverrides),
|
|
});
|
|
return result;
|
|
}
|
|
|
|
function dryRunJson(args, envOverrides = {}) {
|
|
const result = dryRun(args, envOverrides);
|
|
assert.equal(result.status, 0, `expected success for ${args.join(" ")}: ${result.stderr}`);
|
|
return JSON.parse(result.stdout);
|
|
}
|
|
|
|
const SHARD_COUNT = 12;
|
|
const SERIALIZED_SHARD_COUNT = 9;
|
|
|
|
test("the general-server shards form a complete, non-overlapping partition", () => {
|
|
const shards = Array.from({ length: SHARD_COUNT }, (_, index) =>
|
|
dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", String(index), "--shard-count", String(SHARD_COUNT)]),
|
|
);
|
|
|
|
const total = shards[0].generalServerSuiteCount;
|
|
assert.ok(total > 0, "expected a non-empty general-server suite set");
|
|
|
|
const seen = new Set();
|
|
let selectedTotal = 0;
|
|
for (const shard of shards) {
|
|
assert.equal(shard.generalServerSuiteCount, total, "suite count must be stable across shards");
|
|
for (const file of shard.selectedGeneralServerSuites) {
|
|
assert.ok(
|
|
!(file.startsWith("server/src/__tests__/") && /[^/]*(?:route|routes|authz)[^/]*\.test\.ts$/.test(file)),
|
|
`route/authz suite must stay in the serialized lane, not general-server: ${file}`,
|
|
);
|
|
assert.ok(!seen.has(file), `suite assigned to more than one shard: ${file}`);
|
|
seen.add(file);
|
|
selectedTotal += 1;
|
|
}
|
|
}
|
|
|
|
// Every suite runs exactly once: union covers the whole set with no overlap.
|
|
assert.equal(selectedTotal, total, "every suite must be selected exactly once");
|
|
assert.equal(seen.size, total, "union of shards must cover the whole suite set");
|
|
});
|
|
|
|
test("general shards retain scripts and route-named suites outside the serialized directory", () => {
|
|
const general = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", "1"]);
|
|
for (const file of [
|
|
"server/scripts/verify-runner-vendor-dependencies.test.mjs",
|
|
"server/src/services/openrouter-models.test.ts",
|
|
"server/src/routes/setup-token-route.test.ts",
|
|
]) assert.ok(general.selectedGeneralServerSuites.includes(file), `missing configured server suite: ${file}`);
|
|
});
|
|
|
|
for (const group of ["general-workspaces-b", "general-workspaces-c"]) {
|
|
test(`shard flags are rejected for the ${group} group`, () => {
|
|
const result = dryRun(["--mode", "general", "--group", group, "--shard-index", "0", "--shard-count", "3"]);
|
|
assert.notEqual(result.status, 0, `${group} must not accept shard flags`);
|
|
});
|
|
}
|
|
|
|
test("workspace lanes cover every non-server project in the root Vitest configuration", () => {
|
|
const config = readFileSync(path.join(repoRoot, "vitest.config.ts"), "utf8");
|
|
const roots = [...config.matchAll(/^\s+"([^"]+)",?\s*$/gm)].map(match => match[1]);
|
|
assert.ok(roots.includes("server"), "expected the explicit root Vitest project list");
|
|
const expected = roots.filter(root => root !== "server")
|
|
.map(root => JSON.parse(readFileSync(path.join(repoRoot, root, "package.json"), "utf8")).name).sort();
|
|
const actual = ["general-workspaces-a", "general-workspaces-b", "general-workspaces-c"]
|
|
.flatMap(group => dryRunJson(["--mode", "general", "--group", group]).workspaceProjects).sort();
|
|
assert.deepEqual(actual, expected, "no configured project may be silently omitted or run twice");
|
|
});
|
|
|
|
// Collects every `- group:` matrix entry in a workflow with the keys it sets.
|
|
// Comment and blank lines are dropped first, and an entry ends only when the
|
|
// indentation returns to the item level, so a comment between two keys cannot
|
|
// hide a shard setting from the assertions below.
|
|
function matrixEntries(text) {
|
|
const entries = [];
|
|
let current = null;
|
|
for (const line of text.split("\n")) {
|
|
if (/^\s*(#|$)/.test(line)) continue;
|
|
const indent = line.match(/^ */)[0].length;
|
|
const item = line.match(/^ *- group: (\S+)/);
|
|
if (item) {
|
|
current = { group: item[1], indent, keys: ["group"] };
|
|
entries.push(current);
|
|
continue;
|
|
}
|
|
if (!current) continue;
|
|
if (indent <= current.indent) { current = null; continue; }
|
|
const key = line.match(/^ *([A-Za-z_]+):/);
|
|
if (key) current.keys.push(key[1]);
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
test("matrixEntries sees shard keys that sit behind comments or later in the entry", () => {
|
|
const entries = matrixEntries([
|
|
" include:",
|
|
" - group: general-workspaces-b",
|
|
" group_label: workspaces-b",
|
|
" # a comment between keys must not hide the shard setting",
|
|
" shard_index: 0",
|
|
"",
|
|
" shard_count: 3",
|
|
" - group: general-workspaces-c",
|
|
" group_label: workspaces-c",
|
|
" steps:",
|
|
" - name: unrelated",
|
|
" shard_index: 9",
|
|
].join("\n"));
|
|
assert.deepEqual(entries.map(entry => entry.group), ["general-workspaces-b", "general-workspaces-c"]);
|
|
assert.deepEqual(entries[0].keys, ["group", "group_label", "shard_index", "shard_count"]);
|
|
assert.deepEqual(entries[1].keys, ["group", "group_label"], "keys after the matrix must not leak into the last entry");
|
|
});
|
|
|
|
test("workspaces-c runs the db project alone and workspaces-b no longer carries it", () => {
|
|
// The db project was 168-250s of the 436-513s workspaces-b vitest time
|
|
// (PR runs 38084171761, 38083084276 and 38083020834, 2026-10-10), so it
|
|
// runs in its own lane. The PR and release matrices both carry that lane.
|
|
const workspacesC = dryRunJson(["--mode", "general", "--group", "general-workspaces-c"]);
|
|
assert.deepEqual(workspacesC.workspaceProjects, ["@paperclipai/db"]);
|
|
assert.equal(workspacesC.workspacesVitestShard, null);
|
|
const workspacesB = dryRunJson(["--mode", "general", "--group", "general-workspaces-b"]);
|
|
assert.ok(!workspacesB.workspaceProjects.includes("@paperclipai/db"), "db must not run twice");
|
|
assert.ok(workspacesB.workspaceProjects.length > 0, "workspaces-b must still run its other projects");
|
|
for (const workflow of ["pr-trusted.yml", "release-verify.yml"]) {
|
|
const entries = workflow === "pr-trusted.yml"
|
|
? JSON.parse(readFileSync(path.join(repoRoot, ".github/ci-task-matrix.json"), "utf8")).general_tests
|
|
.map((entry) => ({ ...entry, keys: Object.keys(entry) }))
|
|
: matrixEntries(readFileSync(path.join(repoRoot, ".github", "workflows", workflow), "utf8"));
|
|
// The parser must demonstrably see shard keys in this workflow, otherwise
|
|
// the unsharded assertions below could pass for the wrong reason.
|
|
const sharded = entries.filter(entry => entry.group === "general-workspaces-a");
|
|
assert.ok(sharded.length > 0 && sharded.every(entry => entry.keys.includes("shard_index") && entry.keys.includes("shard_count")),
|
|
`${workflow} must carry the sharded workspaces-a entries`);
|
|
for (const group of ["general-workspaces-b", "general-workspaces-c"]) {
|
|
const matching = entries.filter(entry => entry.group === group);
|
|
assert.equal(matching.length, 1, `${workflow} must run ${group} exactly once`);
|
|
assert.ok(matching[0].keys.includes("group_label"), `${workflow} must label the ${group} job`);
|
|
assert.deepEqual(matching[0].keys.filter(key => key.startsWith("shard_")), [], `${workflow} must run ${group} unsharded`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("workspaces-a shards map to Vitest native --shard slices over a stable project list", () => {
|
|
const shards = [0, 1].map((index) =>
|
|
dryRunJson([
|
|
"--mode", "general", "--group", "general-workspaces-a",
|
|
"--shard-index", String(index), "--shard-count", "2",
|
|
]),
|
|
);
|
|
|
|
assert.deepEqual(
|
|
shards.map((shard) => shard.workspacesVitestShard),
|
|
["1/2", "2/2"],
|
|
"each matrix job must pass its own --shard slice to vitest",
|
|
);
|
|
// Vitest's --shard partitions each project's file list deterministically, so
|
|
// an identical project list across jobs is what guarantees complete,
|
|
// non-overlapping coverage of the lane.
|
|
assert.deepEqual(shards[0].workspaceProjects, shards[1].workspaceProjects);
|
|
assert.ok(shards[0].workspaceProjects.length > 0, "workspaces-a must run at least one project");
|
|
|
|
const unsharded = dryRunJson(["--mode", "general", "--group", "general-workspaces-a"]);
|
|
assert.deepEqual(
|
|
unsharded.workspaceProjects,
|
|
shards[0].workspaceProjects,
|
|
"sharding must not change which projects the lane covers",
|
|
);
|
|
assert.equal(unsharded.workspacesVitestShard, null);
|
|
});
|
|
|
|
test("duration-aware partition balances skewed weights better than round-robin", () => {
|
|
// Round-robin puts all three heavy suites on shard 0 (indexes 0, 3, 6).
|
|
const files = ["a", "b", "c", "d", "e", "f", "g", "h", "i"];
|
|
const durations = { a: 30000, d: 30000, g: 30000, b: 100, c: 100, e: 100, f: 100, h: 100, i: 100 };
|
|
|
|
const shards = partitionGeneralServerSuites(files, 3, durations);
|
|
const totals = shards.map((shard) => shard.totalWeight);
|
|
const maxTotal = Math.max(...totals);
|
|
const minTotal = Math.min(...totals);
|
|
assert.ok(
|
|
maxTotal - minTotal <= 200,
|
|
`expected near-even shard weights, got ${totals.join(", ")}`,
|
|
);
|
|
assert.equal(
|
|
shards.flatMap((shard) => shard.files).sort().join(","),
|
|
files.join(","),
|
|
"partition must cover every file exactly once",
|
|
);
|
|
});
|
|
|
|
test("the partition is deterministic for identical inputs", () => {
|
|
const files = Array.from({ length: 50 }, (_, index) => `suite-${index}.test.ts`);
|
|
const durations = Object.fromEntries(files.map((file, index) => [file, (index * 37) % 5000]));
|
|
|
|
const first = partitionGeneralServerSuites(files, 3, durations);
|
|
const second = partitionGeneralServerSuites(files, 3, durations);
|
|
assert.deepEqual(first, second, "same inputs must always produce the same partition");
|
|
});
|
|
|
|
test("suites missing from the manifest get the median weight", () => {
|
|
assert.equal(defaultSuiteWeight({ a: 100, b: 300, c: 900 }), 300);
|
|
assert.equal(defaultSuiteWeight({ a: 100, b: 300, c: 500, d: 900 }), 400);
|
|
assert.equal(defaultSuiteWeight({}), 1000, "empty manifest falls back to a fixed weight");
|
|
});
|
|
|
|
test("a missing or malformed manifest degrades to uniform weights", () => {
|
|
assert.deepEqual(loadShardDurations(path.join(repoRoot, "scripts", "no-such-manifest.json")), {});
|
|
|
|
const files = ["a", "b", "c", "d"];
|
|
const shards = partitionGeneralServerSuites(files, 2, {});
|
|
assert.equal(shards[0].files.length + shards[1].files.length, files.length);
|
|
assert.equal(Math.abs(shards[0].files.length - shards[1].files.length), 0);
|
|
});
|
|
|
|
test("the checked-in manifest loads and covers most of the current suite set", () => {
|
|
const durations = loadShardDurations(durationsManifest);
|
|
assert.ok(Object.keys(durations).length > 0, "manifest must parse to a non-empty duration map");
|
|
|
|
const shard = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", "1"]);
|
|
const currentFiles = shard.selectedGeneralServerSuites;
|
|
const known = currentFiles.filter((file) => durations[file] !== undefined).length;
|
|
assert.ok(
|
|
known / currentFiles.length >= 0.5,
|
|
`manifest is stale: only ${known} of ${currentFiles.length} suites have recorded durations — regenerate it from a recent PR run (see the manifest's $comment)`,
|
|
);
|
|
});
|
|
|
|
test("the chat integration suite keeps a measured duration for duration-aware fallbacks", () => {
|
|
// The PR and release matrices both run the chat suite in dedicated
|
|
// line-sharded lanes, but the plain general-server group (local full runs)
|
|
// still weighs it into the LPT partition; a median-fallback weight there
|
|
// would silently overload whichever shard receives it.
|
|
const chatSuite = "server/src/__tests__/chat-channels.integration.test.ts";
|
|
const durations = loadShardDurations(durationsManifest);
|
|
assert.ok(
|
|
Number.isFinite(durations[chatSuite]),
|
|
"the full chat cohort must have a measured duration, not the median fallback",
|
|
);
|
|
});
|
|
|
|
test("the checked-in serialized manifest loads and covers most of the current suite set", () => {
|
|
const durations = loadShardDurations(serializedDurationsManifest);
|
|
assert.ok(Object.keys(durations).length > 0, "manifest must parse to a non-empty duration map");
|
|
|
|
const shard = dryRunJson(["--mode", "serialized", "--shard-index", "0", "--shard-count", "1"]);
|
|
const currentFiles = shard.selectedSerializedSuites;
|
|
const known = currentFiles.filter((file) => durations[file] !== undefined).length;
|
|
assert.ok(
|
|
known / currentFiles.length >= 0.5,
|
|
`manifest is stale: only ${known} of ${currentFiles.length} suites have recorded durations — regenerate it from a recent PR run (see the manifest's $comment)`,
|
|
);
|
|
});
|
|
|
|
test("the real serialized shard partition is complete, non-overlapping and duration-balanced", () => {
|
|
const durations = loadShardDurations(serializedDurationsManifest);
|
|
const fallback = defaultSuiteWeight(durations);
|
|
const shards = Array.from({ length: SERIALIZED_SHARD_COUNT }, (_, index) =>
|
|
dryRunJson(["--mode", "serialized", "--shard-index", String(index), "--shard-count", String(SERIALIZED_SHARD_COUNT)]),
|
|
);
|
|
|
|
const total = shards[0].serializedSuiteCount;
|
|
const selected = shards.flatMap((shard) => shard.selectedSerializedSuites);
|
|
assert.equal(selected.length, total, "every serialized suite must be selected exactly once");
|
|
assert.equal(new Set(selected).size, total, "serialized shards must not overlap");
|
|
|
|
const totals = shards.map((shard) =>
|
|
shard.selectedSerializedSuites.reduce((sum, file) => sum + (durations[file] ?? fallback), 0),
|
|
);
|
|
const maxTotal = Math.max(...totals);
|
|
const minTotal = Math.min(...totals);
|
|
// LPT keeps the spread within the heaviest single suite; use that as the bound.
|
|
const heaviest = Math.max(...Object.values(durations));
|
|
assert.ok(
|
|
maxTotal - minTotal <= heaviest,
|
|
`serialized shard weight spread ${maxTotal - minTotal}ms exceeds heaviest suite ${heaviest}ms: ${totals.join(", ")}`,
|
|
);
|
|
});
|
|
|
|
const chatSuitePath = "server/src/__tests__/chat-channels.integration.test.ts";
|
|
const nativeRunnerSuitePath =
|
|
"server/src/services/native-runtime/native-codex-runner.integration.test.ts";
|
|
const ingressRetrySuitePath = "server/src/services/native-runtime/native-ingress-bootstrap-retry.integration.test.ts";
|
|
const usageDeliverySuitePath = "server/src/services/native-runtime/native-usage-delivery.integration.test.ts";
|
|
const warmCheckpointOwnershipSuitePath = "server/src/services/native-runtime/native-warm-checkpoint-ownership.integration.test.ts";
|
|
const pluginWarmOwnerSuitePath = "server/src/services/native-runtime/plugin-runner-task-warm.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";
|
|
const restartWaitSuites = ["lease-renewal", "preparation", "checkpoint-cost", "diskless"]
|
|
.map(name => `server/src/services/native-runtime/native-runner-restart-${name}.integration.test.ts`);
|
|
|
|
// Mirrors pr-trusted.yml (12 shards, called by pr.yml so GITHUB_WORKFLOW is
|
|
// "PR"): the chat suite runs in its dedicated lanes and the cargo-dependent
|
|
// native-runner suite in the Rust-cached final Verify Paperclip Runner vitest
|
|
// shard, so together the three cover the full server group exactly.
|
|
test("12 PR without-chat shards plus the dedicated chat and native-runner lanes cover the original server group exactly", () => {
|
|
const durations = loadShardDurations(durationsManifest);
|
|
const fallback = defaultSuiteWeight(durations);
|
|
const prEnv = { GITHUB_WORKFLOW: "PR" };
|
|
const full = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", "1"], prEnv);
|
|
const shards = Array.from({ length: 12 }, (_, index) => dryRunJson([
|
|
"--mode", "general", "--group", "general-server-without-chat",
|
|
"--shard-index", String(index), "--shard-count", "12",
|
|
], prEnv));
|
|
const totals = shards.map((shard) =>
|
|
shard.selectedGeneralServerSuites.reduce((sum, file) => sum + (durations[file] ?? fallback), 0),
|
|
);
|
|
const maxTotal = Math.max(...totals);
|
|
const minTotal = Math.min(...totals);
|
|
// LPT keeps the spread within the heaviest single suite; use that as the
|
|
// bound. The chat and native-runner suites run in their own lanes, so
|
|
// exclude them here.
|
|
const chat = "server/src/__tests__/chat-channels.integration.test.ts";
|
|
const nativeRunner =
|
|
"server/src/services/native-runtime/native-codex-runner.integration.test.ts";
|
|
const heaviest = Math.max(
|
|
...Object.entries(durations)
|
|
.filter(([file]) => file !== chat && file !== nativeRunner)
|
|
.map(([, ms]) => ms),
|
|
);
|
|
assert.ok(
|
|
maxTotal - minTotal <= heaviest,
|
|
`shard weight spread ${maxTotal - minTotal}ms exceeds heaviest suite ${heaviest}ms: ${totals.join(", ")}`,
|
|
);
|
|
|
|
const files = shards.flatMap((shard) => shard.selectedGeneralServerSuites);
|
|
assert.ok(!files.includes(chatSuitePath));
|
|
assert.ok(!files.includes(nativeRunnerSuitePath));
|
|
assert.ok(!files.includes(ingressRetrySuitePath));
|
|
assert.ok(!files.includes(dotRunnerSuitePath));
|
|
assert.ok(!files.includes(restartRecoverySuitePath));
|
|
assert.ok(!files.includes(usageDeliverySuitePath));
|
|
assert.ok(!files.includes(warmCheckpointOwnershipSuitePath));
|
|
assert.ok(!files.includes(pluginWarmOwnerSuitePath));
|
|
assert.deepEqual([...files, chatSuitePath, nativeRunnerSuitePath, ingressRetrySuitePath, dotRunnerSuitePath, restartRecoverySuitePath, ...restartWaitSuites, usageDeliverySuitePath, warmCheckpointOwnershipSuitePath, pluginWarmOwnerSuitePath, "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);
|
|
});
|
|
|
|
// The legacy group and local default must remain complete when a caller has
|
|
// not selected a separate native lane.
|
|
for (const [caller, envOverrides] of [["Release", { GITHUB_WORKFLOW: "Release" }], ["no ambient workflow", {}]]) {
|
|
test(`10 without-chat shards under ${caller} keep the native-runner suite and cover the server group with chat alone`, () => {
|
|
const full = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", "1"], envOverrides);
|
|
const shards = Array.from({ length: 10 }, (_, index) => dryRunJson([
|
|
"--mode", "general", "--group", "general-server-without-chat",
|
|
"--shard-index", String(index), "--shard-count", "10",
|
|
], envOverrides));
|
|
const files = shards.flatMap((shard) => shard.selectedGeneralServerSuites);
|
|
assert.ok(!files.includes(chatSuitePath));
|
|
assert.ok(files.includes(nativeRunnerSuitePath));
|
|
assert.ok(files.includes(dotRunnerSuitePath));
|
|
assert.ok(files.includes(restartRecoverySuitePath));
|
|
assert.ok(files.includes(warmCheckpointOwnershipSuitePath));
|
|
assert.ok(files.includes(pluginWarmOwnerSuitePath));
|
|
assert.deepEqual([...files, chatSuitePath].sort(), full.selectedGeneralServerSuites.sort());
|
|
assert.equal(new Set(files).size, files.length);
|
|
});
|
|
}
|
|
|
|
// Release verification selects its dedicated lane explicitly. Do not infer it
|
|
// from the caller name: previews and future workflow callers need the same cover.
|
|
for (const caller of ["Release", "Cloud readiness", "another caller"]) {
|
|
test(`release server, chat, and native lanes cover every server suite once under ${caller}`, () => {
|
|
const env = { GITHUB_WORKFLOW: caller };
|
|
const full = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", "1"], env);
|
|
const files = Array.from({ length: 10 }, (_, index) => dryRunJson([
|
|
"--mode", "general", "--group", "general-server-without-chat-or-native-runner",
|
|
"--shard-index", String(index), "--shard-count", "10",
|
|
], env)).flatMap((shard) => shard.selectedGeneralServerSuites);
|
|
const native = dryRunJson(["--mode", "general", "--group", "general-server-native-runner"], env);
|
|
const combined = [...files, chatSuitePath, ...native.selectedGeneralServerSuites];
|
|
assert.equal(new Set(combined).size, combined.length, "lanes must not overlap");
|
|
assert.deepEqual(combined.sort(), full.selectedGeneralServerSuites.sort());
|
|
});
|
|
}
|
|
|
|
test("the native-runner lane runs exactly the cargo-dependent suites", () => {
|
|
const lane = dryRunJson(["--mode", "general", "--group", "general-server-native-runner"]);
|
|
assert.deepEqual(lane.selectedGeneralServerSuites, [
|
|
"server/src/services/native-runtime/plugin-runner-probe.test.ts",
|
|
"server/src/services/native-runtime/native-codex-runner.integration.test.ts",
|
|
ingressRetrySuitePath,
|
|
dotRunnerSuitePath,
|
|
restartRecoverySuitePath,
|
|
...restartWaitSuites,
|
|
usageDeliverySuitePath,
|
|
warmCheckpointOwnershipSuitePath,
|
|
pluginWarmOwnerSuitePath,
|
|
]);
|
|
});
|
|
|
|
test("shard flags are rejected for the native-runner group", () => {
|
|
const result = dryRun(["--mode", "general", "--group", "general-server-native-runner", "--shard-index", "0", "--shard-count", "2"]);
|
|
assert.notEqual(result.status, 0, "the native-runner lane is a single suite and must not accept shard flags");
|
|
});
|
|
|
|
// The PR-side exclusion above is safe only while the wiring it assumes holds:
|
|
// pr.yml (the caller whose name reusable pr-trusted.yml jobs see as
|
|
// GITHUB_WORKFLOW) is named PR, the package vitest lanes cover every
|
|
// partition exactly once, exactly one lane runs the native-runner group, and
|
|
// those lanes' package script routes through the wrapper.
|
|
test("the PR workflow wiring for the native-runner lane holds", () => {
|
|
const prWorkflow = readFileSync(path.join(repoRoot, ".github/workflows/pr.yml"), "utf8");
|
|
assert.match(prWorkflow, /^name: PR$/m,
|
|
"renaming pr.yml silently moves the native-runner suite back into the uncached server shards");
|
|
|
|
const matrix = JSON.parse(readFileSync(path.join(repoRoot, ".github/ci-task-matrix.json"), "utf8"));
|
|
const vitestLanes = matrix.verify_paperclip_runner.filter((entry) => entry.command.startsWith("test:typescript:vitest"));
|
|
for (const entry of vitestLanes) {
|
|
assert.match(entry.command, /^test:typescript:vitest (--package-lane=\d+\/\d+|--native-server)$/, entry.lane_label);
|
|
assert.equal(entry.task, "ci:runner-vitest", entry.lane_label);
|
|
}
|
|
const lanes = vitestLanes
|
|
.map((entry) => /--package-lane=(\d+)\/(\d+)$/.exec(entry.command))
|
|
.filter(Boolean).map((match) => [Number(match[1]), Number(match[2])]);
|
|
assert.ok(lanes.length > 0, "expected package-lane test:typescript:vitest lanes in the task matrix");
|
|
assert.equal(new Set(lanes.map(([, count]) => count)).size, 1, "package lanes must agree on the lane count");
|
|
const laneCount = lanes[0][1];
|
|
assert.deepEqual(
|
|
lanes.map(([index]) => index).sort((left, right) => left - right),
|
|
Array.from({ length: laneCount }, (_, index) => index + 1),
|
|
"package lanes must cover every partition exactly once",
|
|
);
|
|
const native = vitestLanes.filter((entry) => entry.command.endsWith("--native-server"));
|
|
assert.equal(native.length, 1, "exactly one vitest lane carries the native-runner group");
|
|
assert.equal(native[0].release_crate, true, "the native-runner lane needs the release crate");
|
|
|
|
const runnerPackage = JSON.parse(
|
|
readFileSync(path.join(repoRoot, "packages/paperclip-runner/package.json"), "utf8"),
|
|
);
|
|
assert.equal(runnerPackage.scripts["test:typescript:vitest"], "node ./scripts/run-pr-vitest-lane.mjs");
|
|
});
|
|
|
|
const laneWrapper = path.join(repoRoot, "packages/paperclip-runner/scripts/run-pr-vitest-lane.mjs");
|
|
|
|
function wrapperPlan(args, envOverrides = {}) {
|
|
const result = spawnSync(process.execPath, [laneWrapper, ...args, "--dry-run"], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
env: workflowEnv(envOverrides),
|
|
});
|
|
assert.equal(result.status, 0, `expected wrapper dry run to succeed for ${args.join(" ")}: ${result.stderr}`);
|
|
return JSON.parse(result.stdout);
|
|
}
|
|
|
|
test("the PR lanes separate complete package and native workloads without changing local shards", () => {
|
|
for (const [args, envOverrides, packageArgs, native] of [
|
|
[["--shard=1/2"], { GITHUB_WORKFLOW: "PR" }, [], false],
|
|
[["--shard=2/2"], { GITHUB_WORKFLOW: "PR" }, null, true],
|
|
[[], { GITHUB_WORKFLOW: "PR" }, [], true],
|
|
[["--shard=2/3"], { GITHUB_WORKFLOW: "PR" }, ["--shard=2/3"], false],
|
|
[["--shard=3/3"], { GITHUB_WORKFLOW: "PR" }, ["--shard=3/3"], true],
|
|
[["--shard=2/2"], { GITHUB_WORKFLOW: "Release" }, ["--shard=2/2"], false],
|
|
[["--shard=2/2"], {}, ["--shard=2/2"], false],
|
|
[[], {}, [], false],
|
|
]) {
|
|
const plan = wrapperPlan(args, envOverrides);
|
|
assert.equal(plan.runNativeRunnerGroup, native);
|
|
assert.equal(plan.runPackageTests, packageArgs !== null);
|
|
const vitest = plan.plannedCommands.find(command => command.args[0] === "exec");
|
|
assert.deepEqual(vitest?.args ?? null, packageArgs === null ? null : ["exec", "vitest", "run", ...packageArgs]);
|
|
assert.equal(plan.plannedCommands.some(command => command.args.includes("general-server-native-runner")), native);
|
|
assert.ok(plan.plannedCommands.some(command => command.args.includes("build:rust")), "both workloads require debug fixtures");
|
|
}
|
|
|
|
const malformed = spawnSync(process.execPath, [laneWrapper, "--shard=nonsense", "--dry-run"], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
env: workflowEnv({ GITHUB_WORKFLOW: "PR" }),
|
|
});
|
|
assert.notEqual(malformed.status, 0, "a malformed shard flag must fail rather than guess a lane");
|
|
});
|
|
|
|
const runnerRoot = path.join(repoRoot, "packages/paperclip-runner");
|
|
const prepCommands = [
|
|
{ command: "pnpm", args: ["run", "ensure:eval-build-deps"], cwd: runnerRoot },
|
|
{ command: "pnpm", args: ["run", "build:rust"], cwd: runnerRoot },
|
|
];
|
|
const nativeCommand = {
|
|
command: "pnpm",
|
|
args: ["test:run:general", "--", "--group", "general-server-native-runner"],
|
|
cwd: repoRoot,
|
|
};
|
|
|
|
// Master's trusted workflow keeps sending the --shard commands until the
|
|
// package-lane matrix merges, so their exact plans must not move.
|
|
test("the master-pinned --shard lane commands plan exactly what they did before package lanes", () => {
|
|
assert.deepEqual(wrapperPlan(["--shard=1/2"], { GITHUB_WORKFLOW: "PR" }).plannedCommands, [
|
|
...prepCommands,
|
|
{ command: "pnpm", args: ["exec", "vitest", "run"], cwd: runnerRoot },
|
|
]);
|
|
assert.deepEqual(wrapperPlan(["--shard=2/2"], { GITHUB_WORKFLOW: "PR" }).plannedCommands, [...prepCommands, nativeCommand]);
|
|
for (const plan of [wrapperPlan(["--shard=1/2"], { GITHUB_WORKFLOW: "PR" }), wrapperPlan(["--shard=2/2"], { GITHUB_WORKFLOW: "PR" })]) {
|
|
assert.equal(plan.packageLane, undefined);
|
|
}
|
|
});
|
|
|
|
test("--native-server runs only the native group under any workflow", () => {
|
|
for (const envOverrides of [{ GITHUB_WORKFLOW: "PR" }, {}]) {
|
|
const plan = wrapperPlan(["--native-server"], envOverrides);
|
|
assert.deepEqual(plan.plannedCommands, [...prepCommands, nativeCommand]);
|
|
assert.equal(plan.runPackageTests, false);
|
|
assert.equal(plan.runNativeRunnerGroup, true);
|
|
}
|
|
});
|
|
|
|
test("the package lane manifest uses the general server duration schema", () => {
|
|
const manifest = JSON.parse(readFileSync(path.join(runnerRoot, "scripts/vitest-lane-durations.json"), "utf8"));
|
|
const general = JSON.parse(readFileSync(durationsManifest, "utf8"));
|
|
// The refresh script writes exactly these keys; hand-added annotations such
|
|
// as the server manifest's splitEstimates are optional.
|
|
assert.deepEqual(Object.keys(manifest), ["$comment", "unit", "sources", "coverage", "durations"]);
|
|
assert.ok(Object.keys(manifest).every((key) => key in general));
|
|
assert.equal(manifest.unit, "ms");
|
|
assert.deepEqual(Object.keys(manifest.coverage), Object.keys(general.coverage));
|
|
for (const source of manifest.sources) {
|
|
assert.deepEqual(Object.keys(source), ["run", "attempt", "sha", "url"]);
|
|
assert.match(source.sha, /^[a-f0-9]{40}$/);
|
|
}
|
|
const entries = Object.entries(manifest.durations);
|
|
assert.ok(entries.length > 100);
|
|
for (const [file, ms] of entries) {
|
|
assert.match(file, /^packages\/paperclip-runner\/.+\.test\.[cm]?[jt]sx?$/);
|
|
assert.ok(Number.isFinite(ms) && ms >= 0, file);
|
|
}
|
|
});
|
|
|
|
test("package lane arguments fail rather than guess a lane", () => {
|
|
for (const args of [["--package-lane=0/3"], ["--package-lane=4/3"], ["--package-lane=abc"], ["--package-lane", "1/3"],
|
|
["--package-lane=1/3", "--package-lane=2/3"], ["--package-lane=1/3", "--shard=1/2"], ["--native-server", "--shard=2/2"],
|
|
["--native-server", "--package-lane=1/3"], ["--native-server", "src/foo.test.ts"]]) {
|
|
const result = spawnSync(process.execPath, [laneWrapper, ...args, "--dry-run"], {
|
|
cwd: repoRoot, encoding: "utf8", env: workflowEnv({ GITHUB_WORKFLOW: "PR" }),
|
|
});
|
|
assert.notEqual(result.status, 0, args.join(" "));
|
|
}
|
|
});
|
|
|
|
const lineShardFile = path.join(repoRoot, "server/src/__tests__/chat-channels.integration.test.ts");
|
|
const caseAt = (line, name) => ({ name, file: lineShardFile, projectName: "@paperclipai/server", location: { line, column: 3 } });
|
|
|
|
test("test-line shards cover nested and parameterized cases exactly once without splitting a source line", () => {
|
|
const cases = [caseAt(10, "suite > nested > first"), caseAt(10, "suite > nested > second"),
|
|
caseAt(20, "same name"), caseAt(30, "same name"), caseAt(40, "last"), caseAt(50, "new case")];
|
|
const shards = partitionTestLines(cases, 3, lineShardFile);
|
|
assert.deepEqual(shards.map((shard) => shard.tests.length), [2, 2, 2]);
|
|
assert.equal(shards.filter((shard) => shard.lines.includes(10)).length, 1);
|
|
assert.equal(shards.find((shard) => shard.lines.includes(10)).tests.length, 2);
|
|
assert.equal(shards.flatMap((shard) => shard.lines).length, 5);
|
|
assert.deepEqual(shards.flatMap((shard) => shard.tests).sort((a, b) => a.location.line - b.location.line), cases);
|
|
assert.deepEqual(partitionTestLines([...cases].reverse(), 3, lineShardFile).map((shard) => shard.lines), shards.map((shard) => shard.lines));
|
|
});
|
|
|
|
test("line-shard collection rejects empty, foreign, or unlocated tests and invalid shard counts", () => {
|
|
const good = caseAt(10, "valid");
|
|
for (const input of [[], null, [{ ...good, file: "/another.test.ts" }], [{ ...good, projectName: "wrong" }],
|
|
[{ ...good, location: undefined }], [{ ...good, location: { line: 0 } }], [{ ...good, name: "" }]]) {
|
|
assert.throws(() => partitionTestLines(input, 1, lineShardFile));
|
|
}
|
|
for (const count of [0, -1, 1.5, Infinity, 2]) assert.throws(() => partitionTestLines([good], count, lineShardFile));
|
|
});
|
|
|
|
test("filtered collection must match the exact assigned case identities, including duplicates", () => {
|
|
const expected = [caseAt(10, "same"), caseAt(10, "same"), caseAt(20, "nested > case")];
|
|
assertSelectedTests(expected, [...expected].reverse(), lineShardFile);
|
|
for (const actual of [expected.slice(1), [...expected, caseAt(30, "extra")],
|
|
[expected[0], expected[1], caseAt(20, "renamed")],
|
|
[expected[0], expected[1], caseAt(21, "nested > case")]]) {
|
|
assert.throws(() => assertSelectedTests(expected, actual, lineShardFile));
|
|
}
|
|
});
|
|
|
|
|
|
test("parallel chat jobs form a complete disjoint partition", () => {
|
|
for (const count of [2, 3, 4]) {
|
|
const shards = Array.from({ length: count }, (_, index) =>
|
|
dryRunJson(["--mode", "general", "--group", "general-chat", "--shard-index", String(index), "--shard-count", String(count)],
|
|
{ PAPERCLIP_TEST_DATABASE_URL: "" }).chatVitestShards,
|
|
);
|
|
assert.ok(shards.every((workers) => workers.length === 2));
|
|
assert.deepEqual(shards.flat().map(({ index }) => index).sort((a, b) => a - b),
|
|
Array.from({ length: count * 2 }, (_, index) => index));
|
|
assert.ok(shards.flat().every((shard) => shard.count === count * 2));
|
|
}
|
|
});
|
|
|
|
test("shared-database and unsharded chat runs retain one serial partition", () => {
|
|
for (let index = 0; index < 3; index++) {
|
|
const result = dryRunJson(["--mode", "general", "--group", "general-chat", "--shard-index", String(index), "--shard-count", "3"],
|
|
{ PAPERCLIP_TEST_DATABASE_URL: "postgresql://shared/test" });
|
|
assert.deepEqual(result.chatVitestShards, [{ index, count: 3 }]);
|
|
}
|
|
assert.deepEqual(dryRunJson(["--mode", "general", "--group", "general-chat"],
|
|
{ PAPERCLIP_TEST_DATABASE_URL: "" }).chatVitestShards, [{ index: 0, count: 1 }]);
|
|
});
|
|
|
|
test("default chat isolation covers six partitions without dropping native suites", () => {
|
|
for (const args of [[], ["--mode", "general"]]) {
|
|
for (const workflow of ["PR", "Release", ""]) {
|
|
const env = { GITHUB_WORKFLOW: workflow, PAPERCLIP_TEST_DATABASE_URL: "" };
|
|
const plan = dryRunJson(args, env);
|
|
const full = dryRunJson(["--mode", "general", "--group", "general-server",
|
|
"--shard-index", "0", "--shard-count", "1"], env);
|
|
assert.deepEqual(plan.chatVitestShards, Array.from({ length: 6 }, (_, index) => ({ index, count: 6 })));
|
|
assert.ok(!plan.selectedGeneralServerSuites.includes(chatSuitePath));
|
|
assert.equal(new Set(plan.selectedGeneralServerSuites).size, plan.selectedGeneralServerSuites.length);
|
|
assert.deepEqual([...plan.selectedGeneralServerSuites, chatSuitePath].sort(), full.selectedGeneralServerSuites.sort());
|
|
}
|
|
}
|
|
});
|
|
|
|
test("default chat isolation leaves explicit groups and shared databases unchanged", () => {
|
|
for (const args of [[], ["--mode", "general"]]) {
|
|
const shared = dryRunJson(args, { PAPERCLIP_TEST_DATABASE_URL: "postgresql://shared/test", GITHUB_WORKFLOW: "PR" });
|
|
assert.equal(shared.chatVitestShards, null);
|
|
assert.equal(shared.selectedGeneralServerSuites, null);
|
|
}
|
|
const full = dryRunJson(["--mode", "general", "--group", "general-server",
|
|
"--shard-index", "0", "--shard-count", "1"], { PAPERCLIP_TEST_DATABASE_URL: "", GITHUB_WORKFLOW: "PR" });
|
|
assert.equal(full.chatVitestShards, null);
|
|
assert.ok(full.selectedGeneralServerSuites.includes(chatSuitePath));
|
|
assert.ok(full.selectedGeneralServerSuites.includes(nativeRunnerSuitePath));
|
|
assert.deepEqual(dryRunJson(["--mode", "general", "--group", "general-chat"],
|
|
{ PAPERCLIP_TEST_DATABASE_URL: "" }).chatVitestShards, [{ index: 0, count: 1 }]);
|
|
assert.equal(dryRunJson(["--mode", "serialized"], { PAPERCLIP_TEST_DATABASE_URL: "" }).chatVitestShards, null);
|
|
});
|
|
|
|
function defaultLauncher({ shared = false, failureIndex = null } = {}) {
|
|
const directory = mkdtempSync(path.join(os.tmpdir(), "default-chat-launch-"));
|
|
const file = path.join(repoRoot, chatSuitePath);
|
|
const lines = readFileSync(file, "utf8").split("\n")
|
|
.flatMap((line, index) => /^\s*it\(/.test(line) ? [index + 1] : []).slice(0, 6);
|
|
assert.equal(lines.length, 6);
|
|
const cases = lines.map(line => ({ file, projectName: "@paperclipai/server",
|
|
name: `default launcher fixture ${line}`, location: { line } }));
|
|
const receipt = path.join(directory, "events.ndjson");
|
|
const active = path.join(directory, "active");
|
|
const fakePnpm = path.join(directory, "pnpm");
|
|
try {
|
|
writeFileSync(fakePnpm, `#!${process.execPath}
|
|
const fs = require('node:fs');
|
|
const args = process.argv.slice(2);
|
|
const tests = ${JSON.stringify(cases)};
|
|
const selected = args.flatMap(arg => {
|
|
const match = arg.match(/chat-channels\\.integration\\.test\\.ts:(\\d+)$/);
|
|
return match ? [Number(match[1])] : [];
|
|
});
|
|
if (args.includes('list')) {
|
|
const output = args.find(arg => arg.startsWith('--json=')).slice(7);
|
|
fs.writeFileSync(output, JSON.stringify(selected.length
|
|
? tests.filter(test => selected.includes(test.location.line)) : tests));
|
|
} else {
|
|
const entry = { args, selected, home: process.env.PAPERCLIP_HOME,
|
|
config: process.env.PAPERCLIP_CONFIG, instance: process.env.PAPERCLIP_INSTANCE_ID,
|
|
tmpdir: process.env.TMPDIR };
|
|
const record = edge => fs.appendFileSync(${JSON.stringify(receipt)}, JSON.stringify({ ...entry, edge }) + '\\n');
|
|
record('begin');
|
|
if (fs.existsSync(${JSON.stringify(active)})) process.exit(91);
|
|
fs.writeFileSync(${JSON.stringify(active)}, 'running');
|
|
setTimeout(() => {
|
|
record('end');
|
|
fs.unlinkSync(${JSON.stringify(active)});
|
|
process.exit(selected.includes(${JSON.stringify(failureIndex === null ? null : lines[failureIndex])}) ? 7 : 0);
|
|
}, selected.length ? 200 : 0);
|
|
}
|
|
`);
|
|
chmodSync(fakePnpm, 0o755);
|
|
const result = spawnSync(process.execPath, [script, "--mode", "general"], {
|
|
cwd: repoRoot, encoding: "utf8", timeout: 30_000,
|
|
env: workflowEnv({ GITHUB_WORKFLOW: "PR", PATH: `${directory}${path.delimiter}${process.env.PATH}`,
|
|
PAPERCLIP_TEST_DATABASE_URL: shared ? "postgresql://shared/test" : "", PAPERCLIP_CI_PROFILE: "false" }),
|
|
});
|
|
const events = existsSync(receipt) ? readFileSync(receipt, "utf8").trim().split("\n").map(line => JSON.parse(line)) : [];
|
|
return { result, events, lines };
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
test("default chat isolation executes every partition sequentially before other groups", { skip: process.platform === "win32" }, () => {
|
|
const { result, events, lines } = defaultLauncher();
|
|
assert.equal(result.status, 0, result.stdout + result.stderr);
|
|
const chat = events.filter(event => event.selected.length > 0);
|
|
assert.deepEqual(chat.map(event => event.edge), Array.from({ length: 6 }, () => ["begin", "end"]).flat());
|
|
const starts = chat.filter(event => event.edge === "begin");
|
|
assert.deepEqual(starts.flatMap(event => event.selected).sort((a, b) => a - b), lines);
|
|
for (const event of starts) {
|
|
assert.ok(event.args.includes("--no-file-parallelism"));
|
|
assert.deepEqual(event.args.filter(arg => arg.startsWith("--maxWorkers")), ["--maxWorkers=1"]);
|
|
}
|
|
for (const field of ["home", "config", "instance", "tmpdir"]) {
|
|
assert.equal(new Set(starts.map(event => event[field])).size, 6, `${field} must be isolated per child`);
|
|
}
|
|
assert.equal(events.findIndex(event => event.selected.length === 0), 12);
|
|
const server = events[12];
|
|
assert.ok(server.args.includes("@paperclipai/server"));
|
|
const excluded = server.args.flatMap((arg, index) => arg === "--exclude" ? [server.args[index + 1]] : []);
|
|
assert.equal(excluded.filter(file => file === chatSuitePath.replace(/^server\//, "")).length, 1);
|
|
for (const file of [nativeRunnerSuitePath, ingressRetrySuitePath, usageDeliverySuitePath, dotRunnerSuitePath, restartRecoverySuitePath,
|
|
"server/src/services/native-runtime/plugin-runner-probe.test.ts"]) {
|
|
assert.ok(!excluded.includes(file.replace(/^server\//, "")), `default must retain ${file}`);
|
|
}
|
|
});
|
|
|
|
test("default chat isolation propagates failure without starting another partition or group", { skip: process.platform === "win32" }, () => {
|
|
const { result, events } = defaultLauncher({ failureIndex: 2 });
|
|
assert.equal(result.status, 1, result.stdout + result.stderr);
|
|
assert.match(result.stderr, /failed \(7\)/);
|
|
assert.equal(events.length, 6);
|
|
assert.ok(events.every(event => event.selected.length === 1));
|
|
assert.deepEqual(events.map(event => event.edge), ["begin", "end", "begin", "end", "begin", "end"]);
|
|
});
|
|
|
|
test("default chat isolation keeps shared-database execution monolithic and serial", { skip: process.platform === "win32" }, () => {
|
|
const { result, events } = defaultLauncher({ shared: true });
|
|
assert.equal(result.status, 0, result.stdout + result.stderr);
|
|
assert.ok(events.every(event => event.selected.length === 0));
|
|
assert.ok(events[0].args.includes("@paperclipai/server"));
|
|
assert.ok(events[0].args.includes("--no-file-parallelism"));
|
|
assert.deepEqual(events[0].args.filter(arg => arg.startsWith("--maxWorkers")), ["--maxWorkers=1"]);
|
|
const excluded = events[0].args.flatMap((arg, index) => arg === "--exclude" ? [events[0].args[index + 1]] : []);
|
|
assert.ok(!excluded.includes(chatSuitePath.replace(/^server\//, "")));
|
|
assert.ok(!excluded.includes(nativeRunnerSuitePath.replace(/^server\//, "")));
|
|
assert.deepEqual(events.map(event => event.edge), Array.from({ length: events.length / 2 }, () => ["begin", "end"]).flat());
|
|
});
|
|
|
|
|
|
test("a failed chat child fails the job after its sibling finishes", { skip: process.platform === "win32" }, () => {
|
|
const directory = mkdtempSync(path.join(os.tmpdir(), "chat-launch-proof-"));
|
|
const file = path.join(repoRoot, "server/src/__tests__/chat-channels.integration.test.ts");
|
|
const lines = readFileSync(file, "utf8").split("\n")
|
|
.flatMap((line, index) => /^\s*it\(/.test(line) ? [index + 1] : []).slice(0, 6);
|
|
assert.equal(lines.length, 6);
|
|
const tests = lines.map((line) => ({ file, projectName: "@paperclipai/server",
|
|
name: `launcher fixture ${line}`, location: { line } }));
|
|
const receipt = path.join(directory, "sibling-finished");
|
|
const fakePnpm = path.join(directory, "pnpm");
|
|
try {
|
|
writeFileSync(fakePnpm, `#!${process.execPath}
|
|
const fs = require('node:fs');
|
|
const args = process.argv.slice(2);
|
|
const tests = ${JSON.stringify(tests)};
|
|
const selected = args.flatMap(arg => {
|
|
const match = arg.match(/chat-channels\\.integration\\.test\\.ts:(\\d+)$/);
|
|
return match ? [Number(match[1])] : [];
|
|
});
|
|
if (args.includes('list')) {
|
|
const output = args.find(arg => arg.startsWith('--json=')).slice(7);
|
|
fs.writeFileSync(output, JSON.stringify(selected.length
|
|
? tests.filter(test => selected.includes(test.location.line)) : tests));
|
|
} else if (selected.includes(${lines[0]})) process.exit(7);
|
|
else setTimeout(() => fs.writeFileSync(${JSON.stringify(receipt)}, 'finished'), 200);
|
|
`);
|
|
chmodSync(fakePnpm, 0o755);
|
|
const result = spawnSync(process.execPath, [script, "--mode", "general", "--group", "general-chat",
|
|
"--shard-index", "0", "--shard-count", "3"], {
|
|
cwd: repoRoot, encoding: "utf8", timeout: 30_000,
|
|
env: { ...process.env, PATH: `${directory}${path.delimiter}${process.env.PATH}`,
|
|
PAPERCLIP_TEST_DATABASE_URL: "", PAPERCLIP_CI_PROFILE: "false" },
|
|
});
|
|
assert.equal(result.status, 1, result.stdout + result.stderr);
|
|
assert.match(result.stderr, /failed \(7\)/);
|
|
assert.equal(readFileSync(receipt, "utf8"), "finished", "a failed worker must not abandon its sibling");
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
for (const [group, parallelWorkers, shardArgs] of [
|
|
["general-server-native-runner", 4, []],
|
|
["general-server-without-chat-or-native-runner", 3, ["--shard-index", "0", "--shard-count", "12"]],
|
|
]) test(`${group} files overlap only when their database fixtures are isolated`, { skip: process.platform === "win32" }, () => {
|
|
const directory = mkdtempSync(path.join(os.tmpdir(), "native-file-workers-"));
|
|
const receipt = path.join(directory, "args.json");
|
|
const fakePnpm = path.join(directory, "pnpm");
|
|
writeFileSync(fakePnpm, `#!/usr/bin/env node\nrequire('node:fs').writeFileSync(${JSON.stringify(receipt)}, JSON.stringify(process.argv.slice(2)));\n`);
|
|
chmodSync(fakePnpm, 0o755);
|
|
try {
|
|
for (const [databaseEnv, workers] of [
|
|
[{}, parallelWorkers],
|
|
[{ PAPERCLIP_TEST_DATABASE_URL: "postgresql://shared/test" }, 1],
|
|
[{ PAPERCLIP_ISSUE_RECOVERY_TEST_DATABASE_URL: "postgresql://shared/recovery_test" }, 1],
|
|
[{ PAPERCLIP_TEST_POSTGRES_ADMIN_URL: "postgresql://owned/postgres" }, parallelWorkers],
|
|
]) {
|
|
const env = workflowEnv({ PATH: `${directory}${path.delimiter}${process.env.PATH}`, PAPERCLIP_CI_PROFILE: "false" });
|
|
for (const key of Object.keys(env)) if (key.endsWith("_TEST_DATABASE_URL")) delete env[key];
|
|
Object.assign(env, databaseEnv);
|
|
const result = spawnSync(process.execPath, [script, "--mode", "general", "--group", group, ...shardArgs], {
|
|
cwd: repoRoot, encoding: "utf8", env,
|
|
});
|
|
assert.equal(result.status, 0, result.stdout + result.stderr);
|
|
const args = JSON.parse(readFileSync(receipt, "utf8"));
|
|
assert.ok(args.includes(`--maxWorkers=${workers}`));
|
|
assert.ok(args.includes(workers > 1 ? "--fileParallelism" : "--no-file-parallelism"));
|
|
if (workers > 1) assert.ok(args.includes("--pool=forks") && args.includes("--isolate"));
|
|
if (group === "general-server-native-runner") {
|
|
for (const file of [restartRecoverySuitePath, ...restartWaitSuites]) assert.ok(args.includes(file));
|
|
}
|
|
}
|
|
} finally { rmSync(directory, { recursive: true, force: true }); }
|
|
});
|