Files
paperclip/scripts/test-sandbox-providers.test.mjs
Nicky LeachandClaude Opus 5.5 3b3037aab5 fix(ci): run the sandbox provider tests in required CI (#19533)
## 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]>
2026-11-06 10:34:03 -08:00

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/);
});