Skip to content

Commit 96aa20f

Browse files
marc-vercelclaude
andauthored
feat(sandbox-mock): add @vercel/sandbox-mock package (#245)
Testing code that uses `@vercel/sandbox` means provisioning real sandboxes — slow, network-bound, and requiring credentials. In order to do that, customers must mock the whole Sandbox object, and they cannot run the integration tests until they go live (or they use real sandboxes). This new package, `@vercel/sandbox-mock`, provides a mock that aims to run a local sandbox with some constraints. We are mocking the API layer, and instead of relying into the Vercel services, we mock it by using `just-bash`. We have also configured changeset to maintain version parity between `@vercel/sandbox` and `@vercel/sandbox-mock`, to ensure that users and agents install the mock with the correct signatures. Testing --- - Each file that I have added contains unit tests (unless it is just types or interfaces). - There is a `packages/vercel-sandbox-mock/tests/compat.test.ts` test file which attempts to run the mock and real sandboxes, to ensure there is compatibility. - I ran the integration tests locally to ensure the compatibility tests work. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0546943 commit 96aa20f

47 files changed

Lines changed: 4623 additions & 81 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/add-sandbox-mock.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@vercel/sandbox-mock": minor
3+
"@vercel/sandbox": patch
4+
---
5+
6+
Add `@vercel/sandbox-mock`, a drop-in mock for `@vercel/sandbox` backed by `just-bash`. Rather than reimplementing the SDK surface, it runs the real `@vercel/sandbox` classes against an in-memory implementation of the `/v2/sandboxes` HTTP API injected through the SDK's `fetch` seam — so command execution, filesystem, multi-user/group management, snapshots, and forking all exercise the real SDK code. Commands run locally via `just-bash` against an in-memory filesystem, and `command()`/`setupSandbox()` let tests stub the output of commands `just-bash` can't run.
7+
8+
As part of this, `Snapshot.get` now forwards a custom `fetch` (via `WithFetchOptions`), matching `Snapshot.list` and `Snapshot.tree`. Previously it always used the global `fetch`, so an injected client — such as the mock — could not intercept the request.

.changeset/config.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
{ "repo": "vercel/sandbox" }
66
],
77
"commit": false,
8-
"fixed": [],
8+
"fixed": [["@vercel/sandbox", "@vercel/sandbox-mock"]],
99
"linked": [],
1010
"access": "public",
1111
"baseBranch": "main",
1212
"updateInternalDependencies": "patch",
13-
"ignore": ["*-example"]
13+
"ignore": ["*-example"],
14+
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
15+
"onlyUpdatePeerDependentsWhenOutOfRange": true
16+
}
1417
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
dist/
2+
.env.test
3+
.turbo
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# @vercel/sandbox-mock
2+
3+
Drop-in mock for `@vercel/sandbox`. Runs your sandbox code against an in-memory
4+
implementation of the Vercel sandbox API instead of provisioning real
5+
sandboxes, so tests stay fast and offline.
6+
7+
## How it works
8+
9+
The mock does **not** reimplement the SDK. It re-exports the real
10+
`@vercel/sandbox` classes and injects a mocked `fetch` (plus dummy credentials)
11+
into every entry point, so `Sandbox`, `Session`, `Command`, `FileSystem`,
12+
`SandboxUser`, and `Snapshot` are the genuine SDK code — only the HTTP layer is
13+
replaced. Requests to `/v2/sandboxes/**` are served from memory, and commands
14+
run locally through [just-bash](https://github.com/vercel-labs/just-bash)
15+
against an in-memory filesystem.
16+
17+
Because the real SDK runs unchanged, argument parsing, pagination, retries,
18+
resume-after-stop, snapshots, forking, and multi-user orchestration all behave
19+
exactly as they do in production.
20+
21+
## Install
22+
23+
```bash
24+
pnpm add -D @vercel/sandbox-mock
25+
```
26+
27+
`@vercel/sandbox` is a peer dependency.
28+
29+
## Usage
30+
31+
Import `Sandbox` from `@vercel/sandbox-mock` instead of `@vercel/sandbox` — the
32+
API is identical:
33+
34+
```ts
35+
import { Sandbox } from "@vercel/sandbox-mock";
36+
37+
const sandbox = await Sandbox.create();
38+
const result = await sandbox.runCommand("echo", ["hello"]);
39+
console.log(await result.stdout()); // "hello\n"
40+
await sandbox.stop();
41+
```
42+
43+
To keep existing `@vercel/sandbox` imports unchanged, alias the module in your
44+
test setup:
45+
46+
```ts
47+
// vitest.setup.ts
48+
vi.mock("@vercel/sandbox", () => import("@vercel/sandbox-mock"));
49+
```
50+
51+
### Command handlers
52+
53+
Some commands can't run under just-bash (e.g. `npm install`). Stub their output
54+
with `command()`. The API follows [msw](https://mswjs.io/) — set defaults once,
55+
override per-test with `use()`, and reset in `afterEach`:
56+
57+
```ts
58+
import { Sandbox, setupSandbox, command } from "@vercel/sandbox-mock";
59+
import { afterEach, test } from "vitest";
60+
61+
const server = setupSandbox(
62+
command("npm install", { stdout: "added 1 package\n", exitCode: 0 }),
63+
command(/^greet/, (args) => ({ stdout: `Hello ${args[0] ?? "world"}\n` })),
64+
);
65+
66+
afterEach(() => server.resetHandlers());
67+
68+
test("handles install failure", async () => {
69+
server.use(command("npm install", { stderr: "ERR!\n", exitCode: 1 }));
70+
const sandbox = await Sandbox.create();
71+
const result = await sandbox.runCommand("npm", ["install"]);
72+
console.log(result.exitCode); // 1
73+
});
74+
```
75+
76+
Handler priority (first match wins): `use()` > `setupSandbox()`. Handlers that
77+
don't match fall through to just-bash.
78+
79+
Handlers are bound when a sandbox starts, so register them (via `setupSandbox()`
80+
or `use()`) **before** calling `Sandbox.create()`. Adding a handler afterwards
81+
has no effect on an already-running sandbox.
82+
83+
### File operations & cleanup
84+
85+
```ts
86+
await using sandbox = await Sandbox.create(); // AsyncDisposable — auto-stops
87+
await sandbox.writeFiles([{ path: "/app/index.ts", content: 'console.log("hi")' }]);
88+
console.log(await sandbox.fs.readFile("/app/index.ts", "utf8"));
89+
```
90+
91+
## Limitations
92+
93+
Command execution is just-bash, not a real Linux VM, so some behaviour differs
94+
from a live sandbox:
95+
96+
- **Users/groups** are simulated in memory. Home-directory scoping, `$HOME`,
97+
relative paths, and group membership work, but there is no real permission
98+
isolation between users. `id -un` reports `vercel-sandbox`.
99+
- **Command output is buffered**, not streamed live — `logs()` emits after the
100+
command finishes.
101+
- **Coreutils and shell semantics** follow just-bash: 32-bit arithmetic, no job
102+
control (`&`), and a different command set than the production image.
103+
- **Network access** is disabled; stub network-dependent commands with
104+
`command()`.
105+
106+
The `compat` test suite runs the same assertions against both the mock and a
107+
live sandbox to keep the two aligned. The live variants run when
108+
`RUN_INTEGRATION_TESTS=1` is set, with credentials taken from `.env.test` or
109+
the environment (same convention as the `vercel-sandbox` package).
110+
111+
## Development
112+
113+
```bash
114+
pnpm install
115+
pnpm run test # unit + integration + compat (mock)
116+
RUN_INTEGRATION_TESTS=1 pnpm run test # also run [real] compat tests
117+
pnpm run typecheck
118+
pnpm run build
119+
```
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
{
2+
"name": "@vercel/sandbox-mock",
3+
"version": "2.5.0",
4+
"description": "Drop-in mock for @vercel/sandbox backed by just-bash.",
5+
"type": "module",
6+
"main": "dist/index.cjs",
7+
"module": "dist/index.js",
8+
"types": "dist/index.d.ts",
9+
"exports": {
10+
".": {
11+
"types": "./dist/index.d.ts",
12+
"import": "./dist/index.js",
13+
"require": "./dist/index.cjs",
14+
"default": "./dist/index.cjs"
15+
},
16+
"./proxy": {
17+
"types": "./dist/proxy.d.ts",
18+
"import": "./dist/proxy.js",
19+
"require": "./dist/proxy.cjs",
20+
"default": "./dist/proxy.cjs"
21+
},
22+
"./package.json": "./package.json"
23+
},
24+
"files": [
25+
"dist",
26+
"README.md",
27+
"LICENSE"
28+
],
29+
"scripts": {
30+
"clean": "rm -rf node_modules dist",
31+
"build": "tsdown",
32+
"test": "vitest run",
33+
"typecheck": "tsc --noEmit"
34+
},
35+
"keywords": [
36+
"vercel",
37+
"sandbox",
38+
"mock",
39+
"testing"
40+
],
41+
"homepage": "https://vercel.com/docs/vercel-sandbox/sdk-reference",
42+
"repository": {
43+
"type": "git",
44+
"url": "https://github.com/vercel/sandbox.git",
45+
"directory": "packages/vercel-sandbox-mock"
46+
},
47+
"license": "Apache-2.0",
48+
"dependencies": {
49+
"just-bash": "^3.1.0",
50+
"tar-stream": "3.1.7"
51+
},
52+
"peerDependencies": {
53+
"@vercel/sandbox": "workspace:^"
54+
},
55+
"devDependencies": {
56+
"@types/node": "22.15.12",
57+
"@types/tar-stream": "3.1.4",
58+
"@vercel/sandbox": "workspace:^",
59+
"tsdown": "catalog:",
60+
"typescript": "5.8.3",
61+
"vitest": "catalog:"
62+
}
63+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { Bash } from "just-bash";
2+
import { describe, expect, test } from "vitest";
3+
import { command, handlersToCustomCommands } from "./handlers";
4+
5+
const ctx = { stdin: "" };
6+
7+
describe("command()", () => {
8+
describe("string patterns", () => {
9+
test("matches the exact command and pattern tokens as a prefix", () => {
10+
const handler = command("npm install");
11+
expect(handler.commandNames).toEqual(["npm"]);
12+
expect(handler.matches("npm", ["install"])).toBe(true);
13+
expect(handler.matches("npm", ["install", "--save"])).toBe(true);
14+
expect(handler.matches("npm", ["run", "build"])).toBe(false);
15+
expect(handler.matches("pnpm", ["install"])).toBe(false);
16+
});
17+
18+
test("a bare command name matches any args", () => {
19+
const handler = command("deploy");
20+
expect(handler.matches("deploy", [])).toBe(true);
21+
expect(handler.matches("deploy", ["--prod"])).toBe(true);
22+
});
23+
24+
test("empty patterns throw", () => {
25+
expect(() => command(" ")).toThrow(/must not be empty/);
26+
});
27+
});
28+
29+
describe("regex patterns", () => {
30+
test("matches the full command line", () => {
31+
const handler = command(/^git (pull|push)/);
32+
expect(handler.commandNames).toEqual(["git"]);
33+
expect(handler.matches("git", ["push", "origin"])).toBe(true);
34+
expect(handler.matches("git", ["status"])).toBe(false);
35+
});
36+
37+
test("stateful (global) regexes match consistently across calls", () => {
38+
const handler = command(/npm install/g);
39+
expect(handler.matches("npm", ["install"])).toBe(true);
40+
expect(handler.matches("npm", ["install"])).toBe(true);
41+
});
42+
43+
test("throws when the command name cannot be extracted", () => {
44+
expect(() => command(/(npm|pnpm) install/)).toThrow(/Cannot extract command name/);
45+
});
46+
});
47+
48+
describe("responses", () => {
49+
test("defaults to an empty successful response", async () => {
50+
expect(await command("noop").resolve("noop", [], ctx)).toEqual({});
51+
});
52+
53+
test("static responses are returned as-is", async () => {
54+
const handler = command("fail", { stderr: "boom", exitCode: 2 });
55+
expect(await handler.resolve("fail", [], ctx)).toEqual({ stderr: "boom", exitCode: 2 });
56+
});
57+
58+
test("function responses receive args and context", async () => {
59+
const handler = command("echo-args", (args, { stdin }) => ({
60+
stdout: JSON.stringify({ args, stdin }),
61+
}));
62+
const result = await handler.resolve("echo-args", ["a"], { stdin: "in" });
63+
expect(JSON.parse(result.stdout!)).toEqual({ args: ["a"], stdin: "in" });
64+
});
65+
});
66+
});
67+
68+
describe("handlersToCustomCommands", () => {
69+
test("groups handlers for the same command into one just-bash command", () => {
70+
const commands = handlersToCustomCommands([
71+
command("npm install"),
72+
command("npm run"),
73+
command("deploy"),
74+
]);
75+
expect(commands.map((c) => c.name).sort()).toEqual(["deploy", "npm"]);
76+
});
77+
78+
test("the first matching handler wins and defaults are filled in", async () => {
79+
const bash = new Bash({
80+
customCommands: handlersToCustomCommands([
81+
command("npm install", { stdout: "installed\n" }),
82+
command("npm", { stdout: "generic\n", exitCode: 1 }),
83+
]),
84+
});
85+
expect(await bash.exec("npm install")).toMatchObject({
86+
stdout: "installed\n",
87+
stderr: "",
88+
exitCode: 0,
89+
});
90+
expect(await bash.exec("npm audit")).toMatchObject({ stdout: "generic\n", exitCode: 1 });
91+
});
92+
93+
test("no matching pattern yields exit code 127", async () => {
94+
const bash = new Bash({
95+
customCommands: handlersToCustomCommands([command("npm install")]),
96+
});
97+
const result = await bash.exec("npm run build");
98+
expect(result.exitCode).toBe(127);
99+
expect(result.stderr).toContain("no pattern matched");
100+
});
101+
102+
test("handlers receive piped stdin as a string", async () => {
103+
const bash = new Bash({
104+
customCommands: handlersToCustomCommands([
105+
command("consume", (_args, { stdin }) => ({ stdout: `got:${stdin}` })),
106+
]),
107+
});
108+
expect((await bash.exec("echo -n data | consume")).stdout).toBe("got:data");
109+
});
110+
});

0 commit comments

Comments
 (0)