feat: add high-level Capsule lifecycle API
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@ -138,3 +138,5 @@ dist
|
|||||||
|
|
||||||
# AI agents
|
# AI agents
|
||||||
.opencode
|
.opencode
|
||||||
|
# Added by code-review-graph
|
||||||
|
.code-review-graph/
|
||||||
|
|||||||
39
AGENTS.md
39
AGENTS.md
@ -65,3 +65,42 @@ Woodpecker CI (`.woodpecker/check.yml`) runs on push to `main` and `dev`:
|
|||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
Runtime: `ws` (WebSocket), `zod` (validation). Everything else is dev-only.
|
Runtime: `ws` (WebSocket), `zod` (validation). Everything else is dev-only.
|
||||||
|
|
||||||
|
<!-- code-review-graph MCP tools -->
|
||||||
|
## MCP Tools: code-review-graph
|
||||||
|
|
||||||
|
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
|
||||||
|
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
|
||||||
|
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
|
||||||
|
you structural context (callers, dependents, test coverage) that file
|
||||||
|
scanning cannot.
|
||||||
|
|
||||||
|
### When to use graph tools FIRST
|
||||||
|
|
||||||
|
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
|
||||||
|
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
|
||||||
|
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
|
||||||
|
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
|
||||||
|
- **Architecture questions**: `get_architecture_overview` + `list_communities`
|
||||||
|
|
||||||
|
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
||||||
|
|
||||||
|
### Key Tools
|
||||||
|
|
||||||
|
| Tool | Use when |
|
||||||
|
| ------ | ---------- |
|
||||||
|
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
|
||||||
|
| `get_review_context` | Need source snippets for review — token-efficient |
|
||||||
|
| `get_impact_radius` | Understanding blast radius of a change |
|
||||||
|
| `get_affected_flows` | Finding which execution paths are impacted |
|
||||||
|
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
|
||||||
|
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
|
||||||
|
| `get_architecture_overview` | Understanding high-level codebase structure |
|
||||||
|
| `refactor_tool` | Planning renames, finding dead code |
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
|
||||||
|
1. The graph auto-updates on file changes (via hooks).
|
||||||
|
2. Use `detect_changes` for code review.
|
||||||
|
3. Use `get_affected_flows` to understand impact.
|
||||||
|
4. Use `query_graph` pattern="tests_for" to check coverage.
|
||||||
|
|||||||
228
src/capsule.ts
Normal file
228
src/capsule.ts
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import {
|
||||||
|
type OperationJsonBody,
|
||||||
|
type OperationJsonResponse,
|
||||||
|
type OperationQueryParams,
|
||||||
|
WrennClient,
|
||||||
|
} from "./client.js";
|
||||||
|
import type { ClientConfig } from "./config.js";
|
||||||
|
import { TimeoutError, WrennError } from "./exceptions.js";
|
||||||
|
|
||||||
|
export type CapsuleInfo = OperationJsonResponse<"getCapsule", 200>;
|
||||||
|
export type CapsuleMetrics = OperationJsonResponse<"getCapsuleMetrics", 200>;
|
||||||
|
export type CapsuleMetricsOptions = OperationQueryParams<"getCapsuleMetrics">;
|
||||||
|
|
||||||
|
type CapsuleStatus = NonNullable<CapsuleInfo["status"]>;
|
||||||
|
|
||||||
|
const DEFAULT_TEMPLATE = "minimal";
|
||||||
|
const DEFAULT_VCPUS = 1;
|
||||||
|
const DEFAULT_MEMORY_MB = 512;
|
||||||
|
const DEFAULT_TIMEOUT_SEC = 0;
|
||||||
|
const DEFAULT_WAIT_TIMEOUT_MS = 60_000;
|
||||||
|
const DEFAULT_WAIT_INTERVAL_MS = 1_000;
|
||||||
|
const TERMINAL_STATUSES = new Set<CapsuleStatus>([
|
||||||
|
"error",
|
||||||
|
"missing",
|
||||||
|
"stopped",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Options accepted when creating a new capsule. */
|
||||||
|
export interface CapsuleCreateOptions extends ClientConfig {
|
||||||
|
/** Template name to boot. Defaults to `minimal`. */
|
||||||
|
template?: string;
|
||||||
|
/** Number of virtual CPUs. Defaults to `1`. */
|
||||||
|
vcpus?: number;
|
||||||
|
/** Memory allocation in MiB. Defaults to `512`. */
|
||||||
|
memory_mb?: number;
|
||||||
|
/** Auto-pause TTL in seconds. Defaults to `0`, meaning no auto-pause. */
|
||||||
|
timeout_sec?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options used by lifecycle operations that wait for a running capsule. */
|
||||||
|
export interface WaitForReadyOptions {
|
||||||
|
/** Maximum time to wait before failing. Defaults to 60 seconds. */
|
||||||
|
timeoutMs?: number;
|
||||||
|
/** Delay between status polls. Defaults to 1 second. */
|
||||||
|
intervalMs?: number;
|
||||||
|
/** Optional cancellation signal. */
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CapsuleResumeOptions = WaitForReadyOptions & {
|
||||||
|
/** When true, wait until the resumed capsule reports `running`. */
|
||||||
|
wait?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function clientConfigFrom(opts?: ClientConfig): ClientConfig | undefined {
|
||||||
|
if (!opts) return undefined;
|
||||||
|
const config: ClientConfig = {};
|
||||||
|
if (opts.baseUrl !== undefined) config.baseUrl = opts.baseUrl;
|
||||||
|
if (opts.apiKey !== undefined) config.apiKey = opts.apiKey;
|
||||||
|
if (opts.token !== undefined) config.token = opts.token;
|
||||||
|
if (opts.hostToken !== undefined) config.hostToken = opts.hostToken;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNotAborted(signal?: AbortSignal): void {
|
||||||
|
if (!signal?.aborted) return;
|
||||||
|
throw new DOMException("Operation aborted", "AbortError");
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
assertNotAborted(signal);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const cleanup = () => signal?.removeEventListener("abort", abort);
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
}, ms);
|
||||||
|
const abort = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
cleanup();
|
||||||
|
reject(new DOMException("Operation aborted", "AbortError"));
|
||||||
|
};
|
||||||
|
signal?.addEventListener("abort", abort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Main user-facing handle for a Wrenn capsule. */
|
||||||
|
export class Capsule {
|
||||||
|
readonly id: string;
|
||||||
|
readonly client: WrennClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps an existing capsule ID without fetching or creating remote resources.
|
||||||
|
*
|
||||||
|
* @param id - Existing capsule identifier.
|
||||||
|
* @param opts - Optional client configuration.
|
||||||
|
*/
|
||||||
|
constructor(id: string, opts?: ClientConfig) {
|
||||||
|
this.id = id;
|
||||||
|
this.client = new WrennClient(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(opts?: CapsuleCreateOptions): Promise<Capsule>;
|
||||||
|
static create(
|
||||||
|
template: string,
|
||||||
|
opts?: CapsuleCreateOptions,
|
||||||
|
): Promise<Capsule>;
|
||||||
|
/** Creates a capsule and returns a high-level capsule handle. */
|
||||||
|
static async create(
|
||||||
|
templateOrOpts?: string | CapsuleCreateOptions,
|
||||||
|
opts?: CapsuleCreateOptions,
|
||||||
|
): Promise<Capsule> {
|
||||||
|
const template =
|
||||||
|
typeof templateOrOpts === "string"
|
||||||
|
? templateOrOpts
|
||||||
|
: (templateOrOpts?.template ?? DEFAULT_TEMPLATE);
|
||||||
|
const createOpts =
|
||||||
|
typeof templateOrOpts === "string" ? opts : templateOrOpts;
|
||||||
|
const clientConfig = clientConfigFrom(createOpts);
|
||||||
|
const client = new WrennClient(clientConfig);
|
||||||
|
const body: OperationJsonBody<"createCapsule"> = {
|
||||||
|
memory_mb: createOpts?.memory_mb ?? DEFAULT_MEMORY_MB,
|
||||||
|
template,
|
||||||
|
timeout_sec: createOpts?.timeout_sec ?? DEFAULT_TIMEOUT_SEC,
|
||||||
|
vcpus: createOpts?.vcpus ?? DEFAULT_VCPUS,
|
||||||
|
};
|
||||||
|
|
||||||
|
const capsule = await client.capsules.create(body);
|
||||||
|
if (!capsule.id) {
|
||||||
|
throw new WrennError(
|
||||||
|
500,
|
||||||
|
"Created capsule response did not include an id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Capsule(capsule.id, clientConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wraps an existing capsule ID without validating it remotely. */
|
||||||
|
static connect(id: string, opts?: ClientConfig): Capsule {
|
||||||
|
return new Capsule(id, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Destroys a capsule by ID without constructing an instance. */
|
||||||
|
static destroy(id: string, opts?: ClientConfig): Promise<void> {
|
||||||
|
return new WrennClient(opts).capsules.destroy(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches the latest capsule metadata. */
|
||||||
|
getInfo(): Promise<CapsuleInfo> {
|
||||||
|
return this.client.capsules.get(this.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Polls capsule metadata until the capsule reaches `running`. */
|
||||||
|
async waitForReady(opts?: WaitForReadyOptions): Promise<CapsuleInfo> {
|
||||||
|
const timeoutMs = opts?.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
|
||||||
|
const intervalMs = opts?.intervalMs ?? DEFAULT_WAIT_INTERVAL_MS;
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
let isFirstPoll = true;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
assertNotAborted(opts?.signal);
|
||||||
|
if (!isFirstPoll && Date.now() >= deadline) {
|
||||||
|
throw new TimeoutError(
|
||||||
|
`Timed out waiting for capsule ${this.id} to become running`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestOpts = opts?.signal ? { signal: opts.signal } : undefined;
|
||||||
|
const capsule = await this.client.capsules.get(this.id, requestOpts);
|
||||||
|
isFirstPoll = false;
|
||||||
|
if (capsule.status === "running") return capsule;
|
||||||
|
if (capsule.status && TERMINAL_STATUSES.has(capsule.status)) {
|
||||||
|
throw new WrennError(
|
||||||
|
409,
|
||||||
|
`Capsule ${this.id} reached terminal status "${capsule.status}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingMs = deadline - Date.now();
|
||||||
|
if (remainingMs <= 0) {
|
||||||
|
throw new TimeoutError(
|
||||||
|
`Timed out waiting for capsule ${this.id} to become running`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await delay(Math.min(intervalMs, remainingMs), opts?.signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Destroys this capsule. */
|
||||||
|
destroy(): Promise<void> {
|
||||||
|
return this.client.capsules.destroy(this.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pauses this capsule and returns the updated capsule metadata. */
|
||||||
|
pause(): Promise<CapsuleInfo> {
|
||||||
|
return this.client.capsules.pause(this.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resumes this capsule and optionally waits until it becomes ready. */
|
||||||
|
async resume(opts?: CapsuleResumeOptions): Promise<CapsuleInfo> {
|
||||||
|
const capsule = await this.client.capsules.resume(this.id);
|
||||||
|
if (!opts?.wait) return capsule;
|
||||||
|
return this.waitForReady(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resets this capsule's inactivity timer. */
|
||||||
|
ping(): Promise<void> {
|
||||||
|
return this.client.capsules.ping(this.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches resource metrics for this capsule. */
|
||||||
|
getMetrics(opts?: CapsuleMetricsOptions): Promise<CapsuleMetrics> {
|
||||||
|
return this.client.capsules.metrics(this.id, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local cleanup hook. This does not mutate or destroy the remote capsule. */
|
||||||
|
close(): void {}
|
||||||
|
|
||||||
|
/** Local async-disposal hook. This does not mutate or destroy the remote capsule. */
|
||||||
|
async [Symbol.asyncDispose](): Promise<void> {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use {@link Capsule} instead. */
|
||||||
|
export const Sandbox = Capsule;
|
||||||
@ -1,5 +1,5 @@
|
|||||||
/** Default Wrenn API origin used when no base URL is supplied. */
|
/** Default Wrenn API origin used when no base URL is supplied. */
|
||||||
export const DEFAULT_BASE_URL = "https://api.wrenn.dev";
|
export const DEFAULT_BASE_URL = "https://app.wrenn.dev/api";
|
||||||
|
|
||||||
/** Environment variable used for API-key authentication. */
|
/** Environment variable used for API-key authentication. */
|
||||||
export const ENV_API_KEY = "WRENN_API_KEY";
|
export const ENV_API_KEY = "WRENN_API_KEY";
|
||||||
|
|||||||
@ -2,6 +2,15 @@ export type { HttpClientConfig, RequestOptions } from "./_shared/http.js";
|
|||||||
export { HttpClient } from "./_shared/http.js";
|
export { HttpClient } from "./_shared/http.js";
|
||||||
export type { WsConnectionOpts } from "./_shared/websocket.js";
|
export type { WsConnectionOpts } from "./_shared/websocket.js";
|
||||||
export { WsConnection } from "./_shared/websocket.js";
|
export { WsConnection } from "./_shared/websocket.js";
|
||||||
|
export type {
|
||||||
|
CapsuleCreateOptions,
|
||||||
|
CapsuleInfo,
|
||||||
|
CapsuleMetrics,
|
||||||
|
CapsuleMetricsOptions,
|
||||||
|
CapsuleResumeOptions,
|
||||||
|
WaitForReadyOptions,
|
||||||
|
} from "./capsule.js";
|
||||||
|
export { Capsule, Sandbox } from "./capsule.js";
|
||||||
export type {
|
export type {
|
||||||
FileUploadInput,
|
FileUploadInput,
|
||||||
OperationJsonBody,
|
OperationJsonBody,
|
||||||
|
|||||||
207
tests/capsule.test.ts
Normal file
207
tests/capsule.test.ts
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { Capsule, Sandbox } from "../src/capsule.js";
|
||||||
|
|
||||||
|
interface CapturedRequest {
|
||||||
|
url: string;
|
||||||
|
init: RequestInit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupFetch(responses: Response[]) {
|
||||||
|
const calls: CapturedRequest[] = [];
|
||||||
|
const fetchMock = vi.fn(
|
||||||
|
async (url: string | URL | Request, init?: RequestInit) => {
|
||||||
|
calls.push({ url: String(url), init: init ?? {} });
|
||||||
|
const response = responses.shift();
|
||||||
|
if (!response) throw new Error("No mock response configured");
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
return { calls, fetchMock };
|
||||||
|
}
|
||||||
|
|
||||||
|
function capsuleResponse(id: string, status = "running") {
|
||||||
|
return Response.json({
|
||||||
|
id,
|
||||||
|
memory_mb: 512,
|
||||||
|
status,
|
||||||
|
template: "minimal",
|
||||||
|
timeout_sec: 0,
|
||||||
|
vcpus: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Capsule", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps an existing capsule without fetching it", () => {
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const capsule = Capsule.connect("cap_1", {
|
||||||
|
apiKey: "api-key",
|
||||||
|
baseUrl: "https://api.example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(capsule.id).toBe("cap_1");
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(capsule).toBeInstanceOf(Capsule);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a capsule from defaulted options", async () => {
|
||||||
|
const { calls } = setupFetch([capsuleResponse("cap_created")]);
|
||||||
|
|
||||||
|
const capsule = await Capsule.create({
|
||||||
|
apiKey: "api-key",
|
||||||
|
baseUrl: "https://api.example.com/",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(capsule.id).toBe("cap_created");
|
||||||
|
expect(calls.at(-1)?.url).toBe("https://api.example.com/v1/capsules");
|
||||||
|
expect(calls.at(-1)?.init.method).toBe("POST");
|
||||||
|
expect(calls.at(-1)?.init.body).toBe(
|
||||||
|
JSON.stringify({
|
||||||
|
memory_mb: 512,
|
||||||
|
template: "minimal",
|
||||||
|
timeout_sec: 0,
|
||||||
|
vcpus: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a capsule with a template overload and explicit resources", async () => {
|
||||||
|
const { calls } = setupFetch([capsuleResponse("cap_custom")]);
|
||||||
|
|
||||||
|
const capsule = await Capsule.create("node", {
|
||||||
|
apiKey: "api-key",
|
||||||
|
baseUrl: "https://api.example.com",
|
||||||
|
memory_mb: 1024,
|
||||||
|
timeout_sec: 60,
|
||||||
|
vcpus: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(capsule.id).toBe("cap_custom");
|
||||||
|
expect(calls.at(-1)?.init.body).toBe(
|
||||||
|
JSON.stringify({
|
||||||
|
memory_mb: 1024,
|
||||||
|
template: "node",
|
||||||
|
timeout_sec: 60,
|
||||||
|
vcpus: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when create does not return an id", async () => {
|
||||||
|
setupFetch([Response.json({ status: "running" })]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
Capsule.create({ baseUrl: "https://api.example.com" }),
|
||||||
|
).rejects.toThrow("Created capsule response did not include an id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps instance lifecycle methods to the low-level client", async () => {
|
||||||
|
const { calls } = setupFetch([
|
||||||
|
capsuleResponse("cap_1"),
|
||||||
|
Response.json({ sandbox_id: "cap_1", range: "10m", points: [] }),
|
||||||
|
new Response(null, { status: 204 }),
|
||||||
|
capsuleResponse("cap_1", "paused"),
|
||||||
|
capsuleResponse("cap_1", "running"),
|
||||||
|
new Response(null, { status: 204 }),
|
||||||
|
]);
|
||||||
|
const capsule = new Capsule("cap_1", {
|
||||||
|
baseUrl: "https://api.example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(capsule.getInfo()).resolves.toMatchObject({ id: "cap_1" });
|
||||||
|
await expect(capsule.getMetrics({ range: "10m" })).resolves.toMatchObject({
|
||||||
|
range: "10m",
|
||||||
|
});
|
||||||
|
await expect(capsule.ping()).resolves.toBeUndefined();
|
||||||
|
await expect(capsule.pause()).resolves.toMatchObject({ status: "paused" });
|
||||||
|
await expect(capsule.resume()).resolves.toMatchObject({
|
||||||
|
status: "running",
|
||||||
|
});
|
||||||
|
await expect(capsule.destroy()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(calls.map((call) => `${call.init.method} ${call.url}`)).toEqual([
|
||||||
|
"GET https://api.example.com/v1/capsules/cap_1",
|
||||||
|
"GET https://api.example.com/v1/capsules/cap_1/metrics?range=10m",
|
||||||
|
"POST https://api.example.com/v1/capsules/cap_1/ping",
|
||||||
|
"POST https://api.example.com/v1/capsules/cap_1/pause",
|
||||||
|
"POST https://api.example.com/v1/capsules/cap_1/resume",
|
||||||
|
"DELETE https://api.example.com/v1/capsules/cap_1",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits until the capsule is running", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { calls } = setupFetch([
|
||||||
|
capsuleResponse("cap_1", "pending"),
|
||||||
|
capsuleResponse("cap_1", "starting"),
|
||||||
|
capsuleResponse("cap_1", "running"),
|
||||||
|
]);
|
||||||
|
const capsule = new Capsule("cap_1", {
|
||||||
|
baseUrl: "https://api.example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ready = capsule.waitForReady({ intervalMs: 100, timeoutMs: 1_000 });
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
await expect(ready).resolves.toMatchObject({ status: "running" });
|
||||||
|
expect(calls).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails waitForReady on terminal capsule states", async () => {
|
||||||
|
setupFetch([capsuleResponse("cap_1", "error")]);
|
||||||
|
const capsule = new Capsule("cap_1", {
|
||||||
|
baseUrl: "https://api.example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(capsule.waitForReady()).rejects.toThrow(
|
||||||
|
'Capsule cap_1 reached terminal status "error"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("times out while waiting for readiness", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
setupFetch([
|
||||||
|
capsuleResponse("cap_1", "starting"),
|
||||||
|
capsuleResponse("cap_1", "starting"),
|
||||||
|
]);
|
||||||
|
const capsule = new Capsule("cap_1", {
|
||||||
|
baseUrl: "https://api.example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ready = capsule.waitForReady({ intervalMs: 100, timeoutMs: 150 });
|
||||||
|
const assertion = expect(ready).rejects.toThrow(
|
||||||
|
"Timed out waiting for capsule cap_1 to become running",
|
||||||
|
);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
await assertion;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate the remote capsule when closed or disposed", async () => {
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const capsule = new Capsule("cap_1", {
|
||||||
|
baseUrl: "https://api.example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
capsule.close();
|
||||||
|
await capsule[Symbol.asyncDispose]();
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports Sandbox as a deprecated Capsule alias", () => {
|
||||||
|
expect(Sandbox).toBe(Capsule);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -31,7 +31,7 @@ describe("resolveConfig", () => {
|
|||||||
vi.stubEnv("WRENN_TOKEN", undefined);
|
vi.stubEnv("WRENN_TOKEN", undefined);
|
||||||
vi.stubEnv("WRENN_HOST_TOKEN", undefined);
|
vi.stubEnv("WRENN_HOST_TOKEN", undefined);
|
||||||
|
|
||||||
expect(resolveConfig()).toEqual({ baseUrl: "https://api.wrenn.dev" });
|
expect(resolveConfig()).toEqual({ baseUrl: "https://app.wrenn.dev/api" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefers explicit options over environment variables", () => {
|
it("prefers explicit options over environment variables", () => {
|
||||||
|
|||||||
92
tests/integration/capsule.integration.test.ts
Normal file
92
tests/integration/capsule.integration.test.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { Capsule, type CapsuleCreateOptions } from "../../src/capsule.js";
|
||||||
|
import { DEFAULT_BASE_URL } from "../../src/config.js";
|
||||||
|
|
||||||
|
const baseUrl = process.env.WRENN_BASE_URL ?? DEFAULT_BASE_URL;
|
||||||
|
const apiKey = process.env.WRENN_API_KEY;
|
||||||
|
const template = process.env.WRENN_TEST_TEMPLATE ?? "minimal";
|
||||||
|
const waitTimeoutMs = Number(process.env.WRENN_TEST_WAIT_TIMEOUT_MS ?? 120_000);
|
||||||
|
const describeWithApiKey = apiKey ? describe : describe.skip;
|
||||||
|
|
||||||
|
const clientOpts = { apiKey, baseUrl } satisfies CapsuleCreateOptions;
|
||||||
|
|
||||||
|
describeWithApiKey("Capsule live integration", () => {
|
||||||
|
it(
|
||||||
|
"creates, waits for, inspects, pings, and destroys a live capsule",
|
||||||
|
async () => {
|
||||||
|
let capsule: Capsule | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
capsule = await Capsule.create(template, {
|
||||||
|
...clientOpts,
|
||||||
|
timeout_sec: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(capsule.id).toBeTypeOf("string");
|
||||||
|
expect(capsule.id.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const ready = await capsule.waitForReady({
|
||||||
|
intervalMs: 2_000,
|
||||||
|
timeoutMs: waitTimeoutMs,
|
||||||
|
});
|
||||||
|
expect(ready).toMatchObject({ id: capsule.id, status: "running" });
|
||||||
|
|
||||||
|
const connected = Capsule.connect(capsule.id, clientOpts);
|
||||||
|
const info = await connected.getInfo();
|
||||||
|
expect(info.id).toBe(capsule.id);
|
||||||
|
|
||||||
|
await expect(connected.ping()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
const metrics = await connected.getMetrics({ range: "10m" });
|
||||||
|
expect(metrics).toBeTypeOf("object");
|
||||||
|
|
||||||
|
await connected.close();
|
||||||
|
await connected[Symbol.asyncDispose]();
|
||||||
|
|
||||||
|
await Capsule.destroy(capsule.id, clientOpts);
|
||||||
|
capsule = undefined;
|
||||||
|
} finally {
|
||||||
|
if (capsule) {
|
||||||
|
await Capsule.destroy(capsule.id, clientOpts).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
waitTimeoutMs + 30_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"pauses and resumes a live capsule through high-level methods",
|
||||||
|
async () => {
|
||||||
|
let capsule: Capsule | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
capsule = await Capsule.create(template, {
|
||||||
|
...clientOpts,
|
||||||
|
timeout_sec: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
await capsule.waitForReady({
|
||||||
|
intervalMs: 2_000,
|
||||||
|
timeoutMs: waitTimeoutMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
const paused = await capsule.pause();
|
||||||
|
expect(paused).toMatchObject({ id: capsule.id });
|
||||||
|
expect(paused.status).toBe("paused");
|
||||||
|
|
||||||
|
const resumed = await capsule.resume({
|
||||||
|
intervalMs: 2_000,
|
||||||
|
timeoutMs: waitTimeoutMs,
|
||||||
|
wait: true,
|
||||||
|
});
|
||||||
|
expect(resumed).toMatchObject({ id: capsule.id, status: "running" });
|
||||||
|
} finally {
|
||||||
|
if (capsule) {
|
||||||
|
await Capsule.destroy(capsule.id, clientOpts).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
waitTimeoutMs * 2 + 30_000,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user