mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 06:16:06 -10: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 <[email protected]>
99 lines
3.9 KiB
JavaScript
99 lines
3.9 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { readdirSync, readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { test } from "node:test";
|
|
import { fileURLToPath } from "node:url";
|
|
import { providersWithTests, runProviderTests } from "./test-sandbox-providers.mjs";
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const providerRoot = "packages/plugins/sandbox-providers";
|
|
|
|
const harness = (statuses = {}) => {
|
|
const calls = { prepared: 0, ran: [], output: "", active: 0, peak: 0 };
|
|
return {
|
|
calls,
|
|
options: {
|
|
root: "/repo",
|
|
providers: ["p/a", "p/b", "p/c"],
|
|
concurrency: 2,
|
|
grouped: false,
|
|
prepare: () => {
|
|
calls.prepared += 1;
|
|
return 0;
|
|
},
|
|
runOne: async ({ directory }) => {
|
|
calls.ran.push(directory);
|
|
calls.active += 1;
|
|
calls.peak = Math.max(calls.peak, calls.active);
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
calls.active -= 1;
|
|
return { status: statuses[directory] ?? 0, output: `${directory} output`, seconds: 1.25 };
|
|
},
|
|
write: (text) => {
|
|
calls.output += text;
|
|
},
|
|
},
|
|
};
|
|
};
|
|
|
|
test("every top-level sandbox provider runs its own suite", () => {
|
|
const onDisk = readdirSync(path.join(root, providerRoot), { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => `${providerRoot}/${entry.name}`)
|
|
.filter((directory) => {
|
|
try {
|
|
return Boolean(JSON.parse(readFileSync(path.join(root, directory, "package.json"), "utf8")).scripts?.test);
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
assert.ok(onDisk.length > 0);
|
|
assert.deepEqual([...providersWithTests({ root })].sort(), onDisk.sort());
|
|
});
|
|
|
|
test("a provider nested in another provider runs with its parent", () => {
|
|
const providers = providersWithTests({ root });
|
|
assert.ok(providers.includes(`${providerRoot}/cloudflare`));
|
|
assert.ok(!providers.includes(`${providerRoot}/cloudflare/bridge-template`));
|
|
const config = readFileSync(path.join(root, providerRoot, "cloudflare", "vitest.config.ts"), "utf8");
|
|
assert.match(config, /bridge-template\/src\/\*\*\/\*\.test\.ts/, "Cloudflare's config must keep running bridge-template tests");
|
|
});
|
|
|
|
test("all suites run after one install, within the concurrency limit", async () => {
|
|
const { calls, options } = harness();
|
|
assert.equal(await runProviderTests(options), 0);
|
|
assert.equal(calls.prepared, 1);
|
|
assert.deepEqual([...calls.ran].sort(), ["p/a", "p/b", "p/c"]);
|
|
assert.equal(calls.peak, 2);
|
|
assert.match(calls.output, /^--- a: passed in 1\.3s\np\/a output\n/m);
|
|
assert.match(calls.output, /3 of 3 provider suites passed/);
|
|
});
|
|
|
|
test("one failing suite fails the run after the others finish", async () => {
|
|
const { calls, options } = harness({ "p/b": 1 });
|
|
assert.equal(await runProviderTests(options), 1);
|
|
assert.deepEqual([...calls.ran].sort(), ["p/a", "p/b", "p/c"]);
|
|
assert.match(calls.output, /--- b: failed in 1\.3s/);
|
|
assert.match(calls.output, /2 of 3 provider suites passed/);
|
|
assert.match(calls.output, /failed: b\n/);
|
|
});
|
|
|
|
test("a failed install runs no suite", async () => {
|
|
const { calls, options } = harness();
|
|
assert.equal(await runProviderTests({ ...options, prepare: () => 7 }), 7);
|
|
assert.deepEqual(calls.ran, []);
|
|
});
|
|
|
|
test("an empty provider list fails instead of passing silently", async () => {
|
|
const { calls, options } = harness();
|
|
assert.equal(await runProviderTests({ ...options, providers: [] }), 1);
|
|
assert.equal(calls.prepared, 0);
|
|
});
|
|
|
|
test("GitHub Actions output folds each suite and annotates failures", async () => {
|
|
const { calls, options } = harness({ "p/a": 1 });
|
|
await runProviderTests({ ...options, grouped: true, providers: ["p/a"] });
|
|
assert.match(calls.output, /^::group::a: failed in 1\.3s\np\/a output\n::endgroup::\n/);
|
|
assert.match(calls.output, /::error title=Sandbox provider tests::a failed/);
|
|
});
|