mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Sandbox provider plugins (Daytona, E2B, Cloudflare, Kubernetes, Modal, and others) run agents in remote sandboxes > - The providers are outside the root pnpm workspace, so `pnpm test:run` and the CI test lanes never run their Vitest suites > - paperclipai/paperclip#18839 typechecks those tests, but a test that fails at runtime still fails nothing > - The providers have about 1,040 tests, and they finish in about 15 seconds on Linux > - This pull request adds a required `Sandbox provider tests` job that runs them when a change can affect them > - The benefit is that a broken provider, plugin SDK, or shared contract fails CI on the pull request instead of in a release or a paid end-to-end run ## Linked Issues or Issue Description Refs #18839 **What happened?** No CI job runs the sandbox provider tests. `runner-full-stack-e2e.yml` builds the Daytona plugin for a paid nightly run, but it does not run any provider's `pnpm test`. **Expected behavior** A pull request that can break a provider runs that provider's tests, and a failure blocks `ci / verify`. **Steps to reproduce** Add `expect(1).toBe(2)` to a test in `packages/plugins/sandbox-providers/e2b/src`. Open a pull request. CI stays green. ## What Changed - Added `scripts/test-sandbox-providers.mjs` and its tests. The script installs the providers through `typecheckTestPrograms` with no programs, because that function already owns the provider install and the plugin SDK link. Then it runs each provider's `pnpm test`, four at a time, and prints each provider's output as one block. It fails if a suite fails or if no provider has a test script. - The script skips `cloudflare/bridge-template`, because Cloudflare's Vitest config already runs those tests. - Added the `sandbox_provider_tests` job to `.github/workflows/pr-trusted.yml`, and made `ci / verify` require its result. - Added the `//#ci:sandbox-providers` task to `turbo.json` and its lane to `.github/ci-task-matrix.json`. The CI planner runs the job only when a change touches `packages/plugins/sandbox-providers/**`, `packages/plugins/sdk/**`, or `packages/shared/**`. `packages/shared` is the plugin SDK's only workspace dependency. Global CI inputs, such as `scripts/**`, `.github/**`, `pnpm-lock.yaml`, or any `package.json`, still select every job. - The job turns off core dumps. A Daytona capacity test kills a process at a file-size limit inside the exported directory. If core dumps are on, the core file joins the export, and the test sees 12 files instead of 11. - Updated the job lists in `scripts/__tests__/e2e-shard.test.mjs` and the job count in `.github/scripts/tests/ci-profile.test.mjs`. Added planner tests to `.github/scripts/tests/ci-plan.test.mjs`. One of these tests fails if the plugin SDK gets a new workspace dependency that is not in the job's inputs. - Added the new test file to `test:release-registry`, and documented the script in `doc/DEVELOPING.md`. ## Verification - `node scripts/test-sandbox-providers.mjs` on macOS: 8 of 9 suites pass in 31.6 s, including the install. Kubernetes fails 12 `file-sync` tests, because macOS has no `/proc/self/fd`. Some Daytona tests skip, because they need GNU `tar` and coreutils. - The same script in a Linux container (`node:24`, a non-root user, core dumps off): all 9 suites pass, with 1,012 tests passed and 26 skipped. The script took 15 s after the install. - Kubernetes: 205 passed. - Daytona: 590 passed and 16 skipped. - With core dumps on, Daytona's `preserve literal capacity repair` test fails. The export includes a `core` file. With `--ulimit core=0`, it passes. - To look for timing failures under load, I ran the whole script 12 more times in the container, limited to 2 CPUs (8 runs) and 1 CPU (4 runs). All 12 runs passed. They took 45 to 76 s on 2 CPUs and 73 to 102 s on 1 CPU. - `ubuntu-latest` has `zstd`, and my container did not, so some Daytona tests skipped in those runs. With `zstd` installed, Daytona passed with 598 tests and 8 skipped, and only the live tests skipped. One earlier `zstd` run failed while the stress runs used the same CPUs, and I did not capture its output. The next run passed. - `node --test` on every test that reads `pr-trusted.yml`, the CI planner, or the task matrix: 816 passed. These include `.github/scripts/tests/*.test.mjs`, `scripts/__tests__/e2e-shard.test.mjs`, `scripts/ci/test-selection-workflow.test.mjs`, and the new tests. - `pnpm run test:release-registry` on macOS: 251 of 255 pass, including the new tests. The 4 failures are `wait_for_npm_package_versions` tests in `scripts/release-lib.test.mjs`. They fail the same way on master on the same machine, and CI runs this suite on Linux. - `actionlint` 1.7.7 reports no errors on the changed workflow. - I called `planTasks` from `.github/scripts/ci-plan.mjs` with one changed file at a time. `server/src/index.ts` and `ui/src/App.tsx` leave `sandbox_provider_tests` off. `packages/plugins/sandbox-providers/e2b/src/plugin.ts` and `packages/shared/src/index.ts` turn it on. This pull request cannot run its own new job. `pr.yml` calls `pr-trusted.yml@master`, so the job first runs on pull requests after this one merges. After merge, check the first pull request that changes a provider or `packages/shared`. ## Risks - **First real run is after merge.** I measured the time on Linux in Docker, not on a CI runner. CI runners are about 2 times slower than my machine, so I expect about 2 to 3 minutes for the job, including the dependency restore. The job runs in parallel with the other jobs, and the slowest required job takes about 13 minutes. So the job should not make CI slower. - **Open provider pull requests.** After this merges, open pull requests that change a provider run its tests. If those tests fail, `ci / verify` blocks them until they are fixed. - **`packages/shared` changes often.** Any change there runs this job. A Linux-only provider failure would block those pull requests. All suites pass on Linux today. - **One unexplained Daytona failure.** One Daytona run with `zstd` failed under CPU contention, and I did not capture its output. Some Daytona tests have short deadlines. If the job is flaky after merge, quarantine the failing test or revert the job, and do not leave `ci / verify` red. - **Path rules can be too narrow.** If a provider starts to import another workspace package, a change to that package does not run this job. The plugin SDK dependency test covers the SDK. It does not cover a provider that imports a workspace package directly. Today no provider does. - **Overlap with paperclipai/paperclip#19489 and paperclipai/paperclip#19522.** Both pull requests change the provider install in `scripts/typecheck-test-programs.mjs`. This pull request does not edit that file. It calls the same install function, so it uses their install changes when they merge. All three pull requests edit the same section of `doc/DEVELOPING.md`, so a small text conflict is possible. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), in Claude Code with tool use (shell, file edits, GitHub CLI, Docker). Extended reasoning was on. ## 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>
330 lines
20 KiB
JavaScript
330 lines
20 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import { comparisonPaths, planTasks } from "../ci-plan.mjs";
|
|
import { mergeDiff } from "../ci-diff.mjs";
|
|
|
|
const root = new URL("../../../", import.meta.url);
|
|
const config = JSON.parse(readFileSync(new URL("turbo.json", root), "utf8"));
|
|
const lanes = JSON.parse(readFileSync(new URL(".github/ci-task-matrix.json", root), "utf8"));
|
|
const plan = (paths, options = {}) => planTasks({ config, lanes, paths, ...options });
|
|
const selected = (result, job) => result.jobs[job].run;
|
|
const selectedTasks = (result) => new Set(Object.values(result.jobs).filter((job) => job.run)
|
|
.flatMap((job) => job.matrix.include.map((entry) => entry.task)));
|
|
|
|
test("trusted workflow fallbacks preserve every declared partition and command", () => {
|
|
const workflow = readFileSync(new URL(".github/workflows/pr-trusted.yml", root), "utf8");
|
|
for (const [job, entries] of Object.entries(lanes)) {
|
|
const block = workflow.split(`\n ${job}:\n`)[1].split(/\n [a-z0-9_]+:\n/)[0];
|
|
const normalized = block.replaceAll("(needs.test_selection.outputs.plan || needs.gate.outputs.plan)", "needs.gate.outputs.plan");
|
|
assert.ok(normalized.includes(`fromJSON(needs.gate.outputs.plan).jobs.${job}.run`), job);
|
|
if (!block.includes(" matrix:")) { assert.equal(entries.length, 1); continue; }
|
|
const fallback = block.match(/matrix:.*\|\| '(.*)'\) \}\}/)?.[1];
|
|
assert.ok(fallback, `${job}: missing full fallback`);
|
|
assert.deepEqual(JSON.parse(fallback), { include: entries }, job);
|
|
}
|
|
});
|
|
|
|
test("a UI component retains browser and build coverage without unrelated server/Rust jobs", () => {
|
|
const result = plan(["ui/src/components/Sidebar.tsx"]);
|
|
assert.equal(selected(result, "build"), true);
|
|
assert.equal(selected(result, "e2e_shards"), true);
|
|
assert.equal(selected(result, "verify_serialized_server"), false);
|
|
assert.deepEqual([...selectedTasks(result)].filter((task) => /server|chat|runner/.test(task)), []);
|
|
assert.equal(result.jobs.general_tests.selected, 2);
|
|
});
|
|
|
|
test("source inspected across package boundaries selects its consumers", () => {
|
|
for (const file of ["ui/src/lib/issue-execution-policy.ts", "ui/src/index.css", "ui/src/pages/CompanyExport.tsx",
|
|
"ui/src/components/MarkdownBody.tsx"]) {
|
|
const tasks = selectedTasks(plan([file]));
|
|
assert.ok(tasks.has("ci:server"), file);
|
|
assert.ok(tasks.has("ci:runner-vitest"), file);
|
|
}
|
|
assert.ok(selectedTasks(plan(["ui/public/app-logos/example.svg"])).has("ci:workspaces-b"));
|
|
});
|
|
|
|
test("repository-wide guidance guards run uncached for every scanned UI file type", () => {
|
|
const source = readFileSync(new URL("server/src/__tests__/cli-invocation-safety.test.ts", root), "utf8");
|
|
const extensions = [...source.split("const SCAN_EXTENSIONS = new Set([")[1].split("]);")[0].matchAll(/"(\.[a-z]+)"/g)]
|
|
.map((match) => match[1]);
|
|
assert.ok(extensions.length > 0);
|
|
for (const extension of extensions) {
|
|
for (const folder of ["ui/src/components", "ui/src/pages", "ui/src/hooks", "ui/public"]) {
|
|
assert.ok(plan([`${folder}/guidance${extension}`]).jobs.typecheck_release_registry.run);
|
|
}
|
|
}
|
|
assert.equal(config.tasks["//#ci:typecheck"].cache, false);
|
|
const workflow = readFileSync(new URL(".github/workflows/pr-trusted.yml", root), "utf8");
|
|
const job = workflow.split("\n typecheck_release_registry:\n")[1].split("\n general_tests:\n")[0];
|
|
assert.match(job, /- name: Verify repository-wide CLI guidance safety\n run: pnpm exec vitest run --project @paperclipai\/server server\/src\/__tests__\/cli-invocation-safety.test.ts/);
|
|
assert.ok(!job.includes("uses: ./.github/actions/run-ci-task"));
|
|
});
|
|
|
|
test("shared contracts, runtime prompts, protocol inputs and fixtures reach consumers", () => {
|
|
const shared = selectedTasks(plan(["packages/shared/src/types/issue.ts"]));
|
|
for (const task of ["ci:server", "ci:db", "ci:workspaces-a", "ci:workspaces-b", "ci:serialized", "ci:e2e"]) {
|
|
assert.ok(shared.has(task), task);
|
|
}
|
|
assert.ok(selectedTasks(plan(["server/src/onboarding-assets/default/AGENTS.md"])).has("ci:runner-vitest"));
|
|
assert.ok(selectedTasks(plan(["packages/paperclip-runner/protocol/schema.json"])).has("ci:runner-rust"));
|
|
for (const file of ["tests/runner-e2e/fixtures/prompt.md", "scripts/run-vitest-stable.mjs", "pnpm-lock.yaml"]) {
|
|
assert.ok(Object.values(plan([file]).jobs).every((job) => job.selected === job.total), file);
|
|
}
|
|
});
|
|
|
|
test("sandbox provider tests run only for the providers, the plugin SDK, and what the SDK imports", () => {
|
|
for (const file of ["packages/plugins/sandbox-providers/daytona/src/plugin.ts",
|
|
"packages/plugins/sandbox-providers/cloudflare/bridge-template/src/index.ts",
|
|
"packages/plugins/sdk/src/index.ts", "packages/shared/src/types/issue.ts"]) {
|
|
assert.equal(selected(plan([file]), "sandbox_provider_tests"), true, file);
|
|
}
|
|
for (const file of ["server/src/index.ts", "ui/src/components/Sidebar.tsx", "cli/src/index.ts",
|
|
"packages/paperclip-runner/src/index.ts", "packages/plugins/plugin-workspace-diff/src/worker.ts"]) {
|
|
assert.equal(selected(plan([file]), "sandbox_provider_tests"), false, file);
|
|
}
|
|
// The provider lockfiles, the shared install workspace and the runner script are global inputs.
|
|
for (const file of ["scripts/typecheck-sandbox-providers/pnpm-lock.yaml", "scripts/test-sandbox-providers.mjs",
|
|
"packages/plugins/sandbox-providers/daytona/package.json"]) {
|
|
assert.equal(selected(plan([file]), "sandbox_provider_tests"), true, file);
|
|
}
|
|
// The SDK depends on shared and nothing else in the workspace. A new dependency must join the inputs.
|
|
const sdk = JSON.parse(readFileSync(new URL("packages/plugins/sdk/package.json", root), "utf8"));
|
|
const workspace = Object.entries({ ...sdk.dependencies, ...sdk.peerDependencies })
|
|
.filter(([, version]) => String(version).startsWith("workspace:")).map(([name]) => name);
|
|
assert.deepEqual(workspace, ["@paperclipai/shared"]);
|
|
const shared = JSON.parse(readFileSync(new URL("packages/shared/package.json", root), "utf8"));
|
|
assert.deepEqual(Object.values({ ...shared.dependencies, ...shared.peerDependencies })
|
|
.filter((version) => String(version).startsWith("workspace:")), []);
|
|
});
|
|
|
|
test("renames out of source and deletions cannot masquerade as release notes", () => {
|
|
const paths = comparisonPaths({ files: [
|
|
{ filename: "releases/v2026.1029.0.md", previous_filename: "packages/shared/src/constants.ts", status: "renamed" },
|
|
{ filename: "server/src/deleted.ts", status: "removed" },
|
|
] });
|
|
assert.ok(selectedTasks(plan(paths)).has("ci:server"));
|
|
assert.ok(selectedTasks(plan(paths)).has("ci:db"));
|
|
});
|
|
|
|
test("unclassified paths and uncertain comparisons select full verification", () => {
|
|
for (const paths of [[], ["new-root/file.ts"], ["../escaped"]]) {
|
|
const result = plan(paths);
|
|
assert.ok(result.forceReason);
|
|
assert.ok(Object.values(result.jobs).every((job) => job.selected === job.total));
|
|
}
|
|
for (const files of [null, [], [{ status: "modified" }], [{ filename: "a", status: "renamed" }],
|
|
Array.from({ length: 300 }, () => ({ filename: "ui/src/test.ts" }))]) {
|
|
assert.throws(() => comparisonPaths({ files }));
|
|
}
|
|
});
|
|
|
|
test("shadow mode explains savings but runs every task; force overrides selection", () => {
|
|
const shadow = plan(["ui/src/components/Sidebar.tsx"], { mode: "shadow" });
|
|
assert.equal(shadow.jobs.verify_serialized_server.proposed, 0);
|
|
assert.equal(shadow.jobs.verify_serialized_server.selected, 9);
|
|
for (const options of [{ mode: "full" }, { forceReason: "Stack" }]) {
|
|
assert.ok(Object.values(plan(["ui/src/components/Sidebar.tsx"], options).jobs)
|
|
.every((job) => job.selected === job.total));
|
|
}
|
|
});
|
|
|
|
test("cache-affecting definition changes force execution across successive PR pushes", () => {
|
|
for (const file of ["turbo.json", "package.json", "packages/db/package.json", "scripts/ci/run-task.mjs",
|
|
".github/actions/run-ci-task/action.yml"]) {
|
|
for (const paths of [[file], [file, "packages/db/src/schema/issues.ts"]]) {
|
|
const result = plan(paths);
|
|
assert.match(result.forceReason, /Global CI input changed/);
|
|
assert.ok(Object.values(result.jobs).every((job) => job.selected === job.total));
|
|
}
|
|
}
|
|
const workflow = readFileSync(new URL(".github/workflows/pr-trusted.yml", root), "utf8");
|
|
assert.equal((workflow.match(/force:.*\.forceReason != null/g) ?? []).length, 3);
|
|
const action = readFileSync(new URL(".github/actions/run-ci-task/action.yml", root), "utf8");
|
|
assert.match(action, /CI_FORCE_TASKS: \$\{\{ inputs.force \}\}/);
|
|
assert.equal((action.match(/if:.*inputs.force != 'true'/g) ?? []).length, 2);
|
|
});
|
|
|
|
test("task dependencies propagate input changes and unsupported graph syntax fails closed", () => {
|
|
const fixture = { globalDependencies: [], tasks: {
|
|
"//#build": { inputs: ["lib/**"] }, "//#test": { inputs: ["app/**"], dependsOn: ["//#build"] },
|
|
} };
|
|
assert.equal(planTasks({ config: fixture, lanes: { tests: [{ task: "test" }] }, paths: ["lib/index.ts"] }).jobs.tests.run, true);
|
|
fixture.tasks["//#build"].dependsOn = ["//#test"];
|
|
assert.throws(() => planTasks({ config: fixture, lanes: { tests: [{ task: "test" }] }, paths: ["app/index.ts"] }), /Cyclic/);
|
|
assert.throws(() => plan(["ui/src/a.ts"], { mode: "typo" }), /Invalid CI mode/);
|
|
});
|
|
|
|
test("local planner reads a real Git rename and handles a missing revision conservatively", () => {
|
|
const directory = mkdtempSync(path.join(tmpdir(), "ci-plan-"));
|
|
const git = (...args) => execFileSync("git", args, { cwd: directory, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
try {
|
|
git("init", "-q"); git("config", "user.name", "CI Test"); git("config", "user.email", "ci@example.invalid");
|
|
mkdirSync(path.join(directory, "packages/shared/src"), { recursive: true });
|
|
writeFileSync(path.join(directory, "packages/shared/src/input.ts"), "original\n");
|
|
git("add", "."); git("commit", "-qm", "base");
|
|
const base = git("rev-parse", "HEAD").trim();
|
|
mkdirSync(path.join(directory, "releases"));
|
|
git("mv", "packages/shared/src/input.ts", "releases/v1.md"); git("commit", "-qm", "rename");
|
|
for (const revision of [base, "missing-revision"]) {
|
|
const result = spawnSync(process.execPath, [new URL("../ci-plan.mjs", import.meta.url).pathname, revision], {
|
|
cwd: directory, encoding: "utf8", env: { ...process.env, GITHUB_OUTPUT: "", GITHUB_STEP_SUMMARY: "",
|
|
CI_TASK_CONFIG: new URL("turbo.json", root).pathname, CI_TASK_MATRIX: new URL(".github/ci-task-matrix.json", root).pathname },
|
|
});
|
|
assert.equal(result.status, 0, result.stderr);
|
|
const resultPlan = JSON.parse(result.stdout);
|
|
assert.ok(selectedTasks(resultPlan).has("ci:server"));
|
|
if (revision !== base) assert.match(resultPlan.forceReason, /Diff unavailable/);
|
|
}
|
|
} finally { rmSync(directory, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test("required aggregates reject omitted, failed and cancelled selected work", () => {
|
|
const workflow = readFileSync(new URL(".github/workflows/pr-trusted.yml", root), "utf8");
|
|
for (const id of ["verify", "e2e"]) {
|
|
const block = workflow.split(`\n ${id}:\n`)[1].split(/\n [a-z0-9_]+:\n/)[0];
|
|
const code = block.split(" node --input-type=module <<'NODE'\n")[1].split("\n NODE")[0]
|
|
.split("\n").map((line) => line.slice(10)).join("\n");
|
|
const names = /needs: \[([^\]]+)\]/.exec(block)[1].split(", ");
|
|
const resultPlan = plan(["ui/src/components/Sidebar.tsx"]);
|
|
const results = Object.fromEntries(names.map((name) => [name, { result:
|
|
name === "gate" || name === "policy" || resultPlan.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(resultPlan), CI_RESULTS: JSON.stringify(results) } });
|
|
assert.equal(run().status, 0);
|
|
for (const name of names) {
|
|
// Optional analysis can fail or be skipped; the unchanged task plan
|
|
// still has to pass. Selected-plan cases are exercised separately.
|
|
if (name === "test_selection") continue;
|
|
const previous = results[name].result;
|
|
for (const bad of previous === "success" ? ["failure", "cancelled", "skipped"] : ["success"]) {
|
|
results[name].result = bad;
|
|
assert.notEqual(run().status, 0, `${id}: ${name} must reject ${bad}`);
|
|
}
|
|
results[name].result = previous;
|
|
}
|
|
}
|
|
});
|
|
|
|
test("the real routing step uses pinned trusted definitions and defaults to full on fetch failure", () => {
|
|
const workflow = readFileSync(new URL(".github/workflows/ci-routing.yml", root), "utf8");
|
|
const block = workflow.split(" - name: Plan affected CI tasks from trusted definitions\n")[1];
|
|
const shell = block.split(" run: |\n")[1].split("\n").map((line) => line.slice(10)).join("\n");
|
|
const directory = mkdtempSync(path.join(tmpdir(), "ci-routing-plan-"));
|
|
try {
|
|
const repository = path.join(directory, "source");
|
|
mkdirSync(repository);
|
|
const gitBin = execFileSync("which", ["git"], { encoding: "utf8" }).trim();
|
|
const git = (...args) => execFileSync(gitBin, args, { cwd: repository, encoding: "utf8" }).trim();
|
|
git("init", "-q", "-b", "main"); git("config", "user.name", "CI"); git("config", "user.email", "ci@example.invalid");
|
|
writeFileSync(path.join(repository, "base"), "base"); git("add", "."); git("commit", "-qm", "base");
|
|
git("checkout", "-qb", "pr");
|
|
mkdirSync(path.join(repository, "ui/src/components"), { recursive: true });
|
|
writeFileSync(path.join(repository, "ui/src/components/Sidebar.tsx"), "change");
|
|
git("add", "."); git("commit", "-qm", "PR");
|
|
const prHead = git("rev-parse", "HEAD");
|
|
git("checkout", "-q", "main");
|
|
writeFileSync(path.join(repository, "unrelated-master"), "already merged");
|
|
git("add", "."); git("commit", "-qm", "advance base");
|
|
const base = git("rev-parse", "HEAD");
|
|
git("merge", "--no-ff", "pr", "-m", "tested merge");
|
|
const merge = git("rev-parse", "HEAD");
|
|
writeFileSync(path.join(directory, "git"), `#!/usr/bin/env node
|
|
const {spawnSync}=require('node:child_process');
|
|
if(process.env.TEST_GIT_FAILURE==='true')process.exit(1);
|
|
const args=process.argv.slice(2).map(a=>a==='https://github.com/paperclipai/paperclip.git'?process.env.TEST_GIT_REPOSITORY:a);
|
|
const r=spawnSync(${JSON.stringify(gitBin)},args,{stdio:'inherit'});process.exit(r.status??1);
|
|
`);
|
|
chmodSync(path.join(directory, "git"), 0o755);
|
|
const gh = path.join(directory, "gh");
|
|
writeFileSync(gh, `#!/usr/bin/env node
|
|
const fs = require('node:fs'), path = require('node:path');
|
|
const endpoint = process.argv.find(arg => arg.startsWith('/repos/'));
|
|
if (endpoint?.endsWith('/git/ref/heads/master')) console.log('a'.repeat(40));
|
|
else if (endpoint?.includes('/contents/')) {
|
|
if (process.env.TEST_FETCH_FAILURE === 'true') process.exit(1);
|
|
const [file, query] = endpoint.split('/contents/')[1].split('?');
|
|
if (query !== 'ref=' + 'a'.repeat(40)) process.exit(2);
|
|
process.stdout.write(fs.readFileSync(path.join(process.env.TEST_TRUSTED_ROOT, file)));
|
|
}
|
|
else process.exit(3);
|
|
`);
|
|
chmodSync(gh, 0o755);
|
|
for (const overrides of [{}, { TEST_FETCH_FAILURE: "true" }, { TEST_GIT_FAILURE: "true" }, { PROFILE_CI: "true" }, { STACK_JSON: "{}" }, { FULL_CI: "false" }]) {
|
|
const output = path.join(directory, "output");
|
|
writeFileSync(output, "");
|
|
const result = spawnSync("bash", ["-c", shell], { encoding: "utf8", env: {
|
|
...process.env, PATH: `${directory}:${process.env.PATH}`, TEST_TRUSTED_ROOT: root.pathname,
|
|
RUNNER_TEMP: directory, FULL_CI: "true", PROFILE_CI: "false", FORCE_FULL_CI: "false",
|
|
CI_INCREMENTAL_MODE: "enabled", STACK_JSON: "null", EVENT_BASE_SHA: "b".repeat(40), EVENT_HEAD_SHA: prHead, EVENT_MERGE_SHA: merge,
|
|
TEST_GIT_REPOSITORY: repository,
|
|
GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: "", ...overrides,
|
|
} });
|
|
assert.equal(result.status, 0, result.stderr);
|
|
const value = readFileSync(output, "utf8").trim();
|
|
if (overrides.TEST_FETCH_FAILURE || overrides.TEST_GIT_FAILURE || overrides.FULL_CI === "false") assert.equal(value, "plan=");
|
|
else {
|
|
assert.notEqual(value, "plan=", `${JSON.stringify(overrides)}: ${result.stderr}`);
|
|
const resultPlan = JSON.parse(value.slice("plan=".length));
|
|
assert.equal(resultPlan.jobs.general_tests.selected, overrides.PROFILE_CI || overrides.STACK_JSON ? 19 : 2);
|
|
assert.equal(resultPlan.diff.base, base);
|
|
assert.deepEqual(resultPlan.changed, ["ui/src/components/Sidebar.tsx"]);
|
|
}
|
|
}
|
|
} finally { rmSync(directory, { recursive: true, force: true }); }
|
|
});
|
|
|
|
|
|
test("audited prose does not widen source selection, but guidance still runs its uncached guard", () => {
|
|
const source = "ui/src/components/Sidebar.tsx";
|
|
const sourcePlan = plan([source]);
|
|
for (const doc of ["doc/DEVELOPING.md", "doc/DATABASE.md", "doc/plans/2026-10-30-example.md"]) {
|
|
const mixed = plan([source, doc]);
|
|
assert.equal(mixed.forceReason, null);
|
|
assert.deepEqual(mixed.jobs, sourcePlan.jobs);
|
|
assert.deepEqual(mixed.documentation, [doc]);
|
|
}
|
|
const guidance = plan(["doc/DEVELOPING.md"]);
|
|
assert.deepEqual([...selectedTasks(guidance)], ["ci:typecheck"]);
|
|
assert.equal(config.tasks["//#ci:typecheck"].cache, false);
|
|
assert.deepEqual([...selectedTasks(plan(["doc/plans/2026-10-30-example.md"]))], []);
|
|
for (const file of ["docs/api/issues.md", "doc/unknown.md", "doc/plans/tool.sh", "skills/paperclip/SKILL.md",
|
|
"server/src/onboarding-assets/default/AGENTS.md", "tests/fixtures/example.md"]) {
|
|
assert.ok(selectedTasks(plan([source, file])).has("ci:server"), file);
|
|
}
|
|
});
|
|
|
|
test("complete merge trees handle more than 300 files, base advancement, renames and deletions", (t) => {
|
|
const directory = mkdtempSync(path.join(tmpdir(), "ci-merge-diff-"));
|
|
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
|
const git = (...args) => execFileSync("git", args, { cwd: directory, encoding: "utf8" }).trim();
|
|
const write = (file, value) => { mkdirSync(path.dirname(path.join(directory, file)), { recursive: true }); writeFileSync(path.join(directory, file), value); };
|
|
const commit = () => { git("add", "."); git("commit", "-qm", "fixture"); return git("rev-parse", "HEAD"); };
|
|
git("init", "-q", "-b", "main"); git("config", "user.name", "CI"); git("config", "user.email", "ci@example.invalid");
|
|
write("ui/src/deleted.ts", "old"); write("ui/src/renamed.ts", "rename"); const staleBase = commit();
|
|
git("checkout", "-qb", "pr");
|
|
for (let index = 0; index < 350; index++) write(`ui/src/component-${index}.ts`, "new");
|
|
git("rm", "ui/src/deleted.ts");
|
|
mkdirSync(path.join(directory, "doc/plans"), { recursive: true });
|
|
git("mv", "ui/src/renamed.ts", "doc/plans/2026-10-30-moved.md");
|
|
const prHead = commit();
|
|
git("checkout", "-q", "main"); write(".github/workflows/unrelated.yml", "already merged CI change"); const base = commit();
|
|
git("merge", "--no-ff", "pr", "-m", "tested merge"); const head = git("rev-parse", "HEAD");
|
|
const result = mergeDiff(head, prHead, directory);
|
|
assert.equal(result.diff.base, base); assert.notEqual(result.diff.base, staleBase);
|
|
assert.equal(result.changed.length, 353);
|
|
assert.ok(result.changed.includes("ui/src/deleted.ts")); assert.ok(result.changed.includes("ui/src/renamed.ts"));
|
|
assert.ok(!result.changed.includes(".github/workflows/unrelated.yml"));
|
|
assert.equal(plan(result.changed).forceReason, null);
|
|
const shallow = path.join(directory, "shallow");
|
|
git("clone", "--quiet", "--depth", "2", `file://${directory}`, shallow);
|
|
assert.equal(execFileSync("git", ["rev-parse", "--is-shallow-repository"], { cwd: shallow, encoding: "utf8" }).trim(), "true");
|
|
assert.deepEqual(mergeDiff(head, prHead, shallow), result);
|
|
assert.throws(() => mergeDiff(head, base, directory), /expected PR head/);
|
|
assert.throws(() => mergeDiff(prHead, prHead, directory), /merge commit/);
|
|
assert.throws(() => mergeDiff("f".repeat(40), prHead, directory));
|
|
});
|