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
|
||||
.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
|
||||
|
||||
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. */
|
||||
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. */
|
||||
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 type { WsConnectionOpts } 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 {
|
||||
FileUploadInput,
|
||||
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_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", () => {
|
||||
|
||||
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