Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/cli-browser-runs-snapshots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@upstash/box-cli": patch
"@upstash/box": patch
---

Give the CLI parity with the SDK, so an agent driving a terminal can reach everything a program can.

- `box browser open|tabs|content|screenshot|act|close|cdp-url`. The browser is the one thing in a box with no shell fallback, because it is driven through the coordinator rather than from inside the container, so `box exec` could never stand in for it. `--tab` is optional while a single tab is open and required once there are several: acting on the wrong page is worse than asking which one. `screenshot` writes to `--out` rather than stdout, which carries text a caller may pipe.
- `box status runs` and `box status logs`. A run id was previously unobtainable, so a failed run could be observed but not investigated.
- `box cancel <run-id>`, with `Box.cancelRun()` added to the SDK. `Run.cancel()` only works while holding the object the call returned, which a separate process never is, so an agent that started a long run had no way to stop it.
- `box snapshot list` and `box snapshot delete`.
- `box from-snapshot --no-repl`, matching `box create`: explicit flag, `--json`, or no terminal on either stream. Without it a script could take a snapshot and never restore one, which made listing and deleting them write-only.

- `box schedule exec|agent|list|get|update|pause|resume|delete`. Cron on a box was reachable only from the SDK and the console. `update` sends just the fields named, because a partial update that also sent the command would clear it.
- `box skills add|remove|list`, `box config model|harness|network|init-command`, and `box resume`.
- `box code <source> --lang js|ts|python`, with `-` reading stdin so the shell does not mangle a program on the way in.
- The rest of the browser: `goto`, `observe`, `extract`, `live-url`, and `recordings start|stop|list|get|download`. `extract` takes a flat JSON Schema file, since a Zod schema cannot travel through a command line, and refuses anything nested rather than silently dropping fields.

`exec.session` is deliberately absent: a session is a live WebSocket with `on`/`send`/`close`, and a one-shot command has nowhere to hold it. `getPreviewUrl` and `listPreviews` are deprecated aliases for the public-URL calls, so `box public-url` already covers them.

`Box.cancelRun()` is added to the SDK, because `Run.cancel()` needs the object the original call returned, which another process never holds. The Python SDK gets the same method as `cancel_run`; it versions separately, so it is not in this changeset.
191 changes: 191 additions & 0 deletions packages/cli/src/__tests__/commands/browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
browserOpenCommand,
browserTabsCommand,
browserContentCommand,
browserScreenshotCommand,
browserActCommand,
browserCloseCommand,
browserCdpUrlCommand,
} from "../../commands/browser.js";
import { CliError } from "../../core/errors.js";

const getBox = vi.hoisted(() => vi.fn());
vi.mock("@upstash/box", () => ({ Box: { get: getBox } }));

vi.mock("../../core/box-ref.js", () => ({
resolveBoxId: vi.fn(() => ({ id: "b1", source: "flag" })),
announceBox: vi.fn(),
}));

describe("box browser", () => {
let stdout: ReturnType<typeof vi.spyOn>;
let stderr: ReturnType<typeof vi.spyOn>;
const flags = { box: "b1", token: "box_test" };

const written = () => stdout.mock.calls.map((c) => String(c[0])).join("");

/** A box whose browser namespace is backed by the given fakes. */
const boxWith = (browser: Record<string, unknown>) => {
getBox.mockResolvedValue({ browser });
return browser;
};

beforeEach(() => {
stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
getBox.mockReset();
});

afterEach(() => {
stdout.mockRestore();
stderr.mockRestore();
});

it("prints the tab id on open, so later commands can address it", async () => {
const create = vi.fn().mockResolvedValue({ id: "tab-7" });
boxWith({ tab: { create } });

await browserOpenCommand("https://example.com", { ...flags });

expect(create).toHaveBeenCalledWith("https://example.com");
expect(written()).toContain("tab-7");
});

it("lists tabs as data, not as SDK objects", async () => {
// A real Tab holds a back-reference to the Box, so emitting one raw is a
// circular structure that JSON.stringify throws on. Model that here, or
// the projection looks unnecessary.
const fakeTab = (id: string, url: string, title: string) => {
const tab: Record<string, unknown> = { id, url, title, content: vi.fn() };
tab.box = { id: "b1", tabs: [tab] };
return tab;
};
boxWith({
listTabs: vi
.fn()
.mockResolvedValue([
fakeTab("tab-1", "https://a.test", "A"),
fakeTab("tab-2", "https://b.test", "B"),
]),
});

await browserTabsCommand({ ...flags, json: true });

// A Tab instance carries methods and a back-reference to the box; emitting
// it raw would put that in --json output.
expect(JSON.parse(written())).toEqual([
{ id: "tab-1", url: "https://a.test", title: "A" },
{ id: "tab-2", url: "https://b.test", title: "B" },
]);
});

it("uses the only open tab without being told", async () => {
const content = vi.fn().mockResolvedValue({ title: "T", url: "u", text: "hello" });
boxWith({ listTabs: vi.fn().mockResolvedValue([{ id: "tab-1", content }]) });

await browserContentCommand({ ...flags });

expect(content).toHaveBeenCalled();
expect(written()).toContain("hello");
});

it("refuses to guess when several tabs are open", async () => {
boxWith({ listTabs: vi.fn().mockResolvedValue([{ id: "tab-1" }, { id: "tab-2" }]) });

// Acting on the wrong page is worse than asking which one.
await expect(browserContentCommand({ ...flags })).rejects.toThrow(/--tab/);
});

it("says what to do when nothing is open", async () => {
boxWith({ listTabs: vi.fn().mockResolvedValue([]) });

await expect(browserContentCommand({ ...flags })).rejects.toThrow(/browser open/);
});

it("addresses the named tab directly, without listing", async () => {
const listTabs = vi.fn();
const content = vi.fn().mockResolvedValue({ title: "T", url: "u", text: "x" });
boxWith({ listTabs, getTab: vi.fn(() => ({ id: "tab-9", content })) });

await browserContentCommand({ ...flags, tab: "tab-9" });

expect(listTabs).not.toHaveBeenCalled();
expect(content).toHaveBeenCalled();
});

describe("screenshot", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "box-shot-"));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));

it("writes the PNG to the file it was given", async () => {
const png = new Uint8Array([137, 80, 78, 71]);
boxWith({
listTabs: vi
.fn()
.mockResolvedValue([{ id: "t", screenshot: vi.fn().mockResolvedValue(png) }]),
});
const out = join(dir, "page.png");

await browserScreenshotCommand({ ...flags, out });

expect([...readFileSync(out)]).toEqual([137, 80, 78, 71]);
});

it("requires --out rather than putting bytes on stdout", async () => {
// stdout is the data channel for text; PNG bytes would corrupt a pipe.
boxWith({ listTabs: vi.fn().mockResolvedValue([{ id: "t" }]) });

await expect(browserScreenshotCommand({ ...flags })).rejects.toThrow(CliError);
});

it("decodes a base64 screenshot rather than writing the string", async () => {
const b64 = Buffer.from([1, 2, 3]).toString("base64");
boxWith({
listTabs: vi
.fn()
.mockResolvedValue([{ id: "t", screenshot: vi.fn().mockResolvedValue(b64) }]),
});
const out = join(dir, "page.png");

await browserScreenshotCommand({ ...flags, out });

expect([...readFileSync(out)]).toEqual([1, 2, 3]);
});
});

it("passes the instruction through to act", async () => {
const act = vi.fn().mockResolvedValue({ success: true });
boxWith({ listTabs: vi.fn().mockResolvedValue([{ id: "t", act }]) });

await browserActCommand("click the login button", { ...flags });

expect(act).toHaveBeenCalledWith("click the login button");
});

it("closes the tab", async () => {
const close = vi.fn().mockResolvedValue(undefined);
boxWith({ listTabs: vi.fn().mockResolvedValue([{ id: "tab-3", close }]) });

await browserCloseCommand({ ...flags });

expect(close).toHaveBeenCalled();
expect(written()).toContain("tab-3");
});

it("prints the CDP url on stdout and the hint on stderr", async () => {
boxWith({ cdpUrl: vi.fn().mockResolvedValue("ws://cdp.test/abc") });

await browserCdpUrlCommand({ ...flags });

// The URL is the data; the how-to is a diagnostic, so a pipe stays clean.
expect(written()).toContain("ws://cdp.test/abc");
expect(stderr.mock.calls.map((c) => String(c[0])).join("")).toContain("connectOverCDP");
});
});
Loading
Loading