mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Release verification must run the same server integration coverage as PR verification. > - Three server suites use real Rust Runner binaries. > - PR checks already run them with the shared Rust dependency cache, but release checks cold-build them inside ordinary server test shards. > - A cold Dot Runner build can consume its entire setup deadline before any test runs. > - This pull request gives release verification a required cached native integration lane and preserves every test. ## Linked Issues or Issue Description **What happened?** On master commit `1881894973a2b25838d8abed9bd8aeebc3af4441`, [Cloud readiness run 37837810707](https://github.com/paperclipai/paperclip/actions/runs/37837810707) failed in server shard 7. Dot Runner's `cargo build --release` exceeded its 300-second child-process deadline. The other 1,640 tests in that shard passed. The failed setup then tried to remove an undefined temporary path and emitted a second error. Cargo output was captured as an opaque buffer, which obscured build progress. **What did you expect to happen?** Build fixture binaries before tests in a lane with the existing Rust dependency cache. Run all native integration tests and make their result required for release readiness. A setup failure should keep its original diagnostic. **Steps to reproduce** Run release verification on a clean runner. The existing `general-server-without-chat` group retains the three Cargo-backed suites outside the PR workflow. The Dot suite can time out during a cold release build. Run the new workflow and partition tests against the previous source to reproduce five routing and coverage failures deterministically. **Version / commit** Observed at `1881894973a2b25838d8abed9bd8aeebc3af4441`; this change is based on `ed6abbf158b`. **Deployment mode** GitHub Actions Release and Cloud readiness verification. No application runtime or deployment action changes. Related work: #15581 also edits Dot integration tests for onboarding behavior. It does not repair release test routing. Searches found no open PR for this failure. ## What Changed - Add an explicit server test group that excludes the dedicated chat and native suites. Preserve the existing PR and local test groups. - Run all three native server suites in one required matrix lane with the existing trusted Rust dependency cache. - Build debug and release fixture binaries in a visible step with a 10-minute limit before tests start. Keep Cargo freshness checks and the existing test deadlines. - Stream Cargo diagnostics and clean up safely when Dot setup stops before creating its temporary directory. - Test complete, non-overlapping partitions for Release, Cloud readiness, other callers, and existing PR/local groups. Check cache restrictions, build order, and the source verification dependency. ## Verification - Passed 44 workflow and partition tests: `node --test scripts/__tests__/run-vitest-stable-shard.test.mjs scripts/__tests__/release-verify-workflow.test.mjs`. - The same tests fail in five relevant cases against the previous workflow and selector. They pass after this correction. - An independent review repeated all 44 tests successfully. - Passed `actionlint .github/workflows/release-verify.yml`, `git diff --check`, and a secret scan. - Passed all 381 workflow policy tests: `node --test '.github/scripts/tests/*.test.mjs'`. - Passed full `pnpm build` in a clean worktree. - Built debug and release fixture binaries, then passed all 32 tests in the three real native integration suites: `pnpm test:run:general -- --group general-server-native-runner` (49.5 seconds, no skips). - Passed full `pnpm -r typecheck`. - Injected a synthetic Cargo setup failure. The suite reports that failure without the secondary undefined-path cleanup error. - Exact-head CI completed: 53 successful checks, including all server shards, both native Runner lanes, build, typecheck, browser tests, and the canary dry run. Two optional Storybook checks were skipped. - Started the duplicate local `pnpm test:run` aggregate and stopped it after exact-head CI passed. No completed local aggregate result is claimed. All test processes owned by that run exited. - Greptile scored the final head `ffe5ab5a28af2dfea9db1c1952c652f070bf52f8` at 5/5 with no review threads. - `Superagent Supply Chain Scan` is neutral, not passed: it only supports exact dependency pin replacements and cannot verify structural edits to `.github/workflows/release-verify.yml`. It reported no annotations. Independent source review, Greptile, actionlint, and the workflow policy tests cover this structural change. - GitHub still requires a code-owner review for the workflow files. There are no merge conflicts. ## Risks - Adds one CI matrix job, which increases concurrent runner demand. The existing cache writer and trust restrictions stay in place. - Missing dependencies still compile from the lockfile. A build that exceeds its step limit fails visibly; failed tests still block source verification. - Workflow execution uses the new lane after merge. PR tests validate the workflow contract and run the existing native test lane. - The supply chain scanner cannot analyze this workflow structure. Its neutral result is an explicit coverage limit; it is not counted as a successful scan. - No runtime, schema, deployment, publication, credential, or test-timeout changes. ## Model Used OpenAI Codex, based on GPT-6, with repository inspection, code execution, and independent agent review. The exact deployed model ID and context-window size are not exposed in this session. ## 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
476 lines
24 KiB
JavaScript
476 lines
24 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { readFileSync } from "node:fs";
|
|
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 serialized shards form a complete, non-overlapping partition", () => {
|
|
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");
|
|
});
|
|
|
|
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(!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("a route/authz suite never leaks into the general-server shards", () => {
|
|
const shard = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", SHARD_COUNT.toString()]);
|
|
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}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
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}`);
|
|
});
|
|
|
|
test("shard flags are rejected for the workspaces-b group", () => {
|
|
const result = dryRun(["--mode", "general", "--group", "general-workspaces-b", "--shard-index", "0", "--shard-count", "3"]);
|
|
assert.notEqual(result.status, 0, "workspaces-b 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"]
|
|
.flatMap(group => dryRunJson(["--mode", "general", "--group", group]).workspaceProjects).sort();
|
|
assert.deepEqual(actual, expected, "no configured project may be silently omitted or run twice");
|
|
});
|
|
|
|
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 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 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(", ")}`,
|
|
);
|
|
});
|
|
|
|
test("the real shard partition is duration-balanced", () => {
|
|
// Mirrors the PR matrix: general-server-without-chat across SHARD_COUNT
|
|
// runners, with the chat suite carried by the dedicated general-chat lanes
|
|
// and the native-runner suite by the Rust-cached PR vitest lane.
|
|
const durations = loadShardDurations(durationsManifest);
|
|
const fallback = defaultSuiteWeight(durations);
|
|
const shards = Array.from({ length: SHARD_COUNT }, (_, index) =>
|
|
dryRunJson(
|
|
["--mode", "general", "--group", "general-server-without-chat", "--shard-index", String(index), "--shard-count", String(SHARD_COUNT)],
|
|
{ GITHUB_WORKFLOW: "PR" },
|
|
),
|
|
);
|
|
|
|
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 chatSuitePath = "server/src/__tests__/chat-channels.integration.test.ts";
|
|
const nativeRunnerSuitePath =
|
|
"server/src/services/native-runtime/native-codex-runner.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";
|
|
|
|
// 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 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 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.deepEqual([...files, chatSuitePath, nativeRunnerSuitePath, dotRunnerSuitePath, restartRecoverySuitePath].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.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/native-codex-runner.integration.test.ts",
|
|
dotRunnerSuitePath,
|
|
restartRecoverySuitePath,
|
|
]);
|
|
});
|
|
|
|
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 sharded vitest lanes partition cleanly
|
|
// with exactly one final shard, and that lane's package script routes through
|
|
// the wrapper that runs the native-runner group.
|
|
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 trustedWorkflow = readFileSync(path.join(repoRoot, ".github/workflows/pr-trusted.yml"), "utf8");
|
|
const lanes = [...trustedWorkflow.matchAll(/command: test:typescript:vitest --shard=(\d+)\/(\d+)/g)]
|
|
.map((match) => [Number(match[1]), Number(match[2])]);
|
|
assert.ok(lanes.length > 0, "expected sharded test:typescript:vitest lanes in pr-trusted.yml");
|
|
assert.equal(new Set(lanes.map(([, count]) => count)).size, 1, "vitest lanes must agree on the shard count");
|
|
const shardCount = lanes[0][1];
|
|
assert.deepEqual(
|
|
lanes.map(([index]) => index).sort((left, right) => left - right),
|
|
Array.from({ length: shardCount }, (_, index) => index + 1),
|
|
"vitest lanes must cover every shard exactly once",
|
|
);
|
|
assert.equal(lanes.filter(([index, count]) => index === count).length, 1,
|
|
"exactly one final vitest shard carries the native-runner group");
|
|
|
|
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 vitest lane wrapper runs the native-runner group exactly on the final PR shard", () => {
|
|
for (const [args, envOverrides, expected] of [
|
|
[["--shard=1/2"], { GITHUB_WORKFLOW: "PR" }, false],
|
|
[["--shard=2/2"], { GITHUB_WORKFLOW: "PR" }, true],
|
|
[[], { GITHUB_WORKFLOW: "PR" }, true],
|
|
[["--shard=2/2"], { GITHUB_WORKFLOW: "Release" }, false],
|
|
[["--shard=2/2"], {}, false],
|
|
[[], {}, false],
|
|
]) {
|
|
const plan = wrapperPlan(args, envOverrides);
|
|
assert.equal(plan.runNativeRunnerGroup, expected,
|
|
`args ${JSON.stringify(args)} env ${JSON.stringify(envOverrides)}`);
|
|
const commands = plan.plannedCommands.map((planned) => planned.args.join(" "));
|
|
assert.ok(commands.some((command) => command.startsWith(`exec vitest run${args.length ? ` ${args.join(" ")}` : ""}`)),
|
|
"the wrapper must pass shard flags through to vitest");
|
|
assert.equal(
|
|
commands.some((command) => command.includes("--group general-server-native-runner")),
|
|
expected,
|
|
);
|
|
}
|
|
|
|
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 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));
|
|
}
|
|
});
|