From b3b1be0b64cffcc91cc17cd6cd4ef6cbdd9b4a04 Mon Sep 17 00:00:00 2001 From: mohaimen Date: Sun, 23 Nov 2025 22:11:09 +0100 Subject: [PATCH 01/46] adapt the sandboxClient for pint changes --- src/AgentClient/index.ts | 25 ++++++++++++------ src/PintClient/execs.ts | 29 +++++++++++++-------- src/Sandbox.ts | 18 +++++++------ src/SandboxClient/commands.ts | 45 +++++++++++++++------------------ src/SandboxClient/filesystem.ts | 17 ++++++++----- src/SandboxClient/index.ts | 14 ++++++++++ src/SandboxClient/terminals.ts | 15 ++++++----- src/agent-client-interface.ts | 16 +++++++----- 8 files changed, 106 insertions(+), 73 deletions(-) diff --git a/src/AgentClient/index.ts b/src/AgentClient/index.ts index ddfced6..c5731ae 100644 --- a/src/AgentClient/index.ts +++ b/src/AgentClient/index.ts @@ -61,17 +61,25 @@ class AgentClientShells implements IAgentClientShells { this.onShellOutEmitter.fire(params); }); } - create( - projectPath: string, - size: shell.ShellSize, - command?: string, - type?: shell.ShellProcessType, - isSystemShell?: boolean - ): Promise { + create({ + command, + args, + size, + type, + isSystemShell, + projectPath, + }: { + command: string; + args: string[]; + projectPath: string; + size: shell.ShellSize; + type?: shell.ShellProcessType; + isSystemShell?: boolean; + }): Promise { return this.agentConnection.request({ method: "shell/create", params: { - command, + command: command + args.join(""), size, type, isSystemShell, @@ -390,6 +398,7 @@ class AgentClientSystem implements IAgentClientSystem { } export class AgentClient implements IAgentClient { + readonly type = "pitcher" as const; static async create({ session, getSession, diff --git a/src/PintClient/execs.ts b/src/PintClient/execs.ts index eaa8e5b..70f3087 100644 --- a/src/PintClient/execs.ts +++ b/src/PintClient/execs.ts @@ -127,29 +127,36 @@ export class PintShellsClient implements IAgentClientShells { status: exec.status as ShellProcessStatus, }; } - async create( - projectPath: string, - size: ShellSize, - command?: string, - type?: ShellProcessType, - isSystemShell?: boolean - ): Promise { - // For Pint, we need to construct args from command - const args = command ? command.split(' ').slice(1) : []; - const baseCommand = command ? command.split(' ')[0] : 'bash'; + async create({ + command, + args, + projectPath, + size, + type, + }: { + command: string; + args: string[]; + projectPath: string; + size: ShellSize; + type?: ShellProcessType; + isSystemShell?: boolean; + }): Promise { const exec = await createExec({ client: this.apiClient, body: { args, - command: baseCommand, + command, interactive: type === "COMMAND" ? false : true, }, }); if (!exec.data) { + console.log(exec); throw new Error(exec.error.message); } + console.log("Gotz shell", exec.data); + await this.open(exec.data.id, { cols: 200, rows: 80 }); return { diff --git a/src/Sandbox.ts b/src/Sandbox.ts index cc4ad7f..2952b79 100644 --- a/src/Sandbox.ts +++ b/src/Sandbox.ts @@ -151,13 +151,13 @@ export class Sandbox { return `export ${key}='${safe}'`; }) .join("\n"); - commands.push( - [ - `cat << 'EOF' > "$HOME/.private/.env"`, - envStrings, - `EOF`, - ].join("\n") - ); + const cmd = [ + `mkdir -p "$HOME/.private"`, + `cat << 'EOF' > "$HOME/.private/.env"`, + envStrings, + `EOF`, + ].join("\n"); + await client.commands.run(cmd); } if (customSession.git) { @@ -189,7 +189,9 @@ export class Sandbox { customSession?: SessionCreateOptions ): Promise { // HACK: we currently do not get a flag for pint, but this is a check we can use for now - const isPint = false; + const isPint = + pitcherManagerResponse.userWorkspacePath === + pitcherManagerResponse.workspacePath; if (!customSession || !customSession.id) { return { diff --git a/src/SandboxClient/commands.ts b/src/SandboxClient/commands.ts index 50b842a..ef949e6 100644 --- a/src/SandboxClient/commands.ts +++ b/src/SandboxClient/commands.ts @@ -118,39 +118,33 @@ export class SandboxCommands { "command.name": opts?.name || "", }, async () => { - const disposableStore = new DisposableStore(); - const onOutput = new Emitter(); - disposableStore.add(onOutput); - command = Array.isArray(command) ? command.join(" && ") : command; const passedEnv = Object.assign(opts?.env ?? {}); - const escapedCommand = command.replace(/'/g, "'\\''"); + // Build bash args array + const args = ["source $HOME/.private/.env 2>/dev/null || true"]; - // TODO: use a new shell API that natively supports cwd & env - let commandWithEnv = Object.keys(passedEnv).length - ? `source $HOME/.private/.env 2>/dev/null || true && env ${Object.entries( - passedEnv - ) - .map(([key, value]) => { - const escapedValue = String(value).replace(/'/g, "'\\''"); - return `${key}='${escapedValue}'`; - }) - .join(" ")} bash -c '${escapedCommand}'` - : `source $HOME/.private/.env 2>/dev/null || true && bash -c '${escapedCommand}'`; + if (Object.keys(passedEnv).length) { + Object.entries(passedEnv).forEach(([key, value]) => { + args.push("&&", "env", `${key}=${value}`); + }); + } if (opts?.cwd) { - commandWithEnv = `cd ${opts.cwd} && ${commandWithEnv}`; + args.push("&&", "cd", opts.cwd); } - const shell = await this.agentClient.shells.create( - this.agentClient.workspacePath, - opts?.dimensions ?? DEFAULT_SHELL_SIZE, - commandWithEnv, - opts?.asGlobalSession ? "COMMAND" : "TERMINAL", - true - ); + args.push("&&", command); + + const shell = await this.agentClient.shells.create({ + command: "bash", + args: ["-c", args.join(" ")], + projectPath: this.agentClient.workspacePath, + size: opts?.dimensions ?? DEFAULT_SHELL_SIZE, + type: opts?.asGlobalSession ? "COMMAND" : "TERMINAL", + isSystemShell: true, + }); if (shell.status === "ERROR" || shell.status === "KILLED") { throw new Error(`Failed to create shell: ${shell.buffer.join("\n")}`); @@ -216,7 +210,8 @@ export class SandboxCommands { return shells .filter( - (shell) => shell.shellType === "TERMINAL" && isCommandShell(shell) + (shell): shell is protocol.shell.CommandShellDTO => + shell.shellType === "TERMINAL" && isCommandShell(shell) ) .map( (shell) => diff --git a/src/SandboxClient/filesystem.ts b/src/SandboxClient/filesystem.ts index 68440a5..91bd312 100644 --- a/src/SandboxClient/filesystem.ts +++ b/src/SandboxClient/filesystem.ts @@ -184,13 +184,16 @@ export class FileSystem { try { // Extract the zip file using unzip command - const result = await this.agentClient.shells.create( - this.agentClient.workspacePath, - { cols: 128, rows: 24 }, - `cd ${this.agentClient.workspacePath} && unzip -o ${tempZipPath}`, - "COMMAND", - true - ); + const result = await this.agentClient.shells.create({ + projectPath: this.agentClient.workspacePath, + size: { cols: 128, rows: 24 }, + command: "bash", + args: [ + `cd ${this.agentClient.workspacePath} && unzip -o ${tempZipPath}`, + ], + type: "COMMAND", + isSystemShell: true, + }); if (result.status === "ERROR" || result.status === "KILLED") { throw new Error( diff --git a/src/SandboxClient/index.ts b/src/SandboxClient/index.ts index 058fd3b..a21932a 100644 --- a/src/SandboxClient/index.ts +++ b/src/SandboxClient/index.ts @@ -16,6 +16,7 @@ import { Barrier } from "../utils/barrier"; import { AgentClient } from "../AgentClient"; import { SandboxSession } from "../types"; import { Tracer, SpanStatusCode } from "@opentelemetry/api"; +import { PintClient } from "../PintClient"; export * from "./filesystem"; export * from "./ports"; @@ -41,6 +42,19 @@ export class SandboxClient { initStatusCb?: (event: system.InitStatus) => void, tracer?: Tracer ) { + if (session.isPint) { + const agentClient = await PintClient.create(session); + + return new SandboxClient( + agentClient, + { hostToken: session.hostToken, tracer }, + { + currentStepIndex: 0, + state: "FINISHED", + steps: [], + } + ); + } const { client: agentClient, joinResult } = await AgentClient.create({ session, getSession, diff --git a/src/SandboxClient/terminals.ts b/src/SandboxClient/terminals.ts index 3310b9c..34522e5 100644 --- a/src/SandboxClient/terminals.ts +++ b/src/SandboxClient/terminals.ts @@ -86,13 +86,14 @@ export class Terminals { commandWithEnv = `cd ${opts.cwd} && ${commandWithEnv}`; } - const shell = await this.agentClient.shells.create( - this.agentClient.workspacePath, - opts?.dimensions ?? DEFAULT_SHELL_SIZE, - commandWithEnv, - "TERMINAL", - true - ); + const shell = await this.agentClient.shells.create({ + projectPath: this.agentClient.workspacePath, + size: opts?.dimensions ?? DEFAULT_SHELL_SIZE, + command: "bash", + args: [commandWithEnv], + type: "TERMINAL", + isSystemShell: true, + }); if (opts?.name) { this.agentClient.shells.rename(shell.shellId, opts.name); diff --git a/src/agent-client-interface.ts b/src/agent-client-interface.ts index a5f9a55..2f84815 100644 --- a/src/agent-client-interface.ts +++ b/src/agent-client-interface.ts @@ -18,13 +18,14 @@ export interface IAgentClientShells { }>; onShellTerminated: Event; onShellOut: Event; - create( - projectPath: string, - size: shell.ShellSize, - command?: string, - type?: shell.ShellProcessType, - isSystemShell?: boolean - ): Promise; + create(options: { + command: string; + args: string[]; + projectPath: string; + size: shell.ShellSize; + type?: shell.ShellProcessType; + isSystemShell?: boolean; + }): Promise; rename(shellId: shell.ShellId, name: string): Promise; getShells(): Promise; open( @@ -128,6 +129,7 @@ export type IAgentClientState = | "HIBERNATED"; export interface IAgentClient { + type: "pitcher" | "pint"; sandboxId: string; workspacePath: string; isUpToDate: boolean; From 7024ed6550e88d7019b7da755a48858cf0a14991 Mon Sep 17 00:00:00 2001 From: Christian Alfoni Date: Wed, 26 Nov 2025 15:37:15 +0100 Subject: [PATCH 02/46] feat: support terminals --- src/AgentClient/index.ts | 136 ++++++++++++++++------- src/PintClient/execs.ts | 189 +++++++++++++------------------- src/SandboxClient/commands.ts | 104 +++++++++++------- src/SandboxClient/filesystem.ts | 11 +- src/SandboxClient/setup.ts | 39 ++++--- src/SandboxClient/tasks.ts | 63 +++++------ src/SandboxClient/terminals.ts | 84 +++++++++----- src/agent-client-interface.ts | 27 +++-- 8 files changed, 368 insertions(+), 285 deletions(-) diff --git a/src/AgentClient/index.ts b/src/AgentClient/index.ts index c5731ae..15fa219 100644 --- a/src/AgentClient/index.ts +++ b/src/AgentClient/index.ts @@ -17,12 +17,15 @@ import { IAgentClientSystem, IAgentClientTasks, PickRawFsResult, + SubscribeShellEvent, } from "../agent-client-interface"; import { AgentConnection } from "./AgentConnection"; import { Emitter, Event } from "../utils/event"; import { DEFAULT_SUBSCRIPTIONS, SandboxSession } from "../types"; import { SandboxClient } from "../SandboxClient"; import { InitStatus } from "../pitcher-protocol/messages/system"; +import { IDisposable } from "@xterm/headless"; +import { Disposable } from "../utils/disposable"; // Timeout for detecting a pong response, leading to a forced disconnect // Increased from 15s to 30s to be more tolerant of network latency @@ -32,35 +35,7 @@ let PONG_DETECTION_TIMEOUT = 30_000; const FOCUS_PONG_DETECTION_TIMEOUT = 5_000; class AgentClientShells implements IAgentClientShells { - private onShellExitedEmitter = new Emitter<{ - shellId: string; - exitCode: number; - }>(); - onShellExited = this.onShellExitedEmitter.event; - - private onShellTerminatedEmitter = new Emitter< - shell.ShellTerminateNotification["params"] - >(); - onShellTerminated = this.onShellTerminatedEmitter.event; - - private onShellOutEmitter = new Emitter< - shell.ShellOutNotification["params"] - >(); - onShellOut = this.onShellOutEmitter.event; - - constructor(private agentConnection: AgentConnection) { - agentConnection.onNotification("shell/exit", (params) => { - this.onShellExitedEmitter.fire(params); - }); - - agentConnection.onNotification("shell/terminate", (params) => { - this.onShellTerminatedEmitter.fire(params); - }); - - agentConnection.onNotification("shell/out", (params) => { - this.onShellOutEmitter.fire(params); - }); - } + constructor(private agentConnection: AgentConnection) {} create({ command, args, @@ -105,17 +80,102 @@ class AgentClientShells implements IAgentClientShells { return result.shells; } - open( + subscribe( shellId: shell.ShellId, - size: shell.ShellSize - ): Promise { - return this.agentConnection.request({ - method: "shell/open", - params: { - shellId, - size, - }, + listener: (event: SubscribeShellEvent) => void + ): IDisposable { + const disposable = new Disposable(); + + const disposeExit = this.agentConnection.onNotification( + "shell/exit", + (params) => { + if (params.shellId === shellId) { + listener({ type: "exit", exitCode: params.exitCode }); + } + } + ); + + const disposeTerminate = this.agentConnection.onNotification( + "shell/terminate", + (params) => { + if (params.shellId === shellId) { + listener({ type: "terminate" }); + } + } + ); + + disposable.onDidDispose(() => { + disposeExit(); + disposeTerminate(); + }); + + return disposable; + } + subscribeOutput( + shellId: shell.ShellId, + size: shell.ShellSize, + listener: (event: { out: string; exitCode?: number }) => void + ): IDisposable { + const disposable = new Disposable(); + let disposeOut: () => void; + let disposeExit: () => void; + + this.agentConnection + .request({ + method: "shell/open", + params: { + shellId, + size, + }, + }) + .then((openShell) => { + listener({ + out: openShell.buffer.join("\n"), + exitCode: openShell.exitCode, + }); + if ("exitCode" in openShell) { + return; + } + + disposeOut = this.agentConnection.onNotification( + "shell/out", + (params) => { + if (params.shellId === shellId) { + listener({ out: params.out, exitCode: openShell.exitCode }); + } + } + ); + disposeExit = this.agentConnection.onNotification( + "shell/exit", + (params) => { + if (params.shellId === shellId) { + listener({ out: "", exitCode: params.exitCode }); + } + } + ); + }) + .catch(() => { + // We do not care + }); + + disposable.onDidDispose(() => { + this.agentConnection + .request({ + method: "shell/close", + params: { + shellId, + size, + }, + }) + .catch(() => { + // We do not care + }); + + disposeOut?.(); + disposeExit?.(); }); + + return disposable; } rename(shellId: shell.ShellId, name: string): Promise { return this.agentConnection.request({ diff --git a/src/PintClient/execs.ts b/src/PintClient/execs.ts index 70f3087..a3d7490 100644 --- a/src/PintClient/execs.ts +++ b/src/PintClient/execs.ts @@ -3,7 +3,8 @@ import { Emitter, EmitterSubscription } from "../utils/event"; import { Disposable } from "../utils/disposable"; import { parseStreamEvent } from "./utils"; import { - IAgentClientShells, + IAgentClientShells, + SubscribeShellEvent, } from "../agent-client-interface"; import { createExec, @@ -27,17 +28,18 @@ import { ShellDTO, ShellProcessStatus, } from "../pitcher-protocol/messages/shell"; +import { IDisposable } from "@xterm/headless"; export class PintShellsClient implements IAgentClientShells { - private openShells: Record = {}; + private execs: ExecItem[] = []; private subscribeAndEvaluateExecsUpdates( + execId: string, compare: ( nextExec: ExecItem, prevExec: ExecItem | undefined, prevExecs: ExecItem[] ) => void ) { - let prevExecs: ExecItem[] = []; const abortController = new AbortController(); streamExecsList({ @@ -51,17 +53,19 @@ export class PintShellsClient implements IAgentClientShells { const execListResponse = parseStreamEvent(evt); const execs = execListResponse.execs; - if (prevExecs && execs) { - execs.forEach((exec) => { - const prevExec = prevExecs?.find( - (execItem) => execItem.id === exec.id - ); + execs.forEach((exec) => { + if (exec.id !== execId) { + return; + } - compare(exec, prevExec, prevExecs); - }); - } + const prevExec = this.execs.find( + (execItem) => execItem.id === exec.id + ); - prevExecs = execs || []; + compare(exec, prevExec, this.execs); + }); + + this.execs = execs; } }); @@ -69,48 +73,6 @@ export class PintShellsClient implements IAgentClientShells { abortController.abort(); }); } - private onShellExitedEmitter = new EmitterSubscription<{ - shellId: string; - exitCode: number; - }>((fire) => - this.subscribeAndEvaluateExecsUpdates((exec, prevExec) => { - if (!prevExec) { - return; - } - - if (prevExec.status === "RUNNING" && exec.status === "EXITED") { - fire({ - shellId: exec.id, - exitCode: exec.exitCode, - }); - } - }) - ); - onShellExited = this.onShellExitedEmitter.event; - - private onShellOutEmitter = new Emitter<{ - shellId: ShellId; - out: string; - }>(); - onShellOut = this.onShellOutEmitter.event; - private onShellTerminatedEmitter = new EmitterSubscription<{ - shellId: string; - author: string; - }>((fire) => - this.subscribeAndEvaluateExecsUpdates((exec, prevExec) => { - if (!prevExec) { - return; - } - - if (prevExec.status === "RUNNING" && exec.status === "STOPPED") { - fire({ - shellId: exec.id, - author: "", - }); - } - }) - ); - onShellTerminated = this.onShellTerminatedEmitter.event; constructor(private apiClient: Client, private sandboxId: string) {} private convertExecToShellDTO(exec: ExecItem) { return { @@ -151,20 +113,72 @@ export class PintShellsClient implements IAgentClientShells { }); if (!exec.data) { - console.log(exec); throw new Error(exec.error.message); } - console.log("Gotz shell", exec.data); - - await this.open(exec.data.id, { cols: 200, rows: 80 }); + this.execs.push(exec.data); return { ...this.convertExecToShellDTO(exec.data), buffer: [], }; } - async delete(shellId: ShellId): Promise { + subscribe( + shellId: ShellId, + listener: (event: SubscribeShellEvent) => void + ): IDisposable { + return this.subscribeAndEvaluateExecsUpdates(shellId, (exec, prevExec) => { + if (!prevExec) { + return; + } + + if (prevExec.status === "RUNNING" && exec.status === "EXITED") { + listener({ + type: "exit", + exitCode: exec.exitCode, + }); + } + }); + } + subscribeOutput( + shellId: ShellId, + size: ShellSize, + listener: (event: { out: string; exitCode?: number }) => void + ): IDisposable { + const disposable = new Disposable(); + const abortController = new AbortController(); + + getExecOutput({ + client: this.apiClient, + path: { id: shellId }, + query: { lastSequence: 0 }, + signal: abortController.signal, + headers: { + Accept: "text/event-stream", + }, + }).then(async ({ stream }) => { + for await (const evt of stream) { + const data = parseStreamEvent<{ + type: "stdout" | "stderr"; + output: ""; + sequence: number; + timestamp: string; + exitCode?: number; + }>(evt); + + listener({ out: data.output, exitCode: data.exitCode }); + } + }); + + disposable.onDidDispose(() => { + abortController.abort(); + }); + + return disposable; + } + async delete( + shellId: ShellId + ): Promise { try { // First get the exec details before deleting it const exec = await getExec({ @@ -190,12 +204,6 @@ export class PintShellsClient implements IAgentClientShells { }); if (deleteResponse.data) { - // Clean up any open shells reference - if (this.openShells[shellId]) { - this.openShells[shellId].abort(); - delete this.openShells[shellId]; - } - return shellDTO as CommandShellDTO | TerminalShellDTO; } else { return null; @@ -213,53 +221,6 @@ export class PintShellsClient implements IAgentClientShells { execs.data?.execs.map((exec) => this.convertExecToShellDTO(exec)) ?? [] ); } - async open(shellId: ShellId, size: ShellSize): Promise { - const abortController = new AbortController(); - - this.openShells[shellId] = abortController; - - const exec = await getExec({ - client: this.apiClient, - path: { - id: shellId, - }, - }); - - if (!exec.data) { - throw new Error(exec.error.message); - } - - const { stream } = await getExecOutput({ - client: this.apiClient, - path: { id: shellId }, - query: { lastSequence: 0 }, - signal: abortController.signal, - headers: { - Accept: "text/event-stream", - }, - }); - - const buffer: string[] = []; - - for await (const evt of stream) { - const data = parseStreamEvent<{ - type: "stdout" | "stderr"; - output: ""; - sequence: number; - timestamp: string; - }>(evt); - - if (!buffer.length) { - buffer.push(data.output); - break; - } - } - - return { - buffer, - ...this.convertExecToShellDTO(exec.data), - }; - } async rename(shellId: ShellId, name: string): Promise { return null; } @@ -271,7 +232,7 @@ export class PintShellsClient implements IAgentClientShells { id: shellId, }, body: { - status: 'running', + status: "running", }, }); @@ -288,7 +249,7 @@ export class PintShellsClient implements IAgentClientShells { id: shellId, }, body: { - type: 'stdin', + type: "stdin", input: input, }, }); diff --git a/src/SandboxClient/commands.ts b/src/SandboxClient/commands.ts index ef949e6..fc07f78 100644 --- a/src/SandboxClient/commands.ts +++ b/src/SandboxClient/commands.ts @@ -132,7 +132,7 @@ export class SandboxCommands { } if (opts?.cwd) { - args.push("&&", "cd", opts.cwd); + args.push("&&", "cd", opts.cwd); } args.push("&&", command); @@ -261,6 +261,7 @@ export class Command { private barrier = new Barrier(); private output: string[] = []; + private isSubscribingOutput = false; /** * The status of the command. @@ -303,39 +304,33 @@ export class Command { this.name = details.name; this.tracer = tracer; - this.disposable.addDisposable( - agentClient.shells.onShellExited(({ shellId, exitCode }) => { - if (shellId === this.shell.shellId) { - this.exitCode = exitCode; - this.status = exitCode === 0 ? "FINISHED" : "ERROR"; - this.barrier.open(); - } - }) - ); - - this.disposable.addDisposable( - agentClient.shells.onShellTerminated(({ shellId }) => { - if (shellId === this.shell.shellId) { - this.status = "KILLED"; - this.barrier.open(); - } - }) - ); - - this.disposable.addDisposable( - this.agentClient.shells.onShellOut(({ shellId, out }) => { - if (shellId !== this.shell.shellId || out.startsWith("[CODESANDBOX]")) { - return; - } - - this.onOutputEmitter.fire(out); - - this.output.push(out); - if (this.output.length > 1000) { - this.output.shift(); - } - }) - ); + if (shell.status === "RUNNING") { + this.disposable.addDisposable( + agentClient.shells.subscribe(shell.shellId, async (event) => { + if (event.type === "terminate") { + this.status = "KILLED"; + this.barrier.open(); + } else { + console.log("Got exit"); + const barrier = new Barrier(); + this.agentClient.shells.subscribeOutput( + this.shell.shellId, + DEFAULT_SHELL_SIZE, + (event) => { + this.output.push(event.out); + if (event.exitCode !== undefined) { + barrier.open(); + } + } + ); + await barrier.wait(); + this.exitCode = event.exitCode; + this.status = event.exitCode === 0 ? "FINISHED" : "ERROR"; + this.barrier.open(); + } + }) + ); + } } private async withSpan( @@ -384,14 +379,45 @@ export class Command { "command.dimensions.rows": dimensions.rows, }, async () => { - const shell = await this.agentClient.shells.open( - this.shell.shellId, - dimensions + if (this.isSubscribingOutput) { + return this.output.join("\n"); + } + + this.isSubscribingOutput = true; + const barrier = new Barrier(); + + this.disposable.addDisposable( + this.agentClient.shells.subscribeOutput( + this.shell.shellId, + dimensions, + ({ out }) => { + if (barrier.isOpen()) { + this.onOutputEmitter.fire(out); + + this.output.push(out); + if (this.output.length > 1000) { + this.output.shift(); + } + } else { + this.output.push(out); + barrier.open(out); + } + } + ) ); - this.output = shell.buffer; + this.disposable.onDidDispose(() => { + barrier.dispose(); + }); + + const result = await barrier.wait(); + + // This will never really happen + if (result.status === "disposed") { + return ""; + } - return this.output.join("\n"); + return result.value; } ); } diff --git a/src/SandboxClient/filesystem.ts b/src/SandboxClient/filesystem.ts index 91bd312..cb33ef4 100644 --- a/src/SandboxClient/filesystem.ts +++ b/src/SandboxClient/filesystem.ts @@ -207,16 +207,17 @@ export class FileSystem { if (result.status === "RUNNING") { // Wait for shell exit event await new Promise((resolve, reject) => { - const disposable = this.agentClient.shells.onShellExited( - ({ shellId, exitCode }) => { - if (shellId === result.shellId) { + const disposable = this.agentClient.shells.subscribe( + result.shellId, + (event) => { + if (event.type === "exit") { disposable.dispose(); - if (exitCode === 0) { + if (event.exitCode === 0) { resolve(); } else { reject( new Error( - `Unzip command failed with exit code ${exitCode}` + `Unzip command failed with exit code ${event.exitCode}` ) ); } diff --git a/src/SandboxClient/setup.ts b/src/SandboxClient/setup.ts index 5b5d36e..8ecdf54 100644 --- a/src/SandboxClient/setup.ts +++ b/src/SandboxClient/setup.ts @@ -4,6 +4,7 @@ import { Emitter } from "../utils/event"; import { DEFAULT_SHELL_SIZE } from "./terminals"; import { type IAgentClient } from "../agent-client-interface"; import { Tracer, SpanStatusCode } from "@opentelemetry/api"; +import { Barrier } from "../utils/barrier"; export class Setup { private disposable = new Disposable(); @@ -164,18 +165,6 @@ export class Step { } }) ); - this.disposable.addDisposable( - this.agentClient.shells.onShellOut(({ shellId, out }) => { - if (shellId === this.step.shellId) { - this.onOutputEmitter.fire(out); - - this.output.push(out); - if (this.output.length > 1000) { - this.output.shift(); - } - } - }) - ); } private withSpan( @@ -224,11 +213,31 @@ export class Step { }, async () => { const open = async (shellId: protocol.shell.ShellId) => { - const shell = await this.agentClient.shells.open(shellId, dimensions); + const barrier = new Barrier(); + this.agentClient.shells.subscribeOutput( + shellId, + dimensions, + ({ out }) => { + if (barrier.isOpen()) { + this.onOutputEmitter.fire(out); - this.output = shell.buffer; + this.output.push(out); + if (this.output.length > 1000) { + this.output.shift(); + } + } else { + this.output.push(out); + barrier.open(out); + } + } + ); + const result = await barrier.wait(); + + if (result.status === "disposed") { + return ""; + } - return this.output.join("\n"); + return result.value; }; if (this.step.shellId) { diff --git a/src/SandboxClient/tasks.ts b/src/SandboxClient/tasks.ts index 70d7a8e..2f6928a 100644 --- a/src/SandboxClient/tasks.ts +++ b/src/SandboxClient/tasks.ts @@ -107,6 +107,7 @@ export class Task { output: string[]; dimensions: typeof DEFAULT_SHELL_SIZE; }; + private currentSubscribeOutput?: IDisposable; private onOutputEmitter = this.disposable.addDisposable( new Emitter() ); @@ -174,36 +175,19 @@ export class Task { task.shell && task.shell.shellId !== lastShellId ) { - const openedShell = await this.agentClient.shells.open( + const openedShell = this.openedShell; + this.currentSubscribeOutput?.dispose(); + this.openedShell.shellId = task.shell.shellId; + this.currentSubscribeOutput = this.agentClient.shells.subscribeOutput( task.shell.shellId, - this.openedShell.dimensions + this.openedShell.dimensions, + ({ out }) => { + this.onOutputEmitter.fire("\x1B[2J\x1B[3J\x1B[1;1H"); + openedShell.output.push(out); + this.onOutputEmitter.fire(out); + } ); - - this.openedShell = { - shellId: openedShell.shellId, - output: openedShell.buffer, - dimensions: this.openedShell.dimensions, - }; - - this.onOutputEmitter.fire("\x1B[2J\x1B[3J\x1B[1;1H"); - openedShell.buffer.forEach((out) => this.onOutputEmitter.fire(out)); - } - }) - ); - - this.disposable.addDisposable( - this.agentClient.shells.onShellOut(({ shellId, out }) => { - if ( - !this.shell || - this.shell.shellId !== shellId || - !this.openedShell - ) { - return; } - - // Update output for shell - this.openedShell.output.push(out); - this.onOutputEmitter.fire(out); }) ); } @@ -256,16 +240,24 @@ export class Task { throw new Error("Task is not running"); } - const openedShell = await this.agentClient.shells.open( - this.shell.shellId, - dimensions - ); + if (this.openedShell) { + return this.openedShell.output.join("\n"); + } - this.openedShell = { - shellId: openedShell.shellId, - output: openedShell.buffer, + const openedShell = (this.openedShell = { dimensions, - }; + output: [] as string[], + shellId: this.shell.shellId, + }); + + this.currentSubscribeOutput = this.agentClient.shells.subscribeOutput( + this.shell.shellId, + dimensions, + ({ out }) => { + openedShell.output.push(out); + this.onOutputEmitter.fire(out); + } + ); return this.openedShell.output.join("\n"); } @@ -356,6 +348,7 @@ export class Task { ); } dispose() { + this.currentSubscribeOutput?.dispose(); this.disposable.dispose(); } } diff --git a/src/SandboxClient/terminals.ts b/src/SandboxClient/terminals.ts index 34522e5..7775d62 100644 --- a/src/SandboxClient/terminals.ts +++ b/src/SandboxClient/terminals.ts @@ -4,6 +4,7 @@ import { Emitter } from "../utils/event"; import { isCommandShell, ShellRunOpts } from "./commands"; import { type IAgentClient } from "../agent-client-interface"; import { Tracer, SpanStatusCode } from "@opentelemetry/api"; +import { Barrier } from "../utils/barrier"; export type ShellSize = { cols: number; rows: number }; @@ -71,26 +72,26 @@ export class Terminals { hasDimensions: !!opts?.dimensions, }, async () => { - const allEnv = Object.assign(opts?.env ?? {}); + const passedEnv = Object.assign(opts?.env ?? {}); - // TODO: use a new shell API that natively supports cwd & env - let commandWithEnv = Object.keys(allEnv).length - ? `source $HOME/.private/.env 2>/dev/null || true && env ${Object.entries( - allEnv - ) - .map(([key, value]) => `${key}=${value}`) - .join(" ")} ${command}` - : `source $HOME/.private/.env 2>/dev/null || true && ${command}`; + // Build bash args array + const args = ["source $HOME/.private/.env 2>/dev/null || true"]; + + if (Object.keys(passedEnv).length) { + Object.entries(passedEnv).forEach(([key, value]) => { + args.push("&&", "env", `${key}=${value}`); + }); + } if (opts?.cwd) { - commandWithEnv = `cd ${opts.cwd} && ${commandWithEnv}`; + args.push("&&", "cd", opts.cwd); } const shell = await this.agentClient.shells.create({ projectPath: this.agentClient.workspacePath, size: opts?.dimensions ?? DEFAULT_SHELL_SIZE, - command: "bash", - args: [commandWithEnv], + command, + args: [], type: "TERMINAL", isSystemShell: true, }); @@ -99,7 +100,11 @@ export class Terminals { this.agentClient.shells.rename(shell.shellId, opts.name); } - return new Terminal(shell, this.agentClient, this.tracer); + const terminal = new Terminal(shell, this.agentClient, this.tracer); + + await terminal.write(args.join(" ") + "\n"); + + return terminal; } ); } @@ -144,6 +149,7 @@ export class Terminal { ); public readonly onOutput = this.onOutputEmitter.event; private output = this.shell.buffer || []; + private isSubscribingOutput = false; /** * Gets the ID of the terminal. Can be used to open it again. @@ -165,18 +171,6 @@ export class Terminal { tracer?: Tracer ) { this.tracer = tracer; - this.disposable.addDisposable( - this.agentClient.shells.onShellOut(({ shellId, out }) => { - if (shellId === this.shell.shellId) { - this.onOutputEmitter.fire(out); - - this.output.push(out); - if (this.output.length > 1000) { - this.output.shift(); - } - } - }) - ); } private async withSpan( @@ -224,14 +218,44 @@ export class Terminal { rows: dimensions.rows, }, async () => { - const shell = await this.agentClient.shells.open( - this.shell.shellId, - dimensions + if (this.isSubscribingOutput) { + return this.output.join("\n"); + } + + const barrier = new Barrier(); + + this.disposable.addDisposable( + this.agentClient.shells.subscribeOutput( + this.shell.shellId, + dimensions, + ({ out }) => { + if (barrier.isOpen()) { + this.onOutputEmitter.fire(out); + + this.output.push(out); + if (this.output.length > 1000) { + this.output.shift(); + } + } else { + this.output.push(out); + barrier.open(out); + } + } + ) ); - this.output = shell.buffer; + this.disposable.onDidDispose(() => { + barrier.dispose(); + }); + + const result = await barrier.wait(); + + // This will never really happen + if (result.status === "disposed") { + return ""; + } - return this.output.join("\n"); + return result.value; } ); } diff --git a/src/agent-client-interface.ts b/src/agent-client-interface.ts index 2f84815..2bcc33d 100644 --- a/src/agent-client-interface.ts +++ b/src/agent-client-interface.ts @@ -1,3 +1,4 @@ +import { IDisposable } from "@xterm/headless"; import { fs, port, @@ -11,13 +12,16 @@ import { } from "./pitcher-protocol"; import { Event } from "./utils/event"; +export type SubscribeShellEvent = + | { + type: "exit"; + exitCode: number; + } + | { + type: "terminate"; + }; + export interface IAgentClientShells { - onShellExited: Event<{ - shellId: string; - exitCode: number; - }>; - onShellTerminated: Event; - onShellOut: Event; create(options: { command: string; args: string[]; @@ -28,10 +32,15 @@ export interface IAgentClientShells { }): Promise; rename(shellId: shell.ShellId, name: string): Promise; getShells(): Promise; - open( + subscribe( shellId: shell.ShellId, - size: shell.ShellSize - ): Promise; + listener: (event: SubscribeShellEvent) => void + ): IDisposable; + subscribeOutput( + shellId: shell.ShellId, + size: shell.ShellSize, + listener: (event: { out: string; exitCode?: number }) => void + ): IDisposable; delete( shellId: shell.ShellId ): Promise; From fee1c59639b863d3d7734a12219a948559273dcc Mon Sep 17 00:00:00 2001 From: Joji Augustine Date: Wed, 26 Nov 2025 17:59:48 +0100 Subject: [PATCH 03/46] add pint detection --- openapi.json | 39 ++++++++++++++++++++++++++--- src/API.ts | 3 +++ src/Sandbox.ts | 3 +-- src/api-clients/client/types.gen.ts | 29 ++++++++++++++++++++- src/types.ts | 3 +++ 5 files changed, 71 insertions(+), 6 deletions(-) diff --git a/openapi.json b/openapi.json index 17bd4c6..4f107f7 100644 --- a/openapi.json +++ b/openapi.json @@ -130,6 +130,36 @@ "example": "pt_1234567890", "type": "string" }, + "image": { + "description": "Container image to use as template", + "properties": { + "architecture": { + "description": "The architecture of the image. Required for multi-platform images", + "type": "string" + }, + "name": { + "description": "The image name (for example 'nginx').", + "type": "string" + }, + "registry": { + "default": "docker.io", + "description": "The container registry where the image is stored.", + "type": "string" + }, + "repository": { + "default": "library", + "description": "The repository or namespace where the image is stored.", + "type": "string" + }, + "tag": { + "default": "latest", + "description": "The image tag.", + "type": "string" + } + }, + "required": ["name"], + "type": "object" + }, "tags": { "default": [], "description": "Tags to set on the new sandbox, if any. Will not inherit tags from the source sandbox.", @@ -143,7 +173,6 @@ "type": "string" } }, - "required": ["forkOf"], "title": "TemplateCreateRequest", "type": "object" }, @@ -941,6 +970,7 @@ "reconnect_token": { "type": "string" }, "use_pint": { "type": "boolean" }, "user_workspace_path": { "type": "string" }, + "vm_agent_type": { "type": "string" }, "workspace_path": { "type": "string" } }, "required": [ @@ -955,7 +985,8 @@ "reconnect_token", "use_pint", "user_workspace_path", - "workspace_path" + "workspace_path", + "vm_agent_type" ], "type": "object" } @@ -1555,6 +1586,7 @@ "reconnect_token": { "type": "string" }, "use_pint": { "type": "boolean" }, "user_workspace_path": { "type": "string" }, + "vm_agent_type": { "type": "string" }, "workspace_path": { "type": "string" } }, "required": [ @@ -1569,7 +1601,8 @@ "reconnect_token", "use_pint", "user_workspace_path", - "workspace_path" + "workspace_path", + "vm_agent_type" ], "type": "object" }, diff --git a/src/API.ts b/src/API.ts index c1fd419..e9759a1 100644 --- a/src/API.ts +++ b/src/API.ts @@ -346,6 +346,9 @@ export class API { pitcherVersion: handledResponse.pitcher_version, latestPitcherVersion: handledResponse.latest_pitcher_version, pitcherToken: handledResponse.pitcher_token, + vmAgentType: handledResponse.vm_agent_type, + pintURL: handledResponse.pint_url, + pintToken: handledResponse.pint_token, }; } diff --git a/src/Sandbox.ts b/src/Sandbox.ts index cc4ad7f..7f1cdc2 100644 --- a/src/Sandbox.ts +++ b/src/Sandbox.ts @@ -188,8 +188,7 @@ export class Sandbox { pitcherManagerResponse: PitcherManagerResponse, customSession?: SessionCreateOptions ): Promise { - // HACK: we currently do not get a flag for pint, but this is a check we can use for now - const isPint = false; + const isPint = pitcherManagerResponse.vmAgentType === "pint"; if (!customSession || !customSession.id) { return { diff --git a/src/api-clients/client/types.gen.ts b/src/api-clients/client/types.gen.ts index e30ac60..22996d4 100644 --- a/src/api-clients/client/types.gen.ts +++ b/src/api-clients/client/types.gen.ts @@ -80,7 +80,32 @@ export type TemplateCreateRequest = { /** * Short ID of the sandbox to fork. */ - forkOf: string; + forkOf?: string; + /** + * Container image to use as template + */ + image?: { + /** + * The architecture of the image. Required for multi-platform images + */ + architecture?: string; + /** + * The image name (for example 'nginx'). + */ + name: string; + /** + * The container registry where the image is stored. + */ + registry?: string; + /** + * The repository or namespace where the image is stored. + */ + repository?: string; + /** + * The image tag. + */ + tag?: string; + }; /** * Tags to set on the new sandbox, if any. Will not inherit tags from the source sandbox. */ @@ -599,6 +624,7 @@ export type VmStartResponse = { reconnect_token: string; use_pint: boolean; user_workspace_path: string; + vm_agent_type: string; workspace_path: string; }; }; @@ -964,6 +990,7 @@ export type SandboxForkResponse = { reconnect_token: string; use_pint: boolean; user_workspace_path: string; + vm_agent_type: string; workspace_path: string; } | null; title: string | null; diff --git a/src/types.ts b/src/types.ts index 952d14e..d80903a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,9 @@ export interface PitcherManagerResponse { latestPitcherVersion: string; pitcherToken: string; cluster: string; + vmAgentType?: string; + pintURL?: string; + pintToken?: string; } export interface SystemMetricsStatus { From f3722ee2c44bc32355c5efb1ae3ebc26a08eb869 Mon Sep 17 00:00:00 2001 From: mohaimen Date: Wed, 26 Nov 2025 23:23:20 +0100 Subject: [PATCH 04/46] add proper pint client detection --- openapi.json | 12 ++++++++---- package.json | 1 + src/API.ts | 7 +++++-- src/Sandbox.ts | 11 ++++++++--- src/api-clients/client/types.gen.ts | 2 ++ src/types.ts | 3 +++ 6 files changed, 27 insertions(+), 9 deletions(-) diff --git a/openapi.json b/openapi.json index 17bd4c6..af5ad26 100644 --- a/openapi.json +++ b/openapi.json @@ -941,7 +941,8 @@ "reconnect_token": { "type": "string" }, "use_pint": { "type": "boolean" }, "user_workspace_path": { "type": "string" }, - "workspace_path": { "type": "string" } + "workspace_path": { "type": "string" }, + "vm_agent_type": { "type": "string" } }, "required": [ "bootup_type", @@ -955,7 +956,8 @@ "reconnect_token", "use_pint", "user_workspace_path", - "workspace_path" + "workspace_path", + "vm_agent_type" ], "type": "object" } @@ -1555,7 +1557,8 @@ "reconnect_token": { "type": "string" }, "use_pint": { "type": "boolean" }, "user_workspace_path": { "type": "string" }, - "workspace_path": { "type": "string" } + "workspace_path": { "type": "string" }, + "vm_agent_type": { "type": "string" } }, "required": [ "bootup_type", @@ -1569,7 +1572,8 @@ "reconnect_token", "use_pint", "user_workspace_path", - "workspace_path" + "workspace_path", + "vm_agent_type" ], "type": "object" }, diff --git a/package.json b/package.json index 5cd880a..6a8ca72 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "build:cjs:types": "tsc -p ./tsconfig.build-cjs.json --emitDeclarationOnly", "build:esm:types": "tsc -p ./tsconfig.build-esm.json --emitDeclarationOnly", "build-openapi": "rimraf src/api-clients && curl -o openapi.json https://api.codesandbox.io/meta/openapi && npx prettier --write ./openapi.json && node_modules/.bin/openapi-ts -i ./openapi.json -o src/api-clients/client -c @hey-api/client-fetch && npm run build-openapi-pint", + "build-openapi-local": "rimraf src/api-clients && npx prettier --write ./openapi.json && node_modules/.bin/openapi-ts -i ./openapi.json -o src/api-clients/client -c @hey-api/client-fetch && npm run build-openapi-pint", "build-openapi:staging": "rimraf src/api-clients && curl -o openapi.json https://api.codesandbox.stream/meta/openapi && npx prettier --write ./openapi.json && node_modules/.bin/openapi-ts -i ./openapi.json -o src/api-clients/client -c @hey-api/client-fetch && npm run build-openapi-rest", "build-openapi-rest": "npm run build-openapi-rest-fs && npm run build-openapi-rest-task && npm run build-openapi-rest-container && npm run build-openapi-rest-git && npm run build-openapi-rest-setup && npm run build-openapi-rest-shell && npm run build-openapi-rest-system", "build-openapi-rest-container": "node_modules/.bin/openapi-ts -i ./openapi-sandbox-container.json -o src/api-clients/client-rest-container -c @hey-api/client-fetch", diff --git a/src/API.ts b/src/API.ts index c1fd419..bd04ba8 100644 --- a/src/API.ts +++ b/src/API.ts @@ -336,8 +336,8 @@ export class API { ); return { - bootupType: - handledResponse.bootup_type as PitcherManagerResponse["bootupType"], + bootupType: + handledResponse.bootup_type as PitcherManagerResponse["bootupType"], cluster: handledResponse.cluster, pitcherURL: handledResponse.pitcher_url, workspacePath: handledResponse.workspace_path, @@ -346,6 +346,9 @@ export class API { pitcherVersion: handledResponse.pitcher_version, latestPitcherVersion: handledResponse.latest_pitcher_version, pitcherToken: handledResponse.pitcher_token, + pintToken: handledResponse.pint_token, + pintURL: handledResponse.pint_url, + vmAgentType: handledResponse.vm_agent_type, }; } diff --git a/src/Sandbox.ts b/src/Sandbox.ts index 2952b79..de1a5d5 100644 --- a/src/Sandbox.ts +++ b/src/Sandbox.ts @@ -189,9 +189,7 @@ export class Sandbox { customSession?: SessionCreateOptions ): Promise { // HACK: we currently do not get a flag for pint, but this is a check we can use for now - const isPint = - pitcherManagerResponse.userWorkspacePath === - pitcherManagerResponse.workspacePath; + const isPint = pitcherManagerResponse.vmAgentType === "pint"; if (!customSession || !customSession.id) { return { @@ -207,6 +205,10 @@ export class Sandbox { userWorkspacePath: pitcherManagerResponse.userWorkspacePath, workspacePath: pitcherManagerResponse.workspacePath, pitcherVersion: pitcherManagerResponse.pitcherVersion, + pintToken: pitcherManagerResponse.pintToken, + pintURL: pitcherManagerResponse.pintURL, + vmAgentType: pitcherManagerResponse.vmAgentType, + }; } @@ -233,6 +235,9 @@ export class Sandbox { userWorkspacePath: handledResponse.user_workspace_path, workspacePath: pitcherManagerResponse.workspacePath, pitcherVersion: pitcherManagerResponse.pitcherVersion, + pintToken: pitcherManagerResponse.pintToken, + pintURL: pitcherManagerResponse.pintURL, + vmAgentType: pitcherManagerResponse.vmAgentType, }; } diff --git a/src/api-clients/client/types.gen.ts b/src/api-clients/client/types.gen.ts index e30ac60..329999d 100644 --- a/src/api-clients/client/types.gen.ts +++ b/src/api-clients/client/types.gen.ts @@ -600,6 +600,7 @@ export type VmStartResponse = { use_pint: boolean; user_workspace_path: string; workspace_path: string; + vm_agent_type: string; }; }; @@ -965,6 +966,7 @@ export type SandboxForkResponse = { use_pint: boolean; user_workspace_path: string; workspace_path: string; + vm_agent_type: string; } | null; title: string | null; }; diff --git a/src/types.ts b/src/types.ts index 952d14e..09ecc3f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,9 @@ export interface PitcherManagerResponse { latestPitcherVersion: string; pitcherToken: string; cluster: string; + vmAgentType: string; + pintURL?: string; + pintToken?: string; } export interface SystemMetricsStatus { From ad25dff8fb411a5bbe081dc1d36cf3093d03b8e7 Mon Sep 17 00:00:00 2001 From: Joji Augustine Date: Thu, 27 Nov 2025 15:10:23 +0100 Subject: [PATCH 05/46] make vm agent in pitcher manager response non optional --- src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types.ts b/src/types.ts index d80903a..09ecc3f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,7 +13,7 @@ export interface PitcherManagerResponse { latestPitcherVersion: string; pitcherToken: string; cluster: string; - vmAgentType?: string; + vmAgentType: string; pintURL?: string; pintToken?: string; } From 565b10f3d6f4c1171cce2634a328170aa532fa2e Mon Sep 17 00:00:00 2001 From: Joji Augustine Date: Thu, 27 Nov 2025 15:28:36 +0100 Subject: [PATCH 06/46] fix build errors --- src/Sandbox.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Sandbox.ts b/src/Sandbox.ts index 7f1cdc2..de2c513 100644 --- a/src/Sandbox.ts +++ b/src/Sandbox.ts @@ -204,6 +204,9 @@ export class Sandbox { userWorkspacePath: pitcherManagerResponse.userWorkspacePath, workspacePath: pitcherManagerResponse.workspacePath, pitcherVersion: pitcherManagerResponse.pitcherVersion, + vmAgentType: pitcherManagerResponse.vmAgentType, + pintURL: pitcherManagerResponse.pintURL, + pintToken: pitcherManagerResponse.pintToken, }; } @@ -230,6 +233,9 @@ export class Sandbox { userWorkspacePath: handledResponse.user_workspace_path, workspacePath: pitcherManagerResponse.workspacePath, pitcherVersion: pitcherManagerResponse.pitcherVersion, + vmAgentType: pitcherManagerResponse.vmAgentType, + pintURL: pitcherManagerResponse.pintURL, + pintToken: pitcherManagerResponse.pintToken, }; } From 459c7afc5613962a88f228e02a9ffade799693c1 Mon Sep 17 00:00:00 2001 From: Joji Augustine Date: Thu, 27 Nov 2025 15:43:47 +0100 Subject: [PATCH 07/46] fix build error --- src/api-clients/client/types.gen.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/api-clients/client/types.gen.ts b/src/api-clients/client/types.gen.ts index 85c4e1a..22996d4 100644 --- a/src/api-clients/client/types.gen.ts +++ b/src/api-clients/client/types.gen.ts @@ -626,7 +626,6 @@ export type VmStartResponse = { user_workspace_path: string; vm_agent_type: string; workspace_path: string; - vm_agent_type: string; }; }; @@ -993,7 +992,6 @@ export type SandboxForkResponse = { user_workspace_path: string; vm_agent_type: string; workspace_path: string; - vm_agent_type: string; } | null; title: string | null; }; From 8b7340d54c456c67aa6d18b029d1a52551236a71 Mon Sep 17 00:00:00 2001 From: Christian Alfoni Date: Fri, 28 Nov 2025 14:40:07 +0100 Subject: [PATCH 08/46] separate how commands/terminals run on pitcher/pint --- openapi-git-spec.json | 1348 ------------------ openapi-git.json | 0 openapi-port.json | 151 -- openapi-sandbox-container.json | 179 --- openapi-sandbox-fs.json | 2005 --------------------------- openapi-sandbox-git.json | 1369 ------------------ openapi-sandbox-setup.json | 570 -------- openapi-sandbox-shell.json | 916 ------------ openapi-sandbox-system.json | 348 ----- openapi-sandbox-task.json | 947 ------------- package.json | 2 +- src/AgentClient/index.ts | 3 +- src/SandboxClient/commands.ts | 78 +- src/SandboxClient/terminals.ts | 49 +- tests/e2e/sandbox-terminals.test.ts | 82 +- 15 files changed, 172 insertions(+), 7875 deletions(-) delete mode 100644 openapi-git-spec.json delete mode 100644 openapi-git.json delete mode 100644 openapi-port.json delete mode 100644 openapi-sandbox-container.json delete mode 100644 openapi-sandbox-fs.json delete mode 100644 openapi-sandbox-git.json delete mode 100644 openapi-sandbox-setup.json delete mode 100644 openapi-sandbox-shell.json delete mode 100644 openapi-sandbox-system.json delete mode 100644 openapi-sandbox-task.json diff --git a/openapi-git-spec.json b/openapi-git-spec.json deleted file mode 100644 index 3a8d660..0000000 --- a/openapi-git-spec.json +++ /dev/null @@ -1,1348 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Git API", - "description": "API for interacting with Git version control in sandboxes", - "version": "1.0.0" - }, - "paths": { - "/git/status": { - "post": { - "summary": "Get git status", - "description": "Retrieve the current git status of the repository", - "operationId": "gitStatus", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/GitStatus" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving git status", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/remotes": { - "post": { - "summary": "Get git remotes", - "description": "Retrieve the remote repositories configured for the git repository", - "operationId": "gitRemotes", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/GitRemotes" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving git remotes", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/targetDiff": { - "post": { - "summary": "Get target diff", - "description": "Retrieve the difference between the current branch and a target branch", - "operationId": "gitTargetDiff", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "branch": { - "type": "string", - "description": "Target branch name" - } - }, - "required": ["branch"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/GitTargetDiff" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving target diff", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/pull": { - "post": { - "summary": "Pull changes", - "description": "Pull changes from the remote repository", - "operationId": "gitPull", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "branch": { - "type": "string", - "description": "Branch to pull from" - }, - "force": { - "type": "boolean", - "description": "Force pull" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error pulling changes", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/discard": { - "post": { - "summary": "Discard changes", - "description": "Discard changes to specified paths or all changes", - "operationId": "gitDiscard", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "paths": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Paths to discard changes for" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "paths": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Paths that were discarded" - } - } - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error discarding changes", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/commit": { - "post": { - "summary": "Commit changes", - "description": "Commit changes to the local repository", - "operationId": "gitCommit", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "paths": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Paths to commit" - }, - "message": { - "type": "string", - "description": "Commit message" - }, - "push": { - "type": "boolean", - "description": "Whether to push after committing" - } - }, - "required": ["message"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "shellId": { - "type": "string", - "description": "ID of the shell process" - } - }, - "required": ["shellId"] - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error committing changes", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/push": { - "post": { - "summary": "Push changes", - "description": "Push changes to the remote repository", - "operationId": "gitPush", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error pushing changes", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/pushToRemote": { - "post": { - "summary": "Push to remote", - "description": "Push changes to a specific remote repository and branch", - "operationId": "gitPushToRemote", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "URL of the remote repository" - }, - "branch": { - "type": "string", - "description": "Branch to push to" - }, - "squashAllCommits": { - "type": "boolean", - "description": "Whether to squash all commits before pushing" - } - }, - "required": ["url", "branch"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error pushing to remote", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/renameBranch": { - "post": { - "summary": "Rename branch", - "description": "Rename a branch in the local repository", - "operationId": "gitRenameBranch", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "oldBranch": { - "type": "string", - "description": "Name of the branch to rename" - }, - "newBranch": { - "type": "string", - "description": "New name for the branch" - } - }, - "required": ["oldBranch", "newBranch"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error renaming branch", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/remoteContent": { - "post": { - "summary": "Get remote content", - "description": "Retrieve the content of a file from a remote branch or commit", - "operationId": "gitRemoteContent", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GitRemoteParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "Content of the file" - } - }, - "required": ["content"] - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving remote content", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/diffStatus": { - "post": { - "summary": "Get diff status", - "description": "Retrieve the status of changes between two references", - "operationId": "gitDiffStatus", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GitDiffStatusParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/GitDiffStatusResult" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving diff status", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/resetLocalWithRemote": { - "post": { - "summary": "Reset local with remote", - "description": "Reset the local repository to match the remote", - "operationId": "gitResetLocalWithRemote", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error resetting local with remote", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/checkoutInitialBranch": { - "post": { - "summary": "Checkout initial branch", - "description": "Checkout the initial branch of the repository", - "operationId": "gitCheckoutInitialBranch", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error checking out initial branch", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/transposeLines": { - "post": { - "summary": "Transpose lines", - "description": "Map line numbers between different git commits", - "operationId": "gitTransposeLines", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "sha": { - "type": "string", - "description": "Commit SHA" - }, - "path": { - "type": "string", - "description": "File path" - }, - "line": { - "type": "number", - "description": "Line number" - } - }, - "required": ["sha", "path", "line"] - } - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "array", - "items": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "File path" - }, - "line": { - "type": "number", - "description": "Line number" - } - }, - "required": ["path", "line"], - "nullable": true - } - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error transposing lines", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "SuccessResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [0], - "description": "Status code for successful operations" - }, - "result": { - "type": "object", - "description": "Result payload for the operation" - } - }, - "required": ["status", "result"] - }, - "ErrorResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [1], - "description": "Status code for error operations" - }, - "error": { - "type": "object", - "description": "Error details" - } - }, - "required": ["status", "error"] - }, - "CommonError": { - "type": "object", - "properties": { - "code": { - "type": "number", - "description": "Error code" - }, - "message": { - "type": "string", - "description": "Error message" - }, - "data": { - "type": "object", - "description": "Additional error data", - "nullable": true - } - }, - "required": ["code", "message"] - }, - "GitStatusShortFormat": { - "type": "string", - "enum": ["", "M", "A", "D", "R", "C", "U", "?"], - "description": "Git status short format codes" - }, - "GitItem": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "File path" - }, - "index": { - "$ref": "#/components/schemas/GitStatusShortFormat" - }, - "workingTree": { - "$ref": "#/components/schemas/GitStatusShortFormat" - }, - "isStaged": { - "type": "boolean", - "description": "Whether the file is staged" - }, - "isConflicted": { - "type": "boolean", - "description": "Whether the file has conflicts" - }, - "fileId": { - "type": "string", - "description": "File ID" - } - }, - "required": ["path", "index", "workingTree", "isStaged", "isConflicted"] - }, - "GitChangedFiles": { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/GitItem" - }, - "description": "Map of file IDs to GitItems" - }, - "GitBranchProperties": { - "type": "object", - "properties": { - "head": { - "type": "string", - "nullable": true, - "description": "Head commit" - }, - "branch": { - "type": "string", - "nullable": true, - "description": "Branch name" - }, - "ahead": { - "type": "number", - "description": "Number of commits ahead" - }, - "behind": { - "type": "number", - "description": "Number of commits behind" - }, - "safe": { - "type": "boolean", - "description": "Whether the branch is safe to use" - } - }, - "required": ["ahead", "behind", "safe"] - }, - "GitCommit": { - "type": "object", - "properties": { - "hash": { - "type": "string", - "description": "Commit hash" - }, - "date": { - "type": "string", - "description": "Commit date" - }, - "message": { - "type": "string", - "description": "Commit message" - }, - "author": { - "type": "string", - "description": "Commit author" - } - }, - "required": ["hash", "date", "message", "author"] - }, - "GitStatus": { - "type": "object", - "properties": { - "changedFiles": { - "$ref": "#/components/schemas/GitChangedFiles" - }, - "deletedFiles": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GitItem" - } - }, - "conflicts": { - "type": "boolean", - "description": "Whether there are remote conflicts" - }, - "localChanges": { - "type": "boolean", - "description": "Whether there are local changes" - }, - "remote": { - "$ref": "#/components/schemas/GitBranchProperties" - }, - "target": { - "$ref": "#/components/schemas/GitBranchProperties" - }, - "head": { - "type": "string", - "description": "Current HEAD commit" - }, - "commits": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GitCommit" - } - }, - "branch": { - "type": "string", - "nullable": true, - "description": "Current branch name" - }, - "isMerging": { - "type": "boolean", - "description": "Whether a merge is in progress" - } - }, - "required": [ - "changedFiles", - "deletedFiles", - "conflicts", - "localChanges", - "remote", - "target", - "commits", - "branch", - "isMerging" - ] - }, - "GitTargetDiff": { - "type": "object", - "properties": { - "ahead": { - "type": "number", - "description": "Number of commits ahead of target" - }, - "behind": { - "type": "number", - "description": "Number of commits behind target" - }, - "commits": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GitCommit" - } - } - }, - "required": ["ahead", "behind", "commits"] - }, - "GitRemotes": { - "type": "object", - "properties": { - "origin": { - "type": "string", - "description": "Origin remote URL" - }, - "upstream": { - "type": "string", - "description": "Upstream remote URL" - } - }, - "required": ["origin", "upstream"] - }, - "GitRemoteParams": { - "type": "object", - "properties": { - "reference": { - "type": "string", - "description": "Branch or commit hash" - }, - "path": { - "type": "string", - "description": "File path" - } - }, - "required": ["reference", "path"] - }, - "GitDiffStatusParams": { - "type": "object", - "properties": { - "base": { - "type": "string", - "description": "Base reference for diffing" - }, - "head": { - "type": "string", - "description": "Head reference for diffing" - } - }, - "required": ["base", "head"] - }, - "GitDiffStatusItem": { - "type": "object", - "properties": { - "status": { - "$ref": "#/components/schemas/GitStatusShortFormat" - }, - "path": { - "type": "string", - "description": "File path" - }, - "oldPath": { - "type": "string", - "description": "Original file path (for renames)" - }, - "hunks": { - "type": "array", - "items": { - "type": "object", - "properties": { - "original": { - "type": "object", - "properties": { - "start": { - "type": "number" - }, - "end": { - "type": "number" - } - }, - "required": ["start", "end"] - }, - "modified": { - "type": "object", - "properties": { - "start": { - "type": "number" - }, - "end": { - "type": "number" - } - }, - "required": ["start", "end"] - } - }, - "required": ["original", "modified"] - } - } - }, - "required": ["status", "path", "hunks"] - }, - "GitDiffStatusResult": { - "type": "object", - "properties": { - "files": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GitDiffStatusItem" - } - } - }, - "required": ["files"] - } - } - } -} diff --git a/openapi-git.json b/openapi-git.json deleted file mode 100644 index e69de29..0000000 diff --git a/openapi-port.json b/openapi-port.json deleted file mode 100644 index 176f301..0000000 --- a/openapi-port.json +++ /dev/null @@ -1,151 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Port API", - "description": "API for managing sandbox port operations", - "version": "1.0.0" - }, - "paths": { - "/port/list": { - "post": { - "summary": "List ports", - "description": "Retrieve a list of available ports and their URLs", - "operationId": "portList", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "list": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Port" - }, - "description": "List of available ports" - } - }, - "required": ["list"] - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error listing ports", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "SuccessResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [0], - "description": "Status code for successful operations" - }, - "result": { - "type": "object", - "description": "Result payload for the operation" - } - }, - "required": ["status", "result"] - }, - "ErrorResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [1], - "description": "Status code for error operations" - }, - "error": { - "type": "object", - "description": "Error details" - } - }, - "required": ["status", "error"] - }, - "CommonError": { - "type": "object", - "properties": { - "code": { - "type": "number", - "description": "Error code" - }, - "message": { - "type": "string", - "description": "Error message" - }, - "data": { - "type": "object", - "description": "Additional error data", - "nullable": true - } - }, - "required": ["code", "message"] - }, - "Port": { - "type": "object", - "properties": { - "port": { - "type": "number", - "description": "Port number" - }, - "url": { - "type": "string", - "description": "URL to access the service on this port" - } - }, - "required": ["port", "url"] - } - } - } -} diff --git a/openapi-sandbox-container.json b/openapi-sandbox-container.json deleted file mode 100644 index f272b37..0000000 --- a/openapi-sandbox-container.json +++ /dev/null @@ -1,179 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Sandbox Container API", - "description": "API for managing sandbox container operations", - "version": "1.0.0" - }, - "paths": { - "/container/setup": { - "post": { - "summary": "Setup container", - "description": "Set up a new container based on a template", - "operationId": "containerSetup", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "templateId": { - "type": "string", - "description": "Identifier of the template to use" - }, - "templateArgs": { - "type": "object", - "description": "Arguments for the template", - "additionalProperties": { - "type": "string" - } - }, - "features": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Feature identifier" - }, - "options": { - "type": "object", - "description": "Options for the feature", - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["id", "options"] - }, - "nullable": true - } - }, - "required": ["templateId", "templateArgs"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/TaskDTO" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error setting up container", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/ProtocolError" - } - } - } - ] - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "SuccessResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [0], - "description": "Status code for successful operations" - }, - "result": { - "type": "object", - "description": "Result payload for the operation" - } - }, - "required": ["status", "result"] - }, - "ErrorResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [1], - "description": "Status code for error operations" - }, - "error": { - "type": "object", - "description": "Error details" - } - }, - "required": ["status", "error"] - }, - "ProtocolError": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Error code" - }, - "message": { - "type": "string", - "description": "Error message" - }, - "data": { - "type": "object", - "description": "Additional error data", - "nullable": true - } - }, - "required": ["code", "message"] - }, - "TaskDTO": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Task identifier" - }, - "status": { - "type": "string", - "description": "Task status" - }, - "progress": { - "type": "number", - "description": "Task progress (0-100)" - } - }, - "required": ["id", "status", "progress"] - } - } - } -} diff --git a/openapi-sandbox-fs.json b/openapi-sandbox-fs.json deleted file mode 100644 index 57c3c6c..0000000 --- a/openapi-sandbox-fs.json +++ /dev/null @@ -1,2005 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Sandbox Rest FS API", - "description": "FS API for interacting with sandbox", - "version": "1.0.0" - }, - "paths": { - "/fs/writeFile": { - "post": { - "summary": "Write to a file", - "description": "Write content to a file at the specified path", - "operationId": "writeFile", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WriteFileRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": {} - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error writing file", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/read": { - "post": { - "summary": "Read file system", - "description": "Retrieve the latest snapshot of the server's MemoryFS file and children list", - "operationId": "fsRead", - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/FSReadResult" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error reading file system", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/DefaultError" - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/operation": { - "post": { - "summary": "Perform file system operation", - "description": "Send a tree operation reflecting filesystem operations", - "operationId": "fsOperation", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSOperationRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/FSOperationResult" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error performing operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/DefaultError" - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/search": { - "post": { - "summary": "Search files", - "description": "Search for content in files", - "operationId": "fsSearch", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSSearchParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SearchResult" - } - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error searching files", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/DefaultError" - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/streamingSearch": { - "post": { - "summary": "Start streaming search", - "description": "Start a streaming search for content in files", - "operationId": "fsStreamingSearch", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSStreamingSearchParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "searchId": { - "type": "string", - "description": "ID of the search operation" - } - } - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error starting streaming search", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/DefaultError" - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/cancelStreamingSearch": { - "post": { - "summary": "Cancel streaming search", - "description": "Cancel an ongoing streaming search", - "operationId": "fsCancelStreamingSearch", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "searchId": { - "type": "string", - "description": "ID of the search to cancel" - } - }, - "required": ["searchId"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "searchId": { - "type": "string", - "description": "ID of the cancelled search" - } - } - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error cancelling search", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/DefaultError" - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/pathSearch": { - "post": { - "summary": "Search file paths", - "description": "Search for file paths matching a pattern", - "operationId": "fsPathSearch", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PathSearchParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/PathSearchResult" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error searching paths", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/DefaultError" - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/upload": { - "post": { - "summary": "Upload file", - "description": "Upload a file to the specified parent directory", - "operationId": "fsUpload", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parentId": { - "type": "string", - "description": "ID of the parent directory" - }, - "filename": { - "type": "string", - "description": "Name of the file to create" - }, - "content": { - "type": "string", - "format": "binary", - "description": "File content as binary data" - } - }, - "required": ["parentId", "filename", "content"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "fileId": { - "type": "string", - "description": "ID of the created file" - } - } - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error uploading file", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/InvalidIdError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/download": { - "post": { - "summary": "Download files", - "description": "Download files at a specified path as a zip", - "operationId": "fsDownload", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to download" - }, - "excludes": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Glob patterns of files/folders to exclude from the download" - } - }, - "required": ["path"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "downloadUrl": { - "type": "string", - "description": "URL to download the files from" - } - } - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error creating download", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/DefaultError" - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/readFile": { - "post": { - "summary": "Read file content", - "description": "Read the content of a file at the specified path", - "operationId": "fsReadFile", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSReadFileParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/FSReadFileResult" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error reading file", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/readdir": { - "post": { - "summary": "Read directory contents", - "description": "List the contents of a directory at the specified path", - "operationId": "fsReadDir", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSReadDirParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/FSReadDirResult" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error reading directory", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/stat": { - "post": { - "summary": "Get file/directory stats", - "description": "Get stats for a file or directory at the specified path", - "operationId": "fsStat", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSStatParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/FSStatResult" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error getting stats", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/copy": { - "post": { - "summary": "Copy file/directory", - "description": "Copy a file or directory from one location to another", - "operationId": "fsCopy", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSCopyParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": {} - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error copying file/directory", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/rename": { - "post": { - "summary": "Rename file/directory", - "description": "Rename a file or directory (move from one location to another)", - "operationId": "fsRename", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSRenameParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": {} - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error renaming file/directory", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/remove": { - "post": { - "summary": "Remove file/directory", - "description": "Delete a file or directory at the specified path", - "operationId": "fsRemove", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSRemoveParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": {} - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error removing file/directory", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/mkdir": { - "post": { - "summary": "Create directory", - "description": "Create a new directory at the specified path", - "operationId": "fsMkdir", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSMkdirParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": {} - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error creating directory", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/watch": { - "post": { - "summary": "Watch file/directory", - "description": "Watch a file or directory for changes", - "operationId": "fsWatch", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSWatchParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/FSWatchResult" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error watching file/directory", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - }, - "/fs/unwatch": { - "post": { - "summary": "Stop watching file/directory", - "description": "Stop watching a file or directory for changes", - "operationId": "fsUnwatch", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FSUnwatchParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": {} - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error unwatching file/directory", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - } - } - ] - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "SuccessResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [0], - "description": "Status code for successful operations" - }, - "result": { - "type": "object", - "description": "Result payload for the operation" - } - }, - "required": ["status", "result"] - }, - "ErrorResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [1], - "description": "Status code for error operations" - }, - "error": { - "oneOf": [ - { - "$ref": "#/components/schemas/DefaultError" - }, - { - "$ref": "#/components/schemas/RawFsError" - } - ], - "discriminator": { - "propertyName": "code" - } - } - }, - "required": ["status", "error"] - }, - "DefaultError": { - "type": "object", - "properties": { - "code": { - "$ref": "#/components/schemas/PitcherErrorCode", - "description": "Error code identifying the type of error" - }, - "data": { - "type": "object", - "description": "Additional error details", - "nullable": true - }, - "publicMessage": { - "type": "string", - "description": "Human-readable error message that can be displayed to users", - "nullable": true - } - }, - "required": ["code"] - }, - "RawFsError": { - "type": "object", - "properties": { - "code": { - "type": "number", - "enum": [102], - "description": "RAWFS_ERROR code" - }, - "data": { - "type": "object", - "properties": { - "errno": { - "type": ["number", "null"], - "description": "File system error number, or null if not available" - } - }, - "required": ["errno"] - }, - "publicMessage": { - "type": "string", - "description": "Human-readable error message that can be displayed to users", - "nullable": true - } - }, - "required": ["code", "data"] - }, - "PitcherErrorCode": { - "type": "integer", - "description": "Enumeration of error codes", - "enum": [ - 0, 1, 2, 3, 100, 101, 102, 200, 201, 204, 300, 400, 404, 410, 420, - 430, 440, 450, 460, 470, 500, 600, 601, 602, 704, 800, 801, 802, 803, - 814 - ], - "x-enum-descriptions": [ - "CRITICAL_ERROR", - "FEATURE_UNAVAILABLE", - "NO_ACCESS", - "RATE_LIMIT", - "INVALID_ID", - "INVALID_PATH", - "RAWFS_ERROR", - "SHELL_NOT_ACCESSIBLE", - "SHELL_CLOSED", - "SHELL_NOT_FOUND", - "MODEL_NOT_FOUND", - "GIT_OPERATION_IN_PROGRESS", - "GIT_REMOTE_FILE_NOT_FOUND", - "GIT_FETCH_FAIL", - "GIT_PULL_CONFLICT", - "GIT_RESET_LOCAL_REMOTE_ERROR", - "GIT_PUSH_FAIL", - "GIT_RESET_CHECKOUT_INITIAL_BRANCH_FAIL", - "GIT_PULL_FAIL", - "GIT_TRANSPOSE_LINES_FAIL", - "CHANNEL_NOT_FOUND", - "CONFIG_FILE_ALREADY_EXISTS", - "TASK_NOT_FOUND", - "COMMAND_ALREADY_CONFIGURED", - "COMMAND_NOT_FOUND", - "AI_NOT_AVAILABLE", - "PROMPT_TOO_BIG", - "FAILED_TO_RESPOND", - "AI_TOO_FREQUENT_REQUESTS", - "AI_CHAT_NOT_FOUND" - ] - }, - "WriteFileRequest": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "File path to write to" - }, - "content": { - "type": "string", - "format": "binary", - "description": "File content as binary data (Uint8Array)" - }, - "create": { - "type": "boolean", - "description": "Whether to create the file if it doesn't exist", - "default": false - }, - "overwrite": { - "type": "boolean", - "description": "Whether to overwrite the file if it exists", - "default": false - } - }, - "required": ["path", "content"] - }, - "FSReadResult": { - "type": "object", - "properties": { - "treeNodes": { - "type": "array", - "items": { - "type": "object", - "description": "JSON representation of a node in the file system" - } - }, - "clock": { - "type": "number", - "description": "Current clock value for the file system" - } - }, - "required": ["treeNodes", "clock"] - }, - "FSOperationRequest": { - "type": "object", - "properties": { - "operation": { - "$ref": "#/components/schemas/FSOperation" - } - }, - "required": ["operation"] - }, - "FSOperation": { - "oneOf": [ - { - "$ref": "#/components/schemas/FSCreateOperation" - }, - { - "$ref": "#/components/schemas/FSDeleteOperation" - }, - { - "$ref": "#/components/schemas/FSMoveOperation" - } - ], - "discriminator": { - "propertyName": "type" - } - }, - "FSCreateOperation": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["create"] - }, - "parentId": { - "type": "string", - "description": "ID of the parent directory" - }, - "newEntry": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "ID of the new entry" - }, - "type": { - "type": "string", - "enum": ["directory", "file"], - "description": "Type of the node" - }, - "name": { - "type": "string", - "description": "Name of the new entry" - } - }, - "required": ["id", "type", "name"] - } - }, - "required": ["type", "parentId", "newEntry"] - }, - "FSDeleteOperation": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["delete"] - }, - "id": { - "type": "string", - "description": "ID of the entry to delete" - } - }, - "required": ["type", "id"] - }, - "FSMoveOperation": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["move"] - }, - "id": { - "type": "string", - "description": "ID of the entry to move" - }, - "parentId": { - "type": "string", - "description": "ID of the new parent directory", - "nullable": true - }, - "name": { - "type": "string", - "description": "New name for the entry", - "nullable": true - } - }, - "required": ["type", "id"] - }, - "FSOperationResult": { - "oneOf": [ - { - "type": "object", - "properties": { - "code": { - "type": "number", - "enum": [0], - "description": "Success code" - }, - "clock": { - "type": "number", - "description": "Current clock value" - } - }, - "required": ["code", "clock"] - }, - { - "type": "object", - "properties": { - "code": { - "type": "number", - "enum": [1], - "description": "Ignored code" - } - }, - "required": ["code"] - } - ], - "discriminator": { - "propertyName": "code" - } - }, - "FSSearchParams": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "Text to search for" - }, - "glob": { - "type": "string", - "description": "Glob pattern to filter files", - "nullable": true - }, - "isRegex": { - "type": "boolean", - "description": "Whether to treat the search text as a regular expression", - "nullable": true - }, - "caseSensitivity": { - "type": "string", - "enum": ["smart", "enabled", "disabled"], - "description": "Case sensitivity setting for the search", - "nullable": true - } - }, - "required": ["text"] - }, - "SearchResult": { - "type": "object", - "properties": { - "fileId": { - "type": "string", - "description": "ID of the file containing the match" - }, - "lines": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "Text of the line containing the match" - } - }, - "required": ["text"] - }, - "lineNumber": { - "type": "integer", - "description": "Line number of the match" - }, - "absoluteOffset": { - "type": "integer", - "description": "Absolute offset of the match in the file" - }, - "submatches": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SearchSubMatch" - } - } - }, - "required": [ - "fileId", - "lines", - "lineNumber", - "absoluteOffset", - "submatches" - ] - }, - "SearchSubMatch": { - "type": "object", - "properties": { - "match": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "Matched text" - } - }, - "required": ["text"] - }, - "start": { - "type": "integer", - "description": "Start position of the match" - }, - "end": { - "type": "integer", - "description": "End position of the match" - } - }, - "required": ["match", "start", "end"] - }, - "FSStreamingSearchParams": { - "type": "object", - "properties": { - "searchId": { - "type": "string", - "description": "ID for the search operation" - }, - "text": { - "type": "string", - "description": "Text to search for" - }, - "glob": { - "type": "string", - "description": "Glob pattern to filter files", - "nullable": true - }, - "isRegex": { - "type": "boolean", - "description": "Whether to treat the search text as a regular expression", - "nullable": true - }, - "caseSensitivity": { - "type": "string", - "enum": ["smart", "enabled", "disabled"], - "description": "Case sensitivity setting for the search", - "nullable": true - }, - "maxResults": { - "type": "integer", - "description": "Maximum number of results to return (default: 10,000)", - "nullable": true - } - }, - "required": ["searchId", "text"] - }, - "PathSearchParams": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "Text to search for in file paths" - } - }, - "required": ["text"] - }, - "PathSearchResult": { - "type": "object", - "properties": { - "matches": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PathSearchMatch" - } - } - }, - "required": ["matches"] - }, - "PathSearchMatch": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path that matched the search" - }, - "submatches": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SearchSubMatch" - } - } - }, - "required": ["path", "submatches"] - }, - "InvalidIdError": { - "type": "object", - "properties": { - "code": { - "type": "number", - "enum": [100], - "description": "INVALID_ID error code" - } - }, - "required": ["code"] - }, - "FSReadFileParams": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to the file to read" - } - }, - "required": ["path"] - }, - "FSReadFileResult": { - "type": "object", - "properties": { - "content": { - "type": "string", - "format": "binary", - "description": "File content as binary data" - } - }, - "required": ["content"] - }, - "FSReadDirParams": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to the directory to read" - } - }, - "required": ["path"] - }, - "FSReadDirResult": { - "type": "object", - "properties": { - "entries": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the entry" - }, - "type": { - "type": "string", - "enum": ["directory", "file"], - "description": "Type of the entry" - }, - "isSymlink": { - "type": "boolean", - "description": "Whether the entry is a symlink" - } - }, - "required": ["name", "type", "isSymlink"] - } - } - }, - "required": ["entries"] - }, - "FSStatParams": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to the file or directory to stat" - } - }, - "required": ["path"] - }, - "FSStatResult": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["directory", "file"], - "description": "Type of the entry" - }, - "isSymlink": { - "type": "boolean", - "description": "Whether the entry is a symlink" - }, - "size": { - "type": "integer", - "description": "Size of the file in bytes" - }, - "mtime": { - "type": "integer", - "description": "Last modified time" - }, - "ctime": { - "type": "integer", - "description": "Creation time" - }, - "atime": { - "type": "integer", - "description": "Last accessed time" - } - }, - "required": ["type", "isSymlink", "size", "mtime", "ctime", "atime"] - }, - "FSCopyParams": { - "type": "object", - "properties": { - "from": { - "type": "string", - "description": "Path to copy from" - }, - "to": { - "type": "string", - "description": "Path to copy to" - }, - "recursive": { - "type": "boolean", - "description": "Whether to copy directories recursively", - "nullable": true - }, - "overwrite": { - "type": "boolean", - "description": "Whether to overwrite existing files", - "nullable": true - } - }, - "required": ["from", "to"] - }, - "FSRenameParams": { - "type": "object", - "properties": { - "from": { - "type": "string", - "description": "Path to rename from" - }, - "to": { - "type": "string", - "description": "Path to rename to" - }, - "overwrite": { - "type": "boolean", - "description": "Whether to overwrite existing files", - "nullable": true - } - }, - "required": ["from", "to"] - }, - "FSRemoveParams": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to remove" - }, - "recursive": { - "type": "boolean", - "description": "Whether to remove directories recursively", - "nullable": true - } - }, - "required": ["path"] - }, - "FSMkdirParams": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to create directory at" - }, - "recursive": { - "type": "boolean", - "description": "Whether to create parent directories if they don't exist", - "nullable": true - } - }, - "required": ["path"] - }, - "FSWatchParams": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to watch" - }, - "recursive": { - "type": "boolean", - "description": "Whether to watch directories recursively", - "nullable": true - }, - "excludes": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Glob patterns to exclude from watching", - "nullable": true - } - }, - "required": ["path"] - }, - "FSWatchResult": { - "type": "object", - "properties": { - "watchId": { - "type": "string", - "description": "ID of the watch" - } - }, - "required": ["watchId"] - }, - "FSUnwatchParams": { - "type": "object", - "properties": { - "watchId": { - "type": "string", - "description": "ID of the watch to stop" - } - }, - "required": ["watchId"] - } - } - } -} diff --git a/openapi-sandbox-git.json b/openapi-sandbox-git.json deleted file mode 100644 index 827a6e9..0000000 --- a/openapi-sandbox-git.json +++ /dev/null @@ -1,1369 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Sandbox Git API", - "description": "API for managing git operations in CodeSandbox", - "version": "1.0.0" - }, - "paths": { - "/git/status": { - "post": { - "summary": "Get git status", - "description": "Retrieve current git status including changed files, branch information, and commits", - "operationId": "gitStatus", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/GitStatus" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving git status", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/remotes": { - "post": { - "summary": "Get git remotes", - "description": "Retrieve git remote information", - "operationId": "gitRemotes", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/GitRemotes" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving git remotes", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/targetDiff": { - "post": { - "summary": "Get git target diff", - "description": "Retrieve diff between current branch and target branch", - "operationId": "gitTargetDiff", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "branch": { - "type": "string", - "description": "Branch to compare against" - } - }, - "required": ["branch"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/GitTargetDiff" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving git target diff", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/pull": { - "post": { - "summary": "Pull from remote", - "description": "Pull changes from remote repository", - "operationId": "gitPull", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "branch": { - "type": "string", - "description": "Branch to pull from" - }, - "force": { - "type": "boolean", - "description": "Force pull even if there are conflicts" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error pulling from remote", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/discard": { - "post": { - "summary": "Discard changes", - "description": "Discard local changes for specified paths", - "operationId": "gitDiscard", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "paths": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Paths of files to discard changes" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "paths": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error discarding changes", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/commit": { - "post": { - "summary": "Commit changes", - "description": "Commit changes to the repository", - "operationId": "gitCommit", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "paths": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Paths of files to commit" - }, - "message": { - "type": "string", - "description": "Commit message" - }, - "push": { - "type": "boolean", - "description": "Whether to push the commit immediately" - } - }, - "required": ["message"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "shellId": { - "type": "string", - "description": "ID of the shell process" - } - }, - "required": ["shellId"] - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error committing changes", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/push": { - "post": { - "summary": "Push changes", - "description": "Push local commits to remote repository", - "operationId": "gitPush", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error pushing changes", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/pushToRemote": { - "post": { - "summary": "Push to remote", - "description": "Push to a specific remote repository", - "operationId": "gitPushToRemote", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "URL of the remote repository" - }, - "branch": { - "type": "string", - "description": "Branch to push to" - }, - "squashAllCommits": { - "type": "boolean", - "description": "Whether to squash all commits into one" - } - }, - "required": ["url", "branch"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error pushing to remote", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/renameBranch": { - "post": { - "summary": "Rename branch", - "description": "Rename a git branch", - "operationId": "gitRenameBranch", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "oldBranch": { - "type": "string", - "description": "Current branch name" - }, - "newBranch": { - "type": "string", - "description": "New branch name" - } - }, - "required": ["oldBranch", "newBranch"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error renaming branch", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/remoteContent": { - "post": { - "summary": "Get remote content", - "description": "Retrieve content from a remote repository", - "operationId": "gitRemoteContent", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GitRemoteParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "Content of the file" - } - }, - "required": ["content"] - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving remote content", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/diffStatus": { - "post": { - "summary": "Get diff status", - "description": "Retrieve diff status between two git references", - "operationId": "gitDiffStatus", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GitDiffStatusParams" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/GitDiffStatusResult" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving diff status", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/resetLocalWithRemote": { - "post": { - "summary": "Reset local with remote", - "description": "Reset local repository to match the remote state", - "operationId": "gitResetLocalWithRemote", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error resetting local with remote", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/checkoutInitialBranch": { - "post": { - "summary": "Checkout initial branch", - "description": "Checkout the initial branch of the repository", - "operationId": "gitCheckoutInitialBranch", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error checking out initial branch", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/git/transposeLines": { - "post": { - "summary": "Transpose lines", - "description": "Transpose line numbers from one git reference to another", - "operationId": "gitTransposeLines", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "sha": { - "type": "string", - "description": "Git commit SHA" - }, - "path": { - "type": "string", - "description": "Path to the file" - }, - "line": { - "type": "number", - "description": "Line number to transpose" - } - }, - "required": ["sha", "path", "line"] - } - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "array", - "items": { - "oneOf": [ - { - "type": "object", - "properties": { - "path": { - "type": "string" - }, - "line": { - "type": "number" - } - }, - "required": ["path", "line"] - }, - { - "type": "null" - } - ] - } - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error transposing lines", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "SuccessResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [0], - "description": "Status code for successful operations" - }, - "result": { - "type": "object", - "description": "Result payload for the operation" - } - }, - "required": ["status", "result"] - }, - "ErrorResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [1], - "description": "Status code for error operations" - }, - "error": { - "type": "object", - "description": "Error details" - } - }, - "required": ["status", "error"] - }, - "CommonError": { - "oneOf": [ - { - "type": "object", - "properties": { - "code": { - "type": "string", - "enum": [ - "GIT_OPERATION_IN_PROGRESS", - "GIT_REMOTE_FILE_NOT_FOUND" - ], - "description": "Error code" - }, - "message": { - "type": "string", - "description": "Error message" - } - }, - "required": ["code", "message"] - }, - { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Protocol error code" - }, - "message": { - "type": "string", - "description": "Error message" - }, - "data": { - "type": "object", - "description": "Additional error data" - } - }, - "required": ["code", "message"] - } - ] - }, - "GitStatusShortFormat": { - "type": "string", - "enum": ["", "M", "A", "D", "R", "C", "U", "?"], - "description": "Git status short format codes" - }, - "GitItem": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "File path" - }, - "index": { - "$ref": "#/components/schemas/GitStatusShortFormat" - }, - "workingTree": { - "$ref": "#/components/schemas/GitStatusShortFormat" - }, - "isStaged": { - "type": "boolean", - "description": "Whether the file is staged" - }, - "isConflicted": { - "type": "boolean", - "description": "Whether the file has conflicts" - }, - "fileId": { - "type": "string", - "description": "Unique identifier for the file" - } - }, - "required": ["path", "index", "workingTree", "isStaged", "isConflicted"] - }, - "GitChangedFiles": { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/GitItem" - }, - "description": "Map of file IDs to Git items" - }, - "GitBranchProperties": { - "type": "object", - "properties": { - "head": { - "type": ["string", "null"], - "description": "Current HEAD reference" - }, - "branch": { - "type": ["string", "null"], - "description": "Current branch name" - }, - "ahead": { - "type": "number", - "description": "Number of commits ahead of the remote" - }, - "behind": { - "type": "number", - "description": "Number of commits behind the remote" - }, - "safe": { - "type": "boolean", - "description": "Whether the branch is safe to operate on" - } - }, - "required": ["ahead", "behind", "safe"] - }, - "GitCommit": { - "type": "object", - "properties": { - "hash": { - "type": "string", - "description": "Commit hash" - }, - "date": { - "type": "string", - "description": "Commit date" - }, - "message": { - "type": "string", - "description": "Commit message" - }, - "author": { - "type": "string", - "description": "Commit author" - } - }, - "required": ["hash", "date", "message", "author"] - }, - "GitStatus": { - "type": "object", - "properties": { - "changedFiles": { - "$ref": "#/components/schemas/GitChangedFiles" - }, - "deletedFiles": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GitItem" - } - }, - "conflicts": { - "type": "boolean", - "description": "Whether there are remote conflicts" - }, - "localChanges": { - "type": "boolean", - "description": "Whether there are local changes" - }, - "remote": { - "$ref": "#/components/schemas/GitBranchProperties" - }, - "target": { - "$ref": "#/components/schemas/GitBranchProperties" - }, - "head": { - "type": "string", - "description": "Current HEAD reference" - }, - "commits": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GitCommit" - } - }, - "branch": { - "type": ["string", "null"], - "description": "Current branch name" - }, - "isMerging": { - "type": "boolean", - "description": "Whether a merge is in progress" - } - }, - "required": [ - "changedFiles", - "deletedFiles", - "conflicts", - "localChanges", - "remote", - "target", - "commits", - "branch", - "isMerging" - ] - }, - "GitTargetDiff": { - "type": "object", - "properties": { - "ahead": { - "type": "number", - "description": "Number of commits ahead of the target" - }, - "behind": { - "type": "number", - "description": "Number of commits behind the target" - }, - "commits": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GitCommit" - } - } - }, - "required": ["ahead", "behind", "commits"] - }, - "GitRemotes": { - "type": "object", - "properties": { - "origin": { - "type": "string", - "description": "Origin remote URL" - }, - "upstream": { - "type": "string", - "description": "Upstream remote URL" - } - }, - "required": ["origin", "upstream"] - }, - "GitRemoteParams": { - "type": "object", - "properties": { - "reference": { - "type": "string", - "description": "Branch or commit hash" - }, - "path": { - "type": "string", - "description": "Path to the file" - } - }, - "required": ["reference", "path"] - }, - "GitDiffStatusParams": { - "type": "object", - "properties": { - "base": { - "type": "string", - "description": "Base reference used for diffing" - }, - "head": { - "type": "string", - "description": "Head reference used for diffing" - } - }, - "required": ["base", "head"] - }, - "GitDiffStatusItem": { - "type": "object", - "properties": { - "status": { - "$ref": "#/components/schemas/GitStatusShortFormat" - }, - "path": { - "type": "string", - "description": "Path to the file" - }, - "oldPath": { - "type": "string", - "description": "Original path for renamed files" - }, - "hunks": { - "type": "array", - "items": { - "type": "object", - "properties": { - "original": { - "type": "object", - "properties": { - "start": { - "type": "number" - }, - "end": { - "type": "number" - } - }, - "required": ["start", "end"] - }, - "modified": { - "type": "object", - "properties": { - "start": { - "type": "number" - }, - "end": { - "type": "number" - } - }, - "required": ["start", "end"] - } - }, - "required": ["original", "modified"] - } - } - }, - "required": ["status", "path", "hunks"] - }, - "GitDiffStatusResult": { - "type": "object", - "properties": { - "files": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GitDiffStatusItem" - } - } - }, - "required": ["files"] - } - } - } -} diff --git a/openapi-sandbox-setup.json b/openapi-sandbox-setup.json deleted file mode 100644 index bc8b5c4..0000000 --- a/openapi-sandbox-setup.json +++ /dev/null @@ -1,570 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Sandbox Setup API", - "description": "API for managing sandbox setup operations", - "version": "1.0.0" - }, - "paths": { - "/setup/get": { - "post": { - "summary": "Get setup progress", - "description": "Retrieve the current setup progress status", - "operationId": "setupGet", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/SetupProgress" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving setup progress", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/ProtocolError" - } - } - } - ] - } - } - } - } - } - } - }, - "/setup/skip": { - "post": { - "summary": "Skip setup step", - "description": "Skip a specific step in the setup process", - "operationId": "setupSkipStep", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "stepIndexToSkip": { - "type": "number", - "description": "Index of the step to skip" - } - }, - "required": ["stepIndexToSkip"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/SetupProgress" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error skipping step", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/ProtocolError" - } - } - } - ] - } - } - } - } - } - } - }, - "/setup/skipAll": { - "post": { - "summary": "Skip all setup steps", - "description": "Skip all remaining steps in the setup process", - "operationId": "setupSkipAll", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "null" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/SetupProgress" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error skipping all steps", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/ProtocolError" - } - } - } - ] - } - } - } - } - } - } - }, - "/setup/disable": { - "post": { - "summary": "Disable setup", - "description": "Disable the setup process", - "operationId": "setupDisable", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "null" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/SetupProgress" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error disabling setup", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/ProtocolError" - } - } - } - ] - } - } - } - } - } - } - }, - "/setup/enable": { - "post": { - "summary": "Enable setup", - "description": "Enable the setup process", - "operationId": "setupEnable", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "null" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/SetupProgress" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error enabling setup", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/ProtocolError" - } - } - } - ] - } - } - } - } - } - } - }, - "/setup/init": { - "post": { - "summary": "Initialize setup", - "description": "Initialize the setup process", - "operationId": "setupInit", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "null" - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/SetupProgress" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error initializing setup", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/ProtocolError" - } - } - } - ] - } - } - } - } - } - } - }, - "/setup/setStep": { - "post": { - "summary": "Set current setup step", - "description": "Set the current step in the setup process (used for restarting)", - "operationId": "setupSetStep", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "stepIndex": { - "type": "number", - "description": "Index of the step to set as current" - } - }, - "required": ["stepIndex"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/SetupProgress" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error setting current step", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/ProtocolError" - } - } - } - ] - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "SuccessResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [0], - "description": "Status code for successful operations" - }, - "result": { - "type": "object", - "description": "Result payload for the operation" - } - }, - "required": ["status", "result"] - }, - "ErrorResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [1], - "description": "Status code for error operations" - }, - "error": { - "type": "object", - "description": "Error details" - } - }, - "required": ["status", "error"] - }, - "ProtocolError": { - "type": "object", - "properties": { - "code": { - "type": "number", - "description": "Error code" - }, - "message": { - "type": "string", - "description": "Error message" - }, - "data": { - "type": "object", - "description": "Additional error data", - "nullable": true - } - }, - "required": ["code", "message"] - }, - "SetupShellStatus": { - "type": "string", - "enum": ["SUCCEEDED", "FAILED", "SKIPPED"], - "description": "Status of a setup shell step" - }, - "Step": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the setup step" - }, - "command": { - "type": "string", - "description": "Command to execute for this step" - }, - "shellId": { - "type": "string", - "description": "ID of the shell executing the command", - "nullable": true - }, - "finishStatus": { - "$ref": "#/components/schemas/SetupShellStatus", - "nullable": true, - "description": "Status of the step after completion" - } - }, - "required": ["name", "command", "shellId", "finishStatus"] - }, - "SetupProgress": { - "type": "object", - "properties": { - "state": { - "type": "string", - "enum": ["IDLE", "IN_PROGRESS", "FINISHED", "STOPPED"], - "description": "Current state of the setup process" - }, - "steps": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Step" - }, - "description": "List of setup steps" - }, - "currentStepIndex": { - "type": "number", - "description": "Index of the current step being executed" - } - }, - "required": ["state", "steps", "currentStepIndex"] - } - } - } -} diff --git a/openapi-sandbox-shell.json b/openapi-sandbox-shell.json deleted file mode 100644 index e362489..0000000 --- a/openapi-sandbox-shell.json +++ /dev/null @@ -1,916 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Sandbox Shell API", - "description": "API for managing terminal and command shells in the sandbox", - "version": "1.0.0" - }, - "paths": { - "/shell/create": { - "post": { - "summary": "Create a new shell", - "description": "Creates a new terminal or command shell", - "operationId": "shellCreate", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "Command to execute in the shell" - }, - "cwd": { - "type": "string", - "description": "Working directory for the shell" - }, - "size": { - "$ref": "#/components/schemas/ShellSize", - "description": "Terminal size dimensions" - }, - "type": { - "$ref": "#/components/schemas/ShellProcessType", - "description": "Type of shell to create" - }, - "isSystemShell": { - "type": "boolean", - "description": "Whether this shell is started by the editor itself to run a specific process" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/OpenShellDTO" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error creating shell", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/shell/in": { - "post": { - "summary": "Send input to shell", - "description": "Sends user input to an active shell", - "operationId": "shellIn", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "shellId": { - "$ref": "#/components/schemas/ShellId", - "description": "ID of the target shell" - }, - "input": { - "type": "string", - "description": "Input to send to the shell" - }, - "size": { - "$ref": "#/components/schemas/ShellSize", - "description": "Current terminal dimensions" - } - }, - "required": ["shellId", "input", "size"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error sending input to shell", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/shell/list": { - "post": { - "summary": "List all shells", - "description": "Retrieves a list of all available shells", - "operationId": "shellList", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "shells": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ShellDTO" - } - } - }, - "required": ["shells"] - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error listing shells", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/shell/open": { - "post": { - "summary": "Open an existing shell", - "description": "Opens an existing shell and retrieves its buffer", - "operationId": "shellOpen", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "shellId": { - "$ref": "#/components/schemas/ShellId", - "description": "ID of the shell to open" - }, - "size": { - "$ref": "#/components/schemas/ShellSize", - "description": "Terminal dimensions" - } - }, - "required": ["shellId", "size"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/OpenShellDTO" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error opening shell", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/shell/close": { - "post": { - "summary": "Close a shell", - "description": "Closes a shell without terminating the underlying process", - "operationId": "shellClose", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "shellId": { - "$ref": "#/components/schemas/ShellId", - "description": "ID of the shell to close" - } - }, - "required": ["shellId"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error closing shell", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/shell/restart": { - "post": { - "summary": "Restart a shell", - "description": "Restarts an existing shell process", - "operationId": "shellRestart", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "shellId": { - "$ref": "#/components/schemas/ShellId", - "description": "ID of the shell to restart" - } - }, - "required": ["shellId"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error restarting shell", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/shell/terminate": { - "post": { - "summary": "Terminate a shell", - "description": "Terminates a shell and its underlying process", - "operationId": "shellTerminate", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "shellId": { - "$ref": "#/components/schemas/ShellId", - "description": "ID of the shell to terminate" - } - }, - "required": ["shellId"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/ShellDTO" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error terminating shell", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/shell/resize": { - "post": { - "summary": "Resize a shell", - "description": "Updates the dimensions of a shell", - "operationId": "shellResize", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "shellId": { - "$ref": "#/components/schemas/ShellId", - "description": "ID of the shell to resize" - }, - "size": { - "$ref": "#/components/schemas/ShellSize", - "description": "New terminal dimensions" - } - }, - "required": ["shellId", "size"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error resizing shell", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/shell/rename": { - "post": { - "summary": "Rename a shell", - "description": "Updates the name of a shell", - "operationId": "shellRename", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "shellId": { - "$ref": "#/components/schemas/ShellId", - "description": "ID of the shell to rename" - }, - "name": { - "type": "string", - "description": "New name for the shell" - } - }, - "required": ["shellId", "name"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error renaming shell", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "SuccessResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [0], - "description": "Status code for successful operations" - }, - "result": { - "type": "object", - "description": "Result payload for the operation" - } - }, - "required": ["status", "result"] - }, - "ErrorResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [1], - "description": "Status code for error operations" - }, - "error": { - "type": "object", - "description": "Error details" - } - }, - "required": ["status", "error"] - }, - "ShellId": { - "type": "string", - "description": "Unique identifier for a shell" - }, - "ShellSize": { - "type": "object", - "properties": { - "cols": { - "type": "number", - "description": "Number of columns in the terminal" - }, - "rows": { - "type": "number", - "description": "Number of rows in the terminal" - } - }, - "required": ["cols", "rows"] - }, - "ShellProcessType": { - "type": "string", - "enum": ["TERMINAL", "COMMAND"], - "description": "Type of shell process" - }, - "ShellProcessStatus": { - "type": "string", - "enum": ["RUNNING", "FINISHED", "ERROR", "KILLED", "RESTARTING"], - "description": "Current status of the shell process" - }, - "BaseShellDTO": { - "type": "object", - "properties": { - "shellId": { - "$ref": "#/components/schemas/ShellId" - }, - "name": { - "type": "string", - "description": "Display name of the shell" - }, - "status": { - "$ref": "#/components/schemas/ShellProcessStatus" - }, - "exitCode": { - "type": "number", - "description": "Exit code of the process if it has finished", - "nullable": true - } - }, - "required": ["shellId", "name", "status"] - }, - "CommandShellDTO": { - "allOf": [ - { - "$ref": "#/components/schemas/BaseShellDTO" - }, - { - "type": "object", - "properties": { - "shellType": { - "type": "string", - "enum": ["COMMAND"], - "description": "Indicates this is a command shell" - }, - "startCommand": { - "type": "string", - "description": "The command that was executed to start this shell" - } - }, - "required": ["shellType", "startCommand"] - } - ] - }, - "TerminalShellDTO": { - "allOf": [ - { - "$ref": "#/components/schemas/BaseShellDTO" - }, - { - "type": "object", - "properties": { - "shellType": { - "type": "string", - "enum": ["TERMINAL"], - "description": "Indicates this is a terminal shell" - }, - "ownerUsername": { - "type": "string", - "description": "Username of the shell owner" - }, - "isSystemShell": { - "type": "boolean", - "description": "Whether this is a system shell" - } - }, - "required": ["shellType", "ownerUsername", "isSystemShell"] - } - ] - }, - "ShellDTO": { - "oneOf": [ - { - "$ref": "#/components/schemas/CommandShellDTO" - }, - { - "$ref": "#/components/schemas/TerminalShellDTO" - } - ], - "discriminator": { - "propertyName": "shellType", - "mapping": { - "COMMAND": "#/components/schemas/CommandShellDTO", - "TERMINAL": "#/components/schemas/TerminalShellDTO" - } - } - }, - "OpenCommandShellDTO": { - "allOf": [ - { - "$ref": "#/components/schemas/CommandShellDTO" - }, - { - "type": "object", - "properties": { - "buffer": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Content buffer of the shell" - } - }, - "required": ["buffer"] - } - ] - }, - "OpenTerminalShellDTO": { - "allOf": [ - { - "$ref": "#/components/schemas/TerminalShellDTO" - }, - { - "type": "object", - "properties": { - "buffer": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Content buffer of the shell" - } - }, - "required": ["buffer"] - } - ] - }, - "OpenShellDTO": { - "oneOf": [ - { - "$ref": "#/components/schemas/OpenCommandShellDTO" - }, - { - "$ref": "#/components/schemas/OpenTerminalShellDTO" - } - ], - "discriminator": { - "propertyName": "shellType", - "mapping": { - "COMMAND": "#/components/schemas/OpenCommandShellDTO", - "TERMINAL": "#/components/schemas/OpenTerminalShellDTO" - } - } - }, - "CommonError": { - "oneOf": [ - { - "type": "object", - "properties": { - "code": { - "type": "string", - "enum": ["SHELL_NOT_ACCESSIBLE"], - "description": "Error code indicating the shell is not accessible" - }, - "message": { - "type": "string", - "description": "Error message" - } - }, - "required": ["code", "message"] - }, - { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Protocol error code" - }, - "message": { - "type": "string", - "description": "Error message" - } - }, - "required": ["code", "message"] - } - ] - } - } - } -} diff --git a/openapi-sandbox-system.json b/openapi-sandbox-system.json deleted file mode 100644 index 501f2f5..0000000 --- a/openapi-sandbox-system.json +++ /dev/null @@ -1,348 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Sandbox System API", - "description": "API for managing sandbox system operations", - "version": "1.0.0" - }, - "paths": { - "/system/update": { - "post": { - "summary": "Update system", - "description": "Update the sandbox system", - "operationId": "systemUpdate", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": {} - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error updating system", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/SystemError" - } - } - } - ] - } - } - } - } - } - } - }, - "/system/hibernate": { - "post": { - "summary": "Hibernate system", - "description": "Put the sandbox system into hibernation mode", - "operationId": "systemHibernate", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error hibernating system", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/SystemError" - } - } - } - ] - } - } - } - } - } - } - }, - "/system/metrics": { - "post": { - "summary": "Get system metrics", - "description": "Retrieve current system metrics including CPU, memory and storage usage", - "operationId": "systemMetrics", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/SystemMetricsStatus" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving system metrics", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/SystemError" - } - } - } - ] - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "SuccessResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [0], - "description": "Status code for successful operations" - }, - "result": { - "type": "object", - "description": "Result payload for the operation" - } - }, - "required": ["status", "result"] - }, - "ErrorResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [1], - "description": "Status code for error operations" - }, - "error": { - "type": "object", - "description": "Error details" - } - }, - "required": ["status", "error"] - }, - "SystemError": { - "type": "object", - "properties": { - "code": { - "type": "number", - "description": "Error code" - }, - "message": { - "type": "string", - "description": "Error message" - }, - "data": { - "type": "object", - "description": "Additional error data", - "nullable": true - } - }, - "required": ["code", "message"] - }, - "SystemMetricsStatus": { - "type": "object", - "properties": { - "cpu": { - "type": "object", - "properties": { - "cores": { - "type": "number", - "description": "Number of CPU cores" - }, - "used": { - "type": "number", - "description": "Used CPU resources" - }, - "configured": { - "type": "number", - "description": "Configured CPU resources" - } - }, - "required": ["cores", "used", "configured"] - }, - "memory": { - "type": "object", - "properties": { - "used": { - "type": "number", - "description": "Used memory in bytes" - }, - "total": { - "type": "number", - "description": "Total available memory in bytes" - }, - "configured": { - "type": "number", - "description": "Configured memory limit in bytes" - } - }, - "required": ["used", "total", "configured"] - }, - "storage": { - "type": "object", - "properties": { - "used": { - "type": "number", - "description": "Used storage in bytes" - }, - "total": { - "type": "number", - "description": "Total available storage in bytes" - }, - "configured": { - "type": "number", - "description": "Configured storage limit in bytes" - } - }, - "required": ["used", "total", "configured"] - } - }, - "required": ["cpu", "memory", "storage"] - }, - "InitStatus": { - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Status message" - }, - "isError": { - "type": "boolean", - "description": "Whether the status represents an error", - "nullable": true - }, - "progress": { - "type": "number", - "description": "Current progress (0-100)", - "minimum": 0, - "maximum": 100 - }, - "nextProgress": { - "type": "number", - "description": "Next progress target (0-100)", - "minimum": 0, - "maximum": 100 - }, - "stdout": { - "type": "string", - "description": "Standard output from the initialization process", - "nullable": true - } - }, - "required": ["message", "progress", "nextProgress"] - } - } - } -} diff --git a/openapi-sandbox-task.json b/openapi-sandbox-task.json deleted file mode 100644 index 6b5e320..0000000 --- a/openapi-sandbox-task.json +++ /dev/null @@ -1,947 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Sandbox Task API", - "description": "API for managing tasks in sandbox", - "version": "1.0.0" - }, - "paths": { - "/task/list": { - "post": { - "summary": "List tasks", - "description": "Retrieve a list of all configured tasks", - "operationId": "taskList", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/TaskListDTO" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error retrieving task list", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/CommonError" - } - } - } - ] - } - } - } - } - } - } - }, - "/task/run": { - "post": { - "summary": "Run task", - "description": "Start execution of a task by ID", - "operationId": "taskRun", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "taskId": { - "type": "string", - "description": "ID of the task to run" - } - }, - "required": ["taskId"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/TaskDTO" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error running task", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/TaskError" - } - } - } - ] - } - } - } - } - } - } - }, - "/task/runCommand": { - "post": { - "summary": "Run command", - "description": "Run a shell command directly, optionally saving it as a task", - "operationId": "taskRunCommand", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "Command to run" - }, - "name": { - "type": "string", - "description": "Optional name for the task", - "nullable": true - }, - "saveToConfig": { - "type": "boolean", - "description": "Whether to save this command as a task in the config", - "nullable": true - } - }, - "required": ["command"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/TaskDTO" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error running command", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/TaskError" - } - } - } - ] - } - } - } - } - } - } - }, - "/task/stop": { - "post": { - "summary": "Stop task", - "description": "Stop execution of a running task", - "operationId": "taskStop", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "taskId": { - "type": "string", - "description": "ID of the task to stop" - } - }, - "required": ["taskId"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "oneOf": [ - { - "$ref": "#/components/schemas/TaskDTO" - }, - { - "type": "null", - "description": "Null when stopping an unconfigured task" - } - ] - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error stopping task", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/TaskError" - } - } - } - ] - } - } - } - } - } - } - }, - "/task/create": { - "post": { - "summary": "Create task", - "description": "Create a new task configuration", - "operationId": "taskCreate", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "taskFields": { - "$ref": "#/components/schemas/TaskDefinitionDTO" - }, - "startTask": { - "type": "boolean", - "description": "Whether to start the task immediately after creation", - "nullable": true - } - }, - "required": ["taskFields"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/TaskListDTO" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error creating task", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/TaskError" - } - } - } - ] - } - } - } - } - } - } - }, - "/task/update": { - "post": { - "summary": "Update task", - "description": "Update an existing task configuration", - "operationId": "taskUpdate", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "taskId": { - "type": "string", - "description": "ID of the task to update" - }, - "taskFields": { - "type": "object", - "description": "Fields to update in the task", - "properties": { - "name": { - "type": "string", - "description": "Name of the task", - "nullable": true - }, - "command": { - "type": "string", - "description": "Command to run", - "nullable": true - }, - "runAtStart": { - "type": "boolean", - "description": "Whether to run the task at sandbox start", - "nullable": true - }, - "preview": { - "type": "object", - "properties": { - "port": { - "type": "number", - "description": "Port to use for previewing the task", - "nullable": true - }, - "pr-link": { - "type": "string", - "enum": ["direct", "redirect", "devtool"], - "description": "Type of PR link to use", - "nullable": true - } - }, - "nullable": true - } - } - } - }, - "required": ["taskId", "taskFields"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/TaskDTO" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error updating task", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/TaskError" - } - } - } - ] - } - } - } - } - } - } - }, - "/task/saveToConfig": { - "post": { - "summary": "Save task to config", - "description": "Save a runtime task to the configuration file", - "operationId": "taskSaveToConfig", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "taskId": { - "type": "string", - "description": "ID of the task to save to config" - } - }, - "required": ["taskId"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "$ref": "#/components/schemas/TaskDTO" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error saving task to config", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/TaskError" - } - } - } - ] - } - } - } - } - } - } - }, - "/task/generateConfig": { - "post": { - "summary": "Generate task config", - "description": "Generate a configuration file from current tasks", - "operationId": "taskGenerateConfig", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error generating config", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/TaskError" - } - } - } - ] - } - } - } - } - } - } - }, - "/task/createSetupTasks": { - "post": { - "summary": "Create setup tasks", - "description": "Create tasks that run during sandbox setup", - "operationId": "taskCreateSetupTasks", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "tasks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TaskDefinitionDTO" - }, - "description": "Setup tasks to create" - } - }, - "required": ["tasks"] - } - } - } - }, - "responses": { - "200": { - "description": "Successful operation", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/SuccessResponse" - }, - { - "type": "object", - "properties": { - "result": { - "type": "null" - } - } - } - ] - } - } - } - }, - "400": { - "description": "Error creating setup tasks", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ErrorResponse" - }, - { - "type": "object", - "properties": { - "error": { - "$ref": "#/components/schemas/TaskError" - } - } - } - ] - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "SuccessResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [0], - "description": "Status code for successful operations" - }, - "result": { - "type": "object", - "description": "Result payload for the operation" - } - }, - "required": ["status", "result"] - }, - "ErrorResponse": { - "type": "object", - "properties": { - "status": { - "type": "number", - "enum": [1], - "description": "Status code for error operations" - }, - "error": { - "type": "object", - "description": "Error details" - } - }, - "required": ["status", "error"] - }, - "CommonError": { - "type": "object", - "properties": { - "code": { - "type": "number", - "description": "Error code" - }, - "message": { - "type": "string", - "description": "Error message" - }, - "data": { - "type": "object", - "description": "Additional error data", - "nullable": true - } - }, - "required": ["code"] - }, - "TaskError": { - "oneOf": [ - { - "type": "object", - "properties": { - "code": { - "type": "number", - "enum": [600], - "description": "CONFIG_FILE_ALREADY_EXISTS error code" - }, - "message": { - "type": "string", - "description": "Error message" - } - }, - "required": ["code", "message"] - }, - { - "type": "object", - "properties": { - "code": { - "type": "number", - "enum": [601], - "description": "TASK_NOT_FOUND error code" - }, - "message": { - "type": "string", - "description": "Error message" - } - }, - "required": ["code", "message"] - }, - { - "type": "object", - "properties": { - "code": { - "type": "number", - "enum": [602], - "description": "COMMAND_ALREADY_CONFIGURED error code" - }, - "message": { - "type": "string", - "description": "Error message" - } - }, - "required": ["code", "message"] - }, - { - "$ref": "#/components/schemas/CommonError" - } - ], - "discriminator": { - "propertyName": "code" - } - }, - "TaskDefinitionDTO": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the task" - }, - "command": { - "type": "string", - "description": "Command to run for the task" - }, - "runAtStart": { - "type": "boolean", - "description": "Whether the task should run when the sandbox starts", - "nullable": true - }, - "preview": { - "type": "object", - "properties": { - "port": { - "type": "number", - "description": "Port to preview from this task", - "nullable": true - }, - "pr-link": { - "type": "string", - "enum": ["direct", "redirect", "devtool"], - "description": "Type of PR link to use", - "nullable": true - } - }, - "nullable": true - } - }, - "required": ["name", "command"] - }, - "CommandShellDTO": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "ID of the shell command" - }, - "command": { - "type": "string", - "description": "Command being executed" - }, - "status": { - "type": "string", - "enum": ["initializing", "running", "stopped", "error"], - "description": "Current status of the shell command" - }, - "output": { - "type": "string", - "description": "Current output of the command" - } - }, - "required": ["id", "command", "status", "output"] - }, - "Port": { - "type": "object", - "properties": { - "port": { - "type": "number", - "description": "Port number" - }, - "hostname": { - "type": "string", - "description": "Hostname the port is bound to" - }, - "status": { - "type": "string", - "enum": ["open", "closed"], - "description": "Current status of the port" - }, - "taskId": { - "type": "string", - "description": "ID of the task that opened this port", - "nullable": true - } - }, - "required": ["port", "hostname", "status"] - }, - "TaskDTO": { - "allOf": [ - { - "$ref": "#/components/schemas/TaskDefinitionDTO" - }, - { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique ID of the task" - }, - "unconfigured": { - "type": "boolean", - "description": "Whether this task is unconfigured (not saved in config)", - "nullable": true - }, - "shell": { - "type": "object", - "nullable": true, - "allOf": [ - { - "$ref": "#/components/schemas/CommandShellDTO" - } - ] - }, - "ports": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Port" - }, - "description": "Ports opened by this task" - } - }, - "required": ["id", "shell", "ports"] - } - ] - }, - "TaskListDTO": { - "type": "object", - "properties": { - "tasks": { - "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/TaskDTO" - }, - "description": "Map of task IDs to task objects" - }, - "setupTasks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TaskDefinitionDTO" - }, - "description": "Tasks that run during sandbox setup" - }, - "validationErrors": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Validation errors in the task configuration" - } - }, - "required": ["tasks", "setupTasks", "validationErrors"] - } - } - } -} diff --git a/package.json b/package.json index 5cd880a..e5eebe6 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "build-openapi-pint": "node_modules/.bin/openapi-ts -i ./pint-openapi-bundled.json -o src/api-clients/pint -c @hey-api/client-fetch", "clean": "rimraf ./dist", "test": "vitest", - "test:e2e": "vitest run tests/e2e", + "test:e2e": "vitest run", "typecheck": "tsc --noEmit", "format": "prettier '**/*.{md,js,jsx,json,ts,tsx}' --write", "postbuild": "rimraf {lib,es}/**/__tests__ {lib,es}/**/*.{spec,test}.{js,d.ts,js.map}", diff --git a/src/AgentClient/index.ts b/src/AgentClient/index.ts index 15fa219..30f0ebc 100644 --- a/src/AgentClient/index.ts +++ b/src/AgentClient/index.ts @@ -133,7 +133,8 @@ class AgentClientShells implements IAgentClientShells { out: openShell.buffer.join("\n"), exitCode: openShell.exitCode, }); - if ("exitCode" in openShell) { + + if (typeof openShell.exitCode === "number") { return; } diff --git a/src/SandboxClient/commands.ts b/src/SandboxClient/commands.ts index fc07f78..722c59f 100644 --- a/src/SandboxClient/commands.ts +++ b/src/SandboxClient/commands.ts @@ -104,6 +104,79 @@ export class SandboxCommands { ); } + private async runBackgroundPitcher( + command: string | string[], + opts?: ShellRunOpts + ) { + const disposableStore = new DisposableStore(); + const onOutput = new Emitter(); + disposableStore.add(onOutput); + + command = Array.isArray(command) ? command.join(" && ") : command; + + const passedEnv = Object.assign(opts?.env ?? {}); + + const escapedCommand = command.replace(/'/g, "'\\''"); + + // TODO: use a new shell API that natively supports cwd & env + let commandWithEnv = Object.keys(passedEnv).length + ? `source $HOME/.private/.env 2>/dev/null || true && env ${Object.entries( + passedEnv + ) + .map(([key, value]) => { + const escapedValue = String(value).replace(/'/g, "'\\''"); + return `${key}='${escapedValue}'`; + }) + .join(" ")} bash -c '${escapedCommand}'` + : `source $HOME/.private/.env 2>/dev/null || true && bash -c '${escapedCommand}'`; + + if (opts?.cwd) { + commandWithEnv = `cd ${opts.cwd} && ${commandWithEnv}`; + } + + const shell = await this.agentClient.shells.create({ + projectPath: this.agentClient.workspacePath, + size: opts?.dimensions ?? DEFAULT_SHELL_SIZE, + command: commandWithEnv, + args: [], + type: opts?.asGlobalSession ? "COMMAND" : "TERMINAL", + isSystemShell: true, + }); + + if (shell.status === "ERROR" || shell.status === "KILLED") { + throw new Error(`Failed to create shell: ${shell.buffer.join("\n")}`); + } + + const details = { + type: "command", + command, + name: opts?.name, + }; + + if (shell.status !== "FINISHED") { + // Only way for us to differentiate between a command and a terminal + this.agentClient.shells + .rename( + shell.shellId, + // We embed some details in the name to properly show the command that was run + // , the name and that it is an actual command + JSON.stringify(details) + ) + .catch(() => { + // It is already done + }); + } + + const cmd = new Command( + this.agentClient, + shell as protocol.shell.CommandShellDTO, + details, + this.tracer + ); + + return cmd; + } + /** * Create and run command in a new shell. Allows you to listen to the output and kill the command. */ @@ -118,6 +191,10 @@ export class SandboxCommands { "command.name": opts?.name || "", }, async () => { + if (this.agentClient.type === "pitcher") { + return this.runBackgroundPitcher(command, opts); + } + command = Array.isArray(command) ? command.join(" && ") : command; const passedEnv = Object.assign(opts?.env ?? {}); @@ -311,7 +388,6 @@ export class Command { this.status = "KILLED"; this.barrier.open(); } else { - console.log("Got exit"); const barrier = new Barrier(); this.agentClient.shells.subscribeOutput( this.shell.shellId, diff --git a/src/SandboxClient/terminals.ts b/src/SandboxClient/terminals.ts index 7775d62..ed6ab2c 100644 --- a/src/SandboxClient/terminals.ts +++ b/src/SandboxClient/terminals.ts @@ -58,6 +58,41 @@ export class Terminals { ); } + private async createPitcherTerminal( + command: "bash" | "zsh" | "fish" | "ksh" | "dash" = "bash", + opts?: ShellRunOpts + ) { + const allEnv = Object.assign(opts?.env ?? {}); + + // TODO: use a new shell API that natively supports cwd & env + let commandWithEnv = Object.keys(allEnv).length + ? `source $HOME/.private/.env 2>/dev/null || true && env ${Object.entries( + allEnv + ) + .map(([key, value]) => `${key}=${value}`) + .join(" ")} ${command}` + : `source $HOME/.private/.env 2>/dev/null || true && ${command}`; + + if (opts?.cwd) { + commandWithEnv = `cd ${opts.cwd} && ${commandWithEnv}`; + } + + const shell = await this.agentClient.shells.create({ + projectPath: this.agentClient.workspacePath, + size: opts?.dimensions ?? DEFAULT_SHELL_SIZE, + command: commandWithEnv, + args: [], + type: "TERMINAL", + isSystemShell: true, + }); + + if (opts?.name) { + this.agentClient.shells.rename(shell.shellId, opts.name); + } + + return new Terminal(shell, this.agentClient, this.tracer); + } + async create( command: "bash" | "zsh" | "fish" | "ksh" | "dash" = "bash", opts?: ShellRunOpts @@ -72,6 +107,10 @@ export class Terminals { hasDimensions: !!opts?.dimensions, }, async () => { + if (this.agentClient.type === "pitcher") { + return this.createPitcherTerminal(command, opts); + } + const passedEnv = Object.assign(opts?.env ?? {}); // Build bash args array @@ -91,18 +130,16 @@ export class Terminals { projectPath: this.agentClient.workspacePath, size: opts?.dimensions ?? DEFAULT_SHELL_SIZE, command, - args: [], + args: this.agentClient.type === "pint" ? [] : args, type: "TERMINAL", isSystemShell: true, }); - if (opts?.name) { - this.agentClient.shells.rename(shell.shellId, opts.name); - } - const terminal = new Terminal(shell, this.agentClient, this.tracer); - await terminal.write(args.join(" ") + "\n"); + if (this.agentClient.type === "pint") { + await terminal.write(args.join(" ") + "\n"); + } return terminal; } diff --git a/tests/e2e/sandbox-terminals.test.ts b/tests/e2e/sandbox-terminals.test.ts index 6400417..92b6354 100644 --- a/tests/e2e/sandbox-terminals.test.ts +++ b/tests/e2e/sandbox-terminals.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { CodeSandbox } from '../../src/index.js'; -import { Sandbox } from '../../src/Sandbox.js'; -import { SandboxClient } from '../../src/SandboxClient/index.js'; -import { initializeSDK, TEST_TEMPLATE_ID } from './helpers.js'; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { CodeSandbox } from "../../src/index.js"; +import { Sandbox } from "../../src/Sandbox.js"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { initializeSDK, TEST_TEMPLATE_ID } from "./helpers.js"; -describe('Sandbox Terminals', () => { +describe("Sandbox Terminals", () => { let sdk: CodeSandbox; let sandbox: Sandbox | undefined; let client: SandboxClient | undefined; @@ -31,7 +31,7 @@ describe('Sandbox Terminals', () => { client = undefined; } } catch (error) { - console.error('Failed to dispose client:', error); + console.error("Failed to dispose client:", error); } if (sandboxId) { @@ -39,19 +39,24 @@ describe('Sandbox Terminals', () => { await sdk.sandboxes.shutdown(sandboxId); await sdk.sandboxes.delete(sandboxId); } catch (error) { - console.error('Failed to cleanup test sandbox:', sandboxId, error); + console.error("Failed to cleanup test sandbox:", sandboxId, error); try { await sdk.sandboxes.delete(sandboxId); } catch (deleteError) { - console.error('Failed to force delete sandbox:', sandboxId, deleteError); + console.error( + "Failed to force delete sandbox:", + sandboxId, + deleteError + ); } } } }); - describe('Terminal creation', () => { - it('should create a terminal', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Terminal creation", () => { + it("should create a terminal", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const terminal = await client.terminals.create(); expect(terminal).toBeDefined(); @@ -61,10 +66,11 @@ describe('Sandbox Terminals', () => { await terminal.kill(); }); - it('should create terminal with custom dimensions', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should create terminal with custom dimensions", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - const terminal = await client.terminals.create('bash', { + const terminal = await client.terminals.create("bash", { dimensions: { cols: 120, rows: 40 }, }); expect(terminal).toBeDefined(); @@ -75,9 +81,10 @@ describe('Sandbox Terminals', () => { }); }); - describe('Terminal listing', () => { - it('should get all terminals', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Terminal listing", () => { + it("should get all terminals", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const terminal1 = await client.terminals.create(); const terminal2 = await client.terminals.create(); @@ -91,8 +98,9 @@ describe('Sandbox Terminals', () => { await terminal2.kill(); }, 15000); - it('should get terminal by ID', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should get terminal by ID", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const terminal = await client.terminals.create(); const retrieved = await client.terminals.get(terminal.id); @@ -107,9 +115,10 @@ describe('Sandbox Terminals', () => { }); }); - describe('Terminal operations', () => { - it('should write to terminal', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Terminal operations", () => { + it("should write to terminal", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const terminal = await client.terminals.create(); @@ -123,8 +132,9 @@ describe('Sandbox Terminals', () => { await terminal.kill(); }); - it('should run command in terminal', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should run command in terminal", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const terminal = await client.terminals.create(); @@ -138,19 +148,23 @@ describe('Sandbox Terminals', () => { await terminal.kill(); }); - it('should receive output from terminal', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should receive output from terminal", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const terminal = await client.terminals.create(); let receivedOutput = false; // Listen for output const disposable = terminal.onOutput((data) => { - if (data.includes('unique_test_string')) { + if (data.includes("unique_test_string")) { receivedOutput = true; } }); + // Users have to open first to get current output + await terminal.open(); + // Write a command await terminal.write('echo "unique_test_string"\n'); @@ -165,9 +179,10 @@ describe('Sandbox Terminals', () => { }, 10000); }); - describe('Terminal lifecycle', () => { - it('should kill terminal', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Terminal lifecycle", () => { + it("should kill terminal", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const terminal = await client.terminals.create(); expect(terminal).toBeDefined(); @@ -179,8 +194,9 @@ describe('Sandbox Terminals', () => { expect(terminal.id).toBeTruthy(); }); - it('should handle multiple terminals', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should handle multiple terminals", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const terminals = await Promise.all([ client.terminals.create(), From 6e0903682e6f1c7a4a984c3066efb038a5c9120a Mon Sep 17 00:00:00 2001 From: Joji Augustine Date: Fri, 28 Nov 2025 15:41:10 +0100 Subject: [PATCH 09/46] fix build error --- src/api-clients/client/types.gen.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/api-clients/client/types.gen.ts b/src/api-clients/client/types.gen.ts index 85c4e1a..22996d4 100644 --- a/src/api-clients/client/types.gen.ts +++ b/src/api-clients/client/types.gen.ts @@ -626,7 +626,6 @@ export type VmStartResponse = { user_workspace_path: string; vm_agent_type: string; workspace_path: string; - vm_agent_type: string; }; }; @@ -993,7 +992,6 @@ export type SandboxForkResponse = { user_workspace_path: string; vm_agent_type: string; workspace_path: string; - vm_agent_type: string; } | null; title: string | null; }; From 5229f20e3ae32bf61f1f84576e51d5d139853602 Mon Sep 17 00:00:00 2001 From: Christian Alfoni Date: Tue, 2 Dec 2025 14:53:19 +0100 Subject: [PATCH 10/46] still failing tests, but getting there --- src/AgentClient/index.ts | 31 +- tests/e2e/helpers.ts | 12 +- tests/emitter-subscription.test.ts | 315 +++++++++-------- tests/pint-shells-client.test.ts | 507 ++++++++++++++++----------- tests/sandbox-creation.test.ts | 127 +++---- tests/sandbox-retry-behavior.test.ts | 36 +- tests/test-utils.ts | 175 +++++---- vitest.config.ts | 1 + 8 files changed, 699 insertions(+), 505 deletions(-) diff --git a/src/AgentClient/index.ts b/src/AgentClient/index.ts index 30f0ebc..b59308e 100644 --- a/src/AgentClient/index.ts +++ b/src/AgentClient/index.ts @@ -35,7 +35,23 @@ let PONG_DETECTION_TIMEOUT = 30_000; const FOCUS_PONG_DETECTION_TIMEOUT = 5_000; class AgentClientShells implements IAgentClientShells { - constructor(private agentConnection: AgentConnection) {} + disposeOutputListener: () => void; + private shellOutputs: Record = {}; + constructor(private agentConnection: AgentConnection) { + // We use a common listener to keep track of all shell output to avoid race conditions. These + // are then flushed. This does not work with multiple listeners, but you would not use multiple + // listeners for command/terminal output anyways. NOTE! These notifications only appear for created/opened shells + this.disposeOutputListener = agentConnection.onNotification( + "shell/out", + (event) => { + if (!this.shellOutputs[event.shellId]) { + this.shellOutputs[event.shellId] = []; + } + + this.shellOutputs[event.shellId].push(event.out); + } + ); + } create({ command, args, @@ -156,7 +172,17 @@ class AgentClientShells implements IAgentClientShells { ); }) .catch(() => { - // We do not care + // Pitcher requires a global shell listener for output to avoid race conditions. When running commands the shell can close + // before we get the output, so this just flushes the output gotten in between creating and subscribing + listener({ + out: this.shellOutputs[shellId] + ? this.shellOutputs[shellId].join("") + : "", + // We give a fake exit code, because pint gives an exit code on last event... but we do not know the exit code as the + // shell is already gone + exitCode: -1, + }); + this.shellOutputs[shellId].length = 0; }); disposable.onDidDispose(() => { @@ -553,6 +579,7 @@ export class AgentClient implements IAgentClient { } } dispose() { + this.shells.disposeOutputListener(); this.agentConnection.dispose(); } } diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 17c9a87..6f33ed6 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -1,19 +1,17 @@ -import { CodeSandbox } from '../../src/index.js'; +import { CodeSandbox } from "../../src/index.js"; /** * Test template ID used across e2e tests */ -export const TEST_TEMPLATE_ID = process.env.CSB_TEST_TEMPLATE_ID ?? ''; +export const TEST_TEMPLATE_ID = process.env.CSB_TEST_TEMPLATE_ID ?? ""; /** * Initialize SDK with API key from environment */ export function initializeSDK(): CodeSandbox { - const apiKey = process.env.CSB_API_KEY; - if (!apiKey) { - throw new Error('CSB_API_KEY environment variable is required for e2e tests'); - } - return new CodeSandbox(apiKey); + return new CodeSandbox("csb_v1_devbox", { + baseUrl: "http://codesandbox.dev", + }); } /** diff --git a/tests/emitter-subscription.test.ts b/tests/emitter-subscription.test.ts index 669d226..8c31b33 100644 --- a/tests/emitter-subscription.test.ts +++ b/tests/emitter-subscription.test.ts @@ -1,211 +1,218 @@ -import { describe, it, expect, vi } from 'vitest' -import { EmitterSubscription } from '../src/utils/event' -import { Disposable } from '../src/utils/disposable' +import { describe, it, expect, vi } from "vitest"; +import { EmitterSubscription } from "../src/utils/event"; +import { Disposable } from "../src/utils/disposable"; +import { sleep } from "../src/utils/sleep"; -describe('EmitterSubscription', () => { - it('should create subscription when first listener is added', () => { - const createSubscription = vi.fn(() => Disposable.create(() => {})) - const subscription = new EmitterSubscription(createSubscription) +describe("EmitterSubscription", () => { + it("should create subscription when first listener is added", () => { + const createSubscription = vi.fn(() => Disposable.create(() => {})); + const subscription = new EmitterSubscription(createSubscription); - expect(createSubscription).not.toHaveBeenCalled() + expect(createSubscription).not.toHaveBeenCalled(); - const disposable = subscription.event(() => {}) + const disposable = subscription.event(() => {}); - expect(createSubscription).toHaveBeenCalledTimes(1) + expect(createSubscription).toHaveBeenCalledTimes(1); - disposable.dispose() - }) + disposable.dispose(); + }); - it('should not create multiple subscriptions for multiple listeners', () => { - const createSubscription = vi.fn(() => Disposable.create(() => {})) - const subscription = new EmitterSubscription(createSubscription) + it("should not create multiple subscriptions for multiple listeners", () => { + const createSubscription = vi.fn(() => Disposable.create(() => {})); + const subscription = new EmitterSubscription(createSubscription); - const disposable1 = subscription.event(() => {}) - const disposable2 = subscription.event(() => {}) - const disposable3 = subscription.event(() => {}) + const disposable1 = subscription.event(() => {}); + const disposable2 = subscription.event(() => {}); + const disposable3 = subscription.event(() => {}); - expect(createSubscription).toHaveBeenCalledTimes(1) + expect(createSubscription).toHaveBeenCalledTimes(1); - disposable1.dispose() - disposable2.dispose() - disposable3.dispose() - }) + disposable1.dispose(); + disposable2.dispose(); + disposable3.dispose(); + }); - it('should fire events to all listeners', () => { + it("should fire events to all listeners", async () => { const subscription = new EmitterSubscription((fire) => { - fire(42) - return Disposable.create(() => {}) - }) + setTimeout(() => { + fire(42); + }, 10); + return Disposable.create(() => {}); + }); - const listener1 = vi.fn() - const listener2 = vi.fn() - const listener3 = vi.fn() + const listener1 = vi.fn(); + const listener2 = vi.fn(); + const listener3 = vi.fn(); - subscription.event(listener1) - subscription.event(listener2) - subscription.event(listener3) + subscription.event(listener1); + subscription.event(listener2); + subscription.event(listener3); - expect(listener1).toHaveBeenCalledWith(42) - expect(listener2).toHaveBeenCalledWith(42) - expect(listener3).toHaveBeenCalledWith(42) - }) + await sleep(100); - it('should allow firing events from subscription callback', () => { - let fireFn: ((value: number) => void) | undefined + expect(listener1).toHaveBeenCalledWith(42); + expect(listener2).toHaveBeenCalledWith(42); + expect(listener3).toHaveBeenCalledWith(42); + }); + + it("should allow firing events from subscription callback", () => { + let fireFn: ((value: number) => void) | undefined; const subscription = new EmitterSubscription((fire) => { - fireFn = fire - return Disposable.create(() => {}) - }) + fireFn = fire; + return Disposable.create(() => {}); + }); - const listener = vi.fn() - subscription.event(listener) + const listener = vi.fn(); + subscription.event(listener); - expect(fireFn).toBeDefined() + expect(fireFn).toBeDefined(); - fireFn!(100) - fireFn!(200) - fireFn!(300) + fireFn!(100); + fireFn!(200); + fireFn!(300); - expect(listener).toHaveBeenCalledTimes(3) - expect(listener).toHaveBeenNthCalledWith(1, 100) - expect(listener).toHaveBeenNthCalledWith(2, 200) - expect(listener).toHaveBeenNthCalledWith(3, 300) - }) + expect(listener).toHaveBeenCalledTimes(3); + expect(listener).toHaveBeenNthCalledWith(1, 100); + expect(listener).toHaveBeenNthCalledWith(2, 200); + expect(listener).toHaveBeenNthCalledWith(3, 300); + }); - it('should dispose subscription when last listener is removed', () => { - const dispose = vi.fn() - const createSubscription = vi.fn(() => Disposable.create(dispose)) - const subscription = new EmitterSubscription(createSubscription) + it("should dispose subscription when last listener is removed", () => { + const dispose = vi.fn(); + const createSubscription = vi.fn(() => Disposable.create(dispose)); + const subscription = new EmitterSubscription(createSubscription); - const disposable1 = subscription.event(() => {}) - const disposable2 = subscription.event(() => {}) + const disposable1 = subscription.event(() => {}); + const disposable2 = subscription.event(() => {}); - expect(dispose).not.toHaveBeenCalled() + expect(dispose).not.toHaveBeenCalled(); - disposable1.dispose() - expect(dispose).not.toHaveBeenCalled() + disposable1.dispose(); + expect(dispose).not.toHaveBeenCalled(); - disposable2.dispose() - expect(dispose).toHaveBeenCalledTimes(1) - }) + disposable2.dispose(); + expect(dispose).toHaveBeenCalledTimes(1); + }); - it('should recreate subscription if listener is added again after all removed', () => { - const dispose = vi.fn() - const createSubscription = vi.fn(() => Disposable.create(dispose)) - const subscription = new EmitterSubscription(createSubscription) + it("should recreate subscription if listener is added again after all removed", () => { + const dispose = vi.fn(); + const createSubscription = vi.fn(() => Disposable.create(dispose)); + const subscription = new EmitterSubscription(createSubscription); - const disposable1 = subscription.event(() => {}) - disposable1.dispose() + const disposable1 = subscription.event(() => {}); + disposable1.dispose(); - expect(createSubscription).toHaveBeenCalledTimes(1) - expect(dispose).toHaveBeenCalledTimes(1) + expect(createSubscription).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledTimes(1); - const disposable2 = subscription.event(() => {}) + const disposable2 = subscription.event(() => {}); - expect(createSubscription).toHaveBeenCalledTimes(2) - expect(dispose).toHaveBeenCalledTimes(1) + expect(createSubscription).toHaveBeenCalledTimes(2); + expect(dispose).toHaveBeenCalledTimes(1); - disposable2.dispose() - expect(dispose).toHaveBeenCalledTimes(2) - }) + disposable2.dispose(); + expect(dispose).toHaveBeenCalledTimes(2); + }); - it('should stop firing to disposed listeners', () => { - let fireFn: ((value: number) => void) | undefined + it("should stop firing to disposed listeners", () => { + let fireFn: ((value: number) => void) | undefined; const subscription = new EmitterSubscription((fire) => { - fireFn = fire - return Disposable.create(() => {}) - }) - - const listener1 = vi.fn() - const listener2 = vi.fn() - const listener3 = vi.fn() - - const disposable1 = subscription.event(listener1) - subscription.event(listener2) - subscription.event(listener3) - - fireFn!(1) - expect(listener1).toHaveBeenCalledTimes(1) - expect(listener2).toHaveBeenCalledTimes(1) - expect(listener3).toHaveBeenCalledTimes(1) - - disposable1.dispose() - - fireFn!(2) - expect(listener1).toHaveBeenCalledTimes(1) // Not called again - expect(listener2).toHaveBeenCalledTimes(2) - expect(listener3).toHaveBeenCalledTimes(2) - }) - - it('should cleanup everything on dispose', () => { - const subscriptionDispose = vi.fn() - const createSubscription = vi.fn(() => Disposable.create(subscriptionDispose)) - - let fireFn: ((value: number) => void) | undefined + fireFn = fire; + return Disposable.create(() => {}); + }); + + const listener1 = vi.fn(); + const listener2 = vi.fn(); + const listener3 = vi.fn(); + + const disposable1 = subscription.event(listener1); + subscription.event(listener2); + subscription.event(listener3); + + fireFn!(1); + expect(listener1).toHaveBeenCalledTimes(1); + expect(listener2).toHaveBeenCalledTimes(1); + expect(listener3).toHaveBeenCalledTimes(1); + + disposable1.dispose(); + + fireFn!(2); + expect(listener1).toHaveBeenCalledTimes(1); // Not called again + expect(listener2).toHaveBeenCalledTimes(2); + expect(listener3).toHaveBeenCalledTimes(2); + }); + + it("should cleanup everything on dispose", () => { + const subscriptionDispose = vi.fn(); + const createSubscription = vi.fn(() => + Disposable.create(subscriptionDispose) + ); + + let fireFn: ((value: number) => void) | undefined; const subscription = new EmitterSubscription((fire) => { - fireFn = fire - return Disposable.create(subscriptionDispose) - }) + fireFn = fire; + return Disposable.create(subscriptionDispose); + }); - const listener = vi.fn() - subscription.event(listener) + const listener = vi.fn(); + subscription.event(listener); - subscription.dispose() + subscription.dispose(); - expect(subscriptionDispose).toHaveBeenCalledTimes(1) + expect(subscriptionDispose).toHaveBeenCalledTimes(1); // Should not fire to listeners after dispose - fireFn!(42) - expect(listener).not.toHaveBeenCalled() - }) + fireFn!(42); + expect(listener).not.toHaveBeenCalled(); + }); - it('should work with interval example', () => { - vi.useFakeTimers() + it("should work with interval example", () => { + vi.useFakeTimers(); - let intervalId: NodeJS.Timeout + let intervalId: NodeJS.Timeout; const subscription = new EmitterSubscription((fire) => { - intervalId = setInterval(() => fire(Date.now()), 1000) - return Disposable.create(() => clearInterval(intervalId)) - }) + intervalId = setInterval(() => fire(Date.now()), 1000); + return Disposable.create(() => clearInterval(intervalId)); + }); - const listener = vi.fn() - const disposable = subscription.event(listener) + const listener = vi.fn(); + const disposable = subscription.event(listener); - vi.advanceTimersByTime(3500) - expect(listener).toHaveBeenCalledTimes(3) + vi.advanceTimersByTime(3500); + expect(listener).toHaveBeenCalledTimes(3); - disposable.dispose() + disposable.dispose(); // Should not receive more events after dispose - vi.advanceTimersByTime(5000) - expect(listener).toHaveBeenCalledTimes(3) + vi.advanceTimersByTime(5000); + expect(listener).toHaveBeenCalledTimes(3); - vi.useRealTimers() - }) + vi.useRealTimers(); + }); - it('should handle multiple add/remove cycles correctly', () => { - const dispose = vi.fn() - const createSubscription = vi.fn(() => Disposable.create(dispose)) - const subscription = new EmitterSubscription(createSubscription) + it("should handle multiple add/remove cycles correctly", () => { + const dispose = vi.fn(); + const createSubscription = vi.fn(() => Disposable.create(dispose)); + const subscription = new EmitterSubscription(createSubscription); // Cycle 1 - const d1 = subscription.event(() => {}) - d1.dispose() - expect(createSubscription).toHaveBeenCalledTimes(1) - expect(dispose).toHaveBeenCalledTimes(1) + const d1 = subscription.event(() => {}); + d1.dispose(); + expect(createSubscription).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledTimes(1); // Cycle 2 - const d2 = subscription.event(() => {}) - d2.dispose() - expect(createSubscription).toHaveBeenCalledTimes(2) - expect(dispose).toHaveBeenCalledTimes(2) + const d2 = subscription.event(() => {}); + d2.dispose(); + expect(createSubscription).toHaveBeenCalledTimes(2); + expect(dispose).toHaveBeenCalledTimes(2); // Cycle 3 - const d3 = subscription.event(() => {}) - d3.dispose() - expect(createSubscription).toHaveBeenCalledTimes(3) - expect(dispose).toHaveBeenCalledTimes(3) - }) -}) \ No newline at end of file + const d3 = subscription.event(() => {}); + d3.dispose(); + expect(createSubscription).toHaveBeenCalledTimes(3); + expect(dispose).toHaveBeenCalledTimes(3); + }); +}); diff --git a/tests/pint-shells-client.test.ts b/tests/pint-shells-client.test.ts index a03ee1d..347ba2a 100644 --- a/tests/pint-shells-client.test.ts +++ b/tests/pint-shells-client.test.ts @@ -1,13 +1,12 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { PintShellsClient } from '../src/PintClient/execs'; -import { Client } from '../src/api-clients/pint/client'; -import * as pintApi from '../src/api-clients/pint'; -import { ExecItem } from '../src/api-clients/pint'; -import { ShellSize, ShellProcessType } from '../src/pitcher-protocol/messages/shell'; -import { IDisposable } from '../src/utils/disposable'; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { PintShellsClient } from "../src/PintClient/execs"; +import { Client } from "../src/api-clients/pint/client"; +import * as pintApi from "../src/api-clients/pint"; +import { ExecItem } from "../src/api-clients/pint"; +import { IDisposable } from "../src/utils/disposable"; // Mock the API functions -vi.mock('../src/api-clients/pint', () => ({ +vi.mock("../src/api-clients/pint", () => ({ createExec: vi.fn(), getExec: vi.fn(), listExecs: vi.fn(), @@ -19,9 +18,9 @@ vi.mock('../src/api-clients/pint', () => ({ })); // Mock the utils parseStreamEvent function -vi.mock('../src/PintClient/utils', () => ({ +vi.mock("../src/PintClient/utils", () => ({ parseStreamEvent: vi.fn((evt) => { - if (typeof evt === 'string') { + if (typeof evt === "string") { return JSON.parse(evt.substring(5)); } return evt; @@ -38,134 +37,132 @@ const createMockResponse = (data: any, error?: any) => ({ // Mock ExecItem for testing const createMockExecItem = (overrides: Partial = {}): ExecItem => ({ - id: 'exec-123', - command: 'bash', + id: "exec-123", + command: "bash", args: [], interactive: true, - status: 'RUNNING', + status: "RUNNING", exitCode: 0, pid: 1234, ...overrides, }); -describe('PintShellsClient', () => { +describe("PintShellsClient", () => { let client: PintShellsClient; let mockApiClient: Client; beforeEach(() => { vi.clearAllMocks(); mockApiClient = {} as Client; - client = new PintShellsClient(mockApiClient, 'sandbox-123'); + client = new PintShellsClient(mockApiClient, "sandbox-123"); }); - describe('create', () => { - it('should successfully create a new shell with command', async () => { + describe("create", () => { + it("should successfully create a new shell with command", async () => { const mockExec = createMockExecItem(); const mockResponse = createMockResponse(mockExec); - + vi.mocked(pintApi.createExec).mockResolvedValue(mockResponse); - - // Mock the open method call - const mockOpenResponse = createMockResponse(mockExec); - vi.mocked(pintApi.getExec).mockResolvedValue(mockOpenResponse); - vi.mocked(pintApi.getExecOutput).mockResolvedValue({ - ...createMockResponse({}), - stream: (async function* (): AsyncGenerator { - yield 'data:{"type":"stdout","output":"Welcome","sequence":1,"timestamp":"2023-01-01T12:00:00Z"}'; - })(), - }); - const result = await client.create( - '/workspace', - { cols: 80, rows: 24 }, - 'npm start', - 'COMMAND', - false - ); + const result = await client.create({ + command: "npm", + args: ["start"], + projectPath: "/workspace", + size: { cols: 80, rows: 24 }, + type: "COMMAND", + }); expect(result).toEqual({ isSystemShell: true, name: JSON.stringify({ - type: 'command', - command: 'bash', - name: '', + type: "command", + command: "bash", + name: "", }), - ownerUsername: 'root', - shellId: 'exec-123', - shellType: 'TERMINAL', - startCommand: 'bash', - status: 'RUNNING', + ownerUsername: "root", + shellId: "exec-123", + shellType: "TERMINAL", + startCommand: "bash", + status: "RUNNING", buffer: [], }); expect(pintApi.createExec).toHaveBeenCalledWith({ client: mockApiClient, body: { - args: ['start'], - command: 'npm', + args: ["start"], + command: "npm", interactive: false, }, }); }); - it('should create a shell with default bash command', async () => { - const mockExec = createMockExecItem({ command: 'bash' }); + it("should create a shell with default bash command", async () => { + const mockExec = createMockExecItem({ command: "bash" }); const mockResponse = createMockResponse(mockExec); - + vi.mocked(pintApi.createExec).mockResolvedValue(mockResponse); - vi.mocked(pintApi.getExec).mockResolvedValue(mockResponse); - vi.mocked(pintApi.getExecOutput).mockResolvedValue({ - ...createMockResponse({}), - stream: (async function* (): AsyncGenerator {})(), - }); - await client.create('/workspace', { cols: 80, rows: 24 }); + await client.create({ + command: "bash", + args: [], + projectPath: "/workspace", + size: { cols: 80, rows: 24 }, + }); expect(pintApi.createExec).toHaveBeenCalledWith({ client: mockApiClient, body: { args: [], - command: 'bash', + command: "bash", interactive: true, }, }); }); - it('should handle API error during creation', async () => { - const mockResponse = createMockResponse(null, { message: 'Creation failed' }); + it("should handle API error during creation", async () => { + const mockResponse = createMockResponse(null, { + message: "Creation failed", + }); vi.mocked(pintApi.createExec).mockResolvedValue(mockResponse); await expect( - client.create('/workspace', { cols: 80, rows: 24 }) - ).rejects.toThrow('Creation failed'); + client.create({ + command: "bash", + args: [], + projectPath: "/workspace", + size: { cols: 80, rows: 24 }, + }) + ).rejects.toThrow("Creation failed"); }); - it('should set interactive based on shell type', async () => { + it("should set interactive based on shell type", async () => { const mockExec = createMockExecItem(); const mockResponse = createMockResponse(mockExec); - + vi.mocked(pintApi.createExec).mockResolvedValue(mockResponse); - vi.mocked(pintApi.getExec).mockResolvedValue(mockResponse); - vi.mocked(pintApi.getExecOutput).mockResolvedValue({ - ...createMockResponse({}), - stream: (async function* (): AsyncGenerator {})(), - }); - await client.create('/workspace', { cols: 80, rows: 24 }, 'echo test', 'TERMINAL'); + await client.create({ + command: "echo", + args: ["test"], + projectPath: "/workspace", + size: { cols: 80, rows: 24 }, + type: "TERMINAL", + }); expect(pintApi.createExec).toHaveBeenCalledWith({ client: mockApiClient, body: { - args: ['test'], - command: 'echo', + args: ["test"], + command: "echo", interactive: true, }, }); }); }); - describe('delete', () => { - it('should successfully delete an existing shell', async () => { + describe("delete", () => { + it("should successfully delete an existing shell", async () => { const mockExec = createMockExecItem(); const getResponse = createMockResponse(mockExec); const deleteResponse = createMockResponse({ success: true }); @@ -173,43 +170,43 @@ describe('PintShellsClient', () => { vi.mocked(pintApi.getExec).mockResolvedValue(getResponse); vi.mocked(pintApi.deleteExec).mockResolvedValue(deleteResponse); - const result = await client.delete('exec-123'); + const result = await client.delete("exec-123"); expect(result).toEqual({ isSystemShell: true, name: JSON.stringify({ - type: 'command', - command: 'bash', - name: '', + type: "command", + command: "bash", + name: "", }), - ownerUsername: 'root', - shellId: 'exec-123', - shellType: 'TERMINAL', - startCommand: 'bash', - status: 'RUNNING', + ownerUsername: "root", + shellId: "exec-123", + shellType: "TERMINAL", + startCommand: "bash", + status: "RUNNING", }); expect(pintApi.getExec).toHaveBeenCalledWith({ client: mockApiClient, - path: { id: 'exec-123' }, + path: { id: "exec-123" }, }); expect(pintApi.deleteExec).toHaveBeenCalledWith({ client: mockApiClient, - path: { id: 'exec-123' }, + path: { id: "exec-123" }, }); }); - it('should return null if shell does not exist', async () => { + it("should return null if shell does not exist", async () => { const getResponse = createMockResponse(null); vi.mocked(pintApi.getExec).mockResolvedValue(getResponse); - const result = await client.delete('nonexistent'); + const result = await client.delete("nonexistent"); expect(result).toBeNull(); expect(pintApi.deleteExec).not.toHaveBeenCalled(); }); - it('should return null if deletion fails', async () => { + it("should return null if deletion fails", async () => { const mockExec = createMockExecItem(); const getResponse = createMockResponse(mockExec); const deleteResponse = createMockResponse(null); @@ -217,25 +214,29 @@ describe('PintShellsClient', () => { vi.mocked(pintApi.getExec).mockResolvedValue(getResponse); vi.mocked(pintApi.deleteExec).mockResolvedValue(deleteResponse); - const result = await client.delete('exec-123'); + const result = await client.delete("exec-123"); expect(result).toBeNull(); }); - it('should handle exceptions gracefully', async () => { - vi.mocked(pintApi.getExec).mockRejectedValue(new Error('Network error')); + it("should handle exceptions gracefully", async () => { + vi.mocked(pintApi.getExec).mockRejectedValue(new Error("Network error")); - const result = await client.delete('exec-123'); + const result = await client.delete("exec-123"); expect(result).toBeNull(); }); }); - describe('getShells', () => { - it('should return list of shells converted from execs', async () => { + describe("getShells", () => { + it("should return list of shells converted from execs", async () => { const mockExecs = [ - createMockExecItem({ id: 'exec-1', command: 'bash' }), - createMockExecItem({ id: 'exec-2', command: 'npm', status: 'EXITED' as any }), + createMockExecItem({ id: "exec-1", command: "bash" }), + createMockExecItem({ + id: "exec-2", + command: "npm", + status: "EXITED" as any, + }), ]; const mockResponse = createMockResponse({ execs: mockExecs }); vi.mocked(pintApi.listExecs).mockResolvedValue(mockResponse); @@ -246,20 +247,20 @@ describe('PintShellsClient', () => { expect(result[0]).toEqual({ isSystemShell: true, name: JSON.stringify({ - type: 'command', - command: 'bash', - name: '', + type: "command", + command: "bash", + name: "", }), - ownerUsername: 'root', - shellId: 'exec-1', - shellType: 'TERMINAL', - startCommand: 'bash', - status: 'RUNNING', + ownerUsername: "root", + shellId: "exec-1", + shellType: "TERMINAL", + startCommand: "bash", + status: "RUNNING", }); - expect(result[1].status).toBe('EXITED'); + expect(result[1].status).toBe("EXITED"); }); - it('should return empty array if no execs found', async () => { + it("should return empty array if no execs found", async () => { const mockResponse = createMockResponse({ execs: [] }); vi.mocked(pintApi.listExecs).mockResolvedValue(mockResponse); @@ -268,7 +269,7 @@ describe('PintShellsClient', () => { expect(result).toEqual([]); }); - it('should handle API error by returning empty array', async () => { + it("should handle API error by returning empty array", async () => { const mockResponse = createMockResponse(null); vi.mocked(pintApi.listExecs).mockResolvedValue(mockResponse); @@ -278,117 +279,81 @@ describe('PintShellsClient', () => { }); }); - describe('open', () => { - it('should successfully open a shell and return with output buffer', async () => { - const mockExec = createMockExecItem(); - const getResponse = createMockResponse(mockExec); - const outputStream = (async function* (): AsyncGenerator { - yield 'data:{"type":"stdout","output":"Hello","sequence":1,"timestamp":"2023-01-01T12:00:00Z"}'; - })(); - - vi.mocked(pintApi.getExec).mockResolvedValue(getResponse); - vi.mocked(pintApi.getExecOutput).mockResolvedValue({ - ...createMockResponse({}), - stream: outputStream, - }); - - const result = await client.open('exec-123', { cols: 80, rows: 24 }); - expect(result).toEqual({ - buffer: ['Hello'], - isSystemShell: true, - name: JSON.stringify({ - type: 'command', - command: 'bash', - name: '', - }), - ownerUsername: 'root', - shellId: 'exec-123', - shellType: 'TERMINAL', - startCommand: 'bash', - status: 'RUNNING', - }); - - expect(pintApi.getExec).toHaveBeenCalledWith({ - client: mockApiClient, - path: { id: 'exec-123' }, - }); - }); - - it('should handle shell that does not exist', async () => { - const getResponse = createMockResponse(null, { message: 'Not found' }); - vi.mocked(pintApi.getExec).mockResolvedValue(getResponse); - - await expect( - client.open('nonexistent', { cols: 80, rows: 24 }) - ).rejects.toThrow('Not found'); - }); - }); - - describe('rename', () => { - it('should return null as rename is not implemented', async () => { - const result = await client.rename('exec-123', 'new-name'); + describe("rename", () => { + it("should return null as rename is not implemented", async () => { + const result = await client.rename("exec-123", "new-name"); expect(result).toBeNull(); }); }); - describe('restart', () => { - it('should successfully restart a shell', async () => { + describe("restart", () => { + it("should successfully restart a shell", async () => { const mockResponse = createMockResponse({ success: true }); vi.mocked(pintApi.updateExec).mockResolvedValue(mockResponse); - const result = await client.restart('exec-123'); + const result = await client.restart("exec-123"); expect(result).toBeNull(); expect(pintApi.updateExec).toHaveBeenCalledWith({ client: mockApiClient, - path: { id: 'exec-123' }, - body: { status: 'running' }, + path: { id: "exec-123" }, + body: { status: "running" }, }); }); - it('should handle restart failure gracefully', async () => { - vi.mocked(pintApi.updateExec).mockRejectedValue(new Error('Restart failed')); + it("should handle restart failure gracefully", async () => { + vi.mocked(pintApi.updateExec).mockRejectedValue( + new Error("Restart failed") + ); - const result = await client.restart('exec-123'); + const result = await client.restart("exec-123"); expect(result).toBeNull(); }); }); - describe('send', () => { - it('should successfully send input to shell', async () => { + describe("send", () => { + it("should successfully send input to shell", async () => { const mockResponse = createMockResponse({ success: true }); vi.mocked(pintApi.execExecStdin).mockResolvedValue(mockResponse); - const result = await client.send('exec-123', 'echo hello', { cols: 80, rows: 24 }); + const result = await client.send("exec-123", "echo hello", { + cols: 80, + rows: 24, + }); expect(result).toBeNull(); expect(pintApi.execExecStdin).toHaveBeenCalledWith({ client: mockApiClient, - path: { id: 'exec-123' }, + path: { id: "exec-123" }, body: { - type: 'stdin', - input: 'echo hello', + type: "stdin", + input: "echo hello", }, }); }); - it('should handle send failure gracefully', async () => { - vi.mocked(pintApi.execExecStdin).mockRejectedValue(new Error('Send failed')); + it("should handle send failure gracefully", async () => { + vi.mocked(pintApi.execExecStdin).mockRejectedValue( + new Error("Send failed") + ); - const result = await client.send('exec-123', 'test', { cols: 80, rows: 24 }); + const result = await client.send("exec-123", "test", { + cols: 80, + rows: 24, + }); expect(result).toBeNull(); }); }); - describe('convertExecToShellDTO', () => { - it('should convert ExecItem to ShellDTO format', async () => { + describe("convertExecToShellDTO", () => { + it("should convert ExecItem to ShellDTO format", async () => { const mockExec = createMockExecItem({ - id: 'test-exec', - command: 'node server.js', - status: 'RUNNING' as any, + id: "test-exec", + command: "node server.js", + status: "RUNNING" as any, }); // Access private method via bracket notation for testing @@ -397,58 +362,180 @@ describe('PintShellsClient', () => { expect(result).toEqual({ isSystemShell: true, name: JSON.stringify({ - type: 'command', - command: 'node server.js', - name: '', + type: "command", + command: "node server.js", + name: "", }), - ownerUsername: 'root', - shellId: 'test-exec', - shellType: 'TERMINAL', - startCommand: 'node server.js', - status: 'RUNNING', + ownerUsername: "root", + shellId: "test-exec", + shellType: "TERMINAL", + startCommand: "node server.js", + status: "RUNNING", }); }); }); - describe('event emitters', () => { - it('should have onShellExited event emitter', () => { - expect(client.onShellExited).toBeDefined(); - expect(typeof client.onShellExited).toBe('function'); - }); + describe("subscribe", () => { + it("should subscribe to shell exit events", async () => { + // Mock the stream for subscribeAndEvaluateExecsUpdates + const streamMock = (async function* (): AsyncGenerator< + string, + any, + unknown + > { + // First yield: initial state with RUNNING status + yield 'data:{"execs":[{"id":"exec-123","status":"RUNNING","exitCode":null,"command":"bash","args":[],"interactive":true,"pid":1234}]}'; + // Second yield: state change to EXITED + yield 'data:{"execs":[{"id":"exec-123","status":"EXITED","exitCode":0,"command":"bash","args":[],"interactive":true,"pid":1234}]}'; + })(); - it('should have onShellOut event emitter', () => { - expect(client.onShellOut).toBeDefined(); - expect(typeof client.onShellOut).toBe('function'); - }); + vi.mocked(pintApi.streamExecsList).mockResolvedValue({ + ...createMockResponse({}), + stream: streamMock, + }); + + const events: any[] = []; + const disposable = client.subscribe("exec-123", (event) => { + events.push(event); + }); + + // Give some time for the stream to process + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: "exit", + exitCode: 0, + }); - it('should have onShellTerminated event emitter', () => { - expect(client.onShellTerminated).toBeDefined(); - expect(typeof client.onShellTerminated).toBe('function'); + disposable.dispose(); }); - it('should emit shell exit events when status changes from RUNNING to EXITED', async () => { - // Mock the stream for subscribeAndEvaluateExecsUpdates - const streamMock = (async function* (): AsyncGenerator { - yield 'data:{"execs":[{"id":"exec-123","status":"EXITED","exitCode":0,"command":"bash","args":[],"interactive":true,"pid":1234}]}'; - })(); - + it("should return disposable for cleanup", () => { + const streamMock = (async function* (): AsyncGenerator< + string, + any, + unknown + > {})(); + vi.mocked(pintApi.streamExecsList).mockResolvedValue({ ...createMockResponse({}), stream: streamMock, }); - // Test that the event emitter is properly set up - const unsubscribe: IDisposable = client.onShellExited((event) => { - expect(event.shellId).toBe('exec-123'); - expect(event.exitCode).toBe(0); + const disposable = client.subscribe("exec-123", () => {}); + + expect(disposable).toBeDefined(); + expect(typeof disposable.dispose).toBe("function"); + + disposable.dispose(); + }); + }); + + describe("subscribeOutput", () => { + it("should subscribe to shell output events", async () => { + const outputStream = (async function* (): AsyncGenerator< + string, + any, + unknown + > { + yield 'data:{"type":"stdout","output":"Hello World","sequence":1,"timestamp":"2023-01-01T12:00:00Z"}'; + yield 'data:{"type":"stdout","output":"Second line","sequence":2,"timestamp":"2023-01-01T12:00:01Z"}'; + })(); + + vi.mocked(pintApi.getExecOutput).mockResolvedValue({ + ...createMockResponse({}), + stream: outputStream, }); + const outputs: any[] = []; + const disposable = client.subscribeOutput( + "exec-123", + { cols: 80, rows: 24 }, + (event) => { + outputs.push(event); + } + ); + // Give some time for the stream to process - await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(outputs.length).toBeGreaterThan(0); + expect(outputs[0]).toEqual({ + out: "Hello World", + exitCode: undefined, + }); + + disposable.dispose(); - unsubscribe.dispose(); - // Note: Due to the async nature of the stream, we can't easily test the actual firing - // without more complex mocking, but we can verify the structure exists + expect(pintApi.getExecOutput).toHaveBeenCalledWith({ + client: mockApiClient, + path: { id: "exec-123" }, + query: { lastSequence: 0 }, + signal: expect.any(AbortSignal), + headers: { + Accept: "text/event-stream", + }, + }); + }); + + it("should handle output with exit code", async () => { + const outputStream = (async function* (): AsyncGenerator< + string, + any, + unknown + > { + yield 'data:{"type":"stdout","output":"Done","sequence":1,"timestamp":"2023-01-01T12:00:00Z","exitCode":0}'; + })(); + + vi.mocked(pintApi.getExecOutput).mockResolvedValue({ + ...createMockResponse({}), + stream: outputStream, + }); + + const outputs: any[] = []; + const disposable = client.subscribeOutput( + "exec-123", + { cols: 80, rows: 24 }, + (event) => { + outputs.push(event); + } + ); + + // Give some time for the stream to process + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(outputs.length).toBeGreaterThan(0); + expect(outputs[0]).toEqual({ + out: "Done", + exitCode: 0, + }); + + disposable.dispose(); + }); + + it("should return disposable for cleanup", () => { + const outputStream = (async function* (): AsyncGenerator< + string, + any, + unknown + > {})(); + + vi.mocked(pintApi.getExecOutput).mockResolvedValue({ + ...createMockResponse({}), + stream: outputStream, + }); + + const disposable = client.subscribeOutput( + "exec-123", + { cols: 80, rows: 24 }, + () => {} + ); + + expect(disposable).toBeDefined(); + expect(typeof disposable.dispose).toBe("function"); + + disposable.dispose(); }); }); -}); \ No newline at end of file +}); diff --git a/tests/sandbox-creation.test.ts b/tests/sandbox-creation.test.ts index e93444b..22ab9e7 100644 --- a/tests/sandbox-creation.test.ts +++ b/tests/sandbox-creation.test.ts @@ -1,80 +1,89 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import nock from 'nock' -import { CodeSandbox } from '../src/index' -import { - mockForkSandboxSuccess, - mockStartVMSuccess, - setupTestEnvironment, - cleanupTestEnvironment -} from './test-utils' - -describe('Sandbox Creation', () => { +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import nock from "nock"; +import { CodeSandbox } from "../src/index"; +import { + mockForkSandboxSuccess, + mockStartVMSuccess, + setupTestEnvironment, + cleanupTestEnvironment, +} from "./test-utils"; + +describe("Sandbox Creation", () => { beforeEach(() => { - setupTestEnvironment() - }) + setupTestEnvironment(); + }); afterEach(() => { - cleanupTestEnvironment() - }) + cleanupTestEnvironment(); + }); - it('should successfully create and start a sandbox', async () => { + it("should successfully create and start a sandbox", async () => { // Mock the fork sandbox API call (pcz35m is the default template) - const forkScope = mockForkSandboxSuccess('test-sandbox-123', { - title: 'Test Sandbox', - description: 'Integration test sandbox', + const forkScope = mockForkSandboxSuccess("test-sandbox-123", { + title: "Test Sandbox", + description: "Integration test sandbox", privacy: 1, - tags: ['integration-test', 'sdk'] - }) + tags: ["integration-test", "sdk"], + }); // Mock the start VM API call - use regex to match any ID - const startScope = mockStartVMSuccess('test-sandbox-123') + const startScope = mockStartVMSuccess("test-sandbox-123"); // Initialize SDK - const sdk = new CodeSandbox() - + const sdk = new CodeSandbox(); + // Create sandbox const sandbox = await sdk.sandboxes.create({ - title: 'Test Sandbox', - description: 'Integration test sandbox', - privacy: 'unlisted', - tags: ['integration-test'] - }) + title: "Test Sandbox", + description: "Integration test sandbox", + privacy: "unlisted", + tags: ["integration-test"], + }); // Verify sandbox was created successfully - expect(sandbox).toBeDefined() - expect(sandbox.id).toBe('test-sandbox-123') - + expect(sandbox).toBeDefined(); + expect(sandbox.id).toBe("test-sandbox-123"); + // Verify all API calls were made - expect(forkScope.isDone()).toBe(true) - expect(startScope.isDone()).toBe(true) - }, 10000) // 10 second timeout for integration test + expect(forkScope.isDone()).toBe(true); + expect(startScope.isDone()).toBe(true); + }, 10000); // 10 second timeout for integration test - it('should use default template when no id is provided', async () => { + it("should use default template when no id is provided", async () => { // Mock default template call - pcz35m is the default template - const forkScope = mockForkSandboxSuccess('default-sandbox-456') + // Default privacy is "public-hosts" which maps to privacy: 2, private_preview: false + const forkScope = mockForkSandboxSuccess("default-sandbox-456", { + privacy: 2, + private_preview: false, + }); + + const startScope = mockStartVMSuccess("default-sandbox-456"); - const startScope = mockStartVMSuccess('default-sandbox-456') + const sdk = new CodeSandbox(); - const sdk = new CodeSandbox() - // Create sandbox without specifying template id - const sandbox = await sdk.sandboxes.create() - - expect(sandbox).toBeDefined() - expect(sandbox.id).toBe('default-sandbox-456') - expect(forkScope.isDone()).toBe(true) - expect(startScope.isDone()).toBe(true) - }) - - it('should handle API errors gracefully', async () => { - // Mock fork sandbox failure - nock('https://api.codesandbox.io') - .post('/sandbox/pcz35m/fork') - .reply(500, { message: 'Internal server error' }) - - const sdk = new CodeSandbox() - + const sandbox = await sdk.sandboxes.create(); + + expect(sandbox).toBeDefined(); + expect(sandbox.id).toBe("default-sandbox-456"); + expect(forkScope.isDone()).toBe(true); + expect(startScope.isDone()).toBe(true); + }); + + it("should handle API errors gracefully", async () => { + // Mock fork sandbox failure with expected request body + nock("https://api.codesandbox.io") + .post("/sandbox/pcz35m/fork", { + privacy: 2, + tags: ["sdk"], + path: "/SDK", + private_preview: false + }) + .reply(500, { message: "Internal server error" }); + + const sdk = new CodeSandbox(); + // Expect the creation to throw an error - await expect(sdk.sandboxes.create()).rejects.toThrow() - }) -}) \ No newline at end of file + await expect(sdk.sandboxes.create()).rejects.toThrow(); + }); +}); diff --git a/tests/sandbox-retry-behavior.test.ts b/tests/sandbox-retry-behavior.test.ts index 4e03c80..f9e2853 100644 --- a/tests/sandbox-retry-behavior.test.ts +++ b/tests/sandbox-retry-behavior.test.ts @@ -20,10 +20,15 @@ describe('Create operation retry behavior', () => { it('should fail immediately on fork API error (no retry for fork)', async () => { let forkRequestCount = 0 - + // Mock fork to fail once - should fail immediately since fork doesn't retry const forkScope = nock('https://api.codesandbox.io') - .post('/sandbox/pcz35m/fork') + .post('/sandbox/pcz35m/fork', { + privacy: 2, + tags: ['sdk'], + path: '/SDK', + private_preview: false + }) .reply(500, () => { forkRequestCount++ return { error: { errors: ['Fork failed'] } } @@ -44,9 +49,12 @@ describe('Create operation retry behavior', () => { it('should retry start VM failures and eventually succeed', async () => { let startVMRequestCount = 0 - - // Mock successful fork - const forkScope = mockForkSandboxSuccess('test-sandbox-start-retry') + + // Mock successful fork with default privacy settings + const forkScope = mockForkSandboxSuccess('test-sandbox-start-retry', { + privacy: 2, + private_preview: false, + }) // Mock start VM to fail twice const failureScope = nock('https://api.codesandbox.io') @@ -92,9 +100,12 @@ describe('Create operation retry behavior', () => { it('should fail create after start VM exhausts all retries', async () => { let startVMRequestCount = 0 - - // Mock successful fork - const forkScope = mockForkSandboxSuccess('test-sandbox-start-fail') + + // Mock successful fork with default privacy settings + const forkScope = mockForkSandboxSuccess('test-sandbox-start-fail', { + privacy: 2, + private_preview: false, + }) // Mock start VM to fail all 3 retry attempts const failureScope = nock('https://api.codesandbox.io') @@ -117,9 +128,12 @@ describe('Create operation retry behavior', () => { it('should validate retry timing for start VM failures', async () => { let startVMRequestCount = 0 - - // Mock successful fork - const forkScope = mockForkSandboxSuccess('test-sandbox-timing') + + // Mock successful fork with default privacy settings + const forkScope = mockForkSandboxSuccess('test-sandbox-timing', { + privacy: 2, + private_preview: false, + }) // Mock start VM to fail twice const failureScope = nock('https://api.codesandbox.io') diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 6ad1cf8..219c219 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -1,99 +1,150 @@ -import nock from 'nock' +import nock from "nock"; -export const mockForkSandboxSuccess = (sandboxId: string, options?: { - title?: string - description?: string - privacy?: number - tags?: string[] -}) => { - return nock('https://api.codesandbox.io') - .post('/sandbox/pcz35m/fork', { - privacy: options?.privacy ?? 1, - ...(options?.title && { title: options.title }), - ...(options?.description && { description: options.description }), - tags: options?.tags ?? ['sdk'], - path: '/SDK' - }) +export const mockForkSandboxSuccess = ( + sandboxId: string, + options?: { + title?: string; + description?: string; + privacy?: number; + tags?: string[]; + private_preview?: boolean; + } +) => { + const requestBody: Record = { + privacy: options?.privacy ?? 1, + ...(options?.title && { title: options.title }), + ...(options?.description && { description: options.description }), + tags: options?.tags ?? ["sdk"], + path: "/SDK", + }; + + // Only add private_preview if explicitly provided + if (options?.private_preview !== undefined) { + requestBody.private_preview = options.private_preview; + } + + return nock("https://api.codesandbox.io") + .post("/sandbox/pcz35m/fork", requestBody) .reply(200, { data: { id: sandboxId, - title: options?.title ?? 'Test Sandbox', + title: options?.title ?? "Test Sandbox", description: options?.description, privacy: options?.privacy ?? 1, - tags: options?.tags ?? ['sdk'], - created_at: '2025-01-21T12:00:00Z', - updated_at: '2025-01-21T12:00:00Z' - } - }) -} + tags: options?.tags ?? ["sdk"], + created_at: "2025-01-21T12:00:00Z", + updated_at: "2025-01-21T12:00:00Z", + }, + }); +}; -export const mockStartVMSuccess = (sandboxId: string, bootupType: 'CLEAN' | 'RESUME' = 'CLEAN') => { - return nock('https://api.codesandbox.io') +export const mockStartVMSuccess = ( + sandboxId: string, + bootupType: "CLEAN" | "RESUME" = "CLEAN" +) => { + return nock("https://api.codesandbox.io") .post(/\/vm\/.*\/start/) .reply(200, { data: { bootup_type: bootupType, - cluster: 'test-cluster', + cluster: "test-cluster", pitcher_url: `wss://pitcher.codesandbox.io/${sandboxId}`, - workspace_path: '/project/sandbox', - user_workspace_path: '/project/sandbox', - pitcher_manager_version: '1.0.0', - pitcher_version: '1.0.0', - latest_pitcher_version: '1.0.0', - pitcher_token: `pitcher-token-${sandboxId.split('-').pop()}` - } - }) -} + workspace_path: "/project/sandbox", + user_workspace_path: "/project/sandbox", + pitcher_manager_version: "1.0.0", + pitcher_version: "1.0.0", + latest_pitcher_version: "1.0.0", + pitcher_token: `pitcher-token-${sandboxId.split("-").pop()}`, + }, + }); +}; -export const mockStartVMFailure = (times: number = 1, errorMessage: string = 'Start failed') => { - return nock('https://api.codesandbox.io') +export const mockStartVMFailure = ( + times: number = 1, + errorMessage: string = "Start failed" +) => { + return nock("https://api.codesandbox.io") .post(/\/vm\/.*\/start/) .times(times) - .reply(500, { error: { errors: [errorMessage] } }) -} + .reply(500, { error: { errors: [errorMessage] } }); +}; export const mockHibernateSuccess = (sandboxId: string) => { - return nock('https://api.codesandbox.io') + return nock("https://api.codesandbox.io") .post(`/vm/${sandboxId}/hibernate`) .reply(200, { data: { - success: true - } - }) -} + success: true, + }, + }); +}; -export const mockHibernateFailure = (sandboxId: string, times: number = 1, errorMessage: string = 'Server error') => { - return nock('https://api.codesandbox.io') +export const mockHibernateFailure = ( + sandboxId: string, + times: number = 1, + errorMessage: string = "Server error" +) => { + return nock("https://api.codesandbox.io") .post(`/vm/${sandboxId}/hibernate`) .times(times) - .reply(500, { error: { errors: [errorMessage] } }) -} + .reply(500, { error: { errors: [errorMessage] } }); +}; export const mockShutdownSuccess = (sandboxId: string) => { - return nock('https://api.codesandbox.io') + return nock("https://api.codesandbox.io") .post(`/vm/${sandboxId}/shutdown`) .reply(200, { data: { - success: true - } - }) -} + success: true, + }, + }); +}; -export const mockShutdownFailure = (sandboxId: string, times: number = 1, errorMessage: string = 'Shutdown failed') => { - return nock('https://api.codesandbox.io') +export const mockShutdownFailure = ( + sandboxId: string, + times: number = 1, + errorMessage: string = "Shutdown failed" +) => { + return nock("https://api.codesandbox.io") .post(`/vm/${sandboxId}/shutdown`) .times(times) - .reply(500, { error: { errors: [errorMessage] } }) -} + .reply(500, { error: { errors: [errorMessage] } }); +}; export const setupTestEnvironment = () => { - process.env.CSB_API_KEY = 'csb_test_key_123' - nock.cleanAll() -} + process.env.CSB_API_KEY = "csb_test_key_123"; + nock.cleanAll(); +}; export const cleanupTestEnvironment = () => { if (!nock.isDone()) { - console.error('Unused nock interceptors:', nock.pendingMocks()) + console.error("Unused nock interceptors:", nock.pendingMocks()); + } + nock.cleanAll(); +}; + +/** + * Properly cleanup test sandbox with correct sequencing: + * 1. Wait for client cleanup (disconnect and dispose) + * 2. Wait for shutdown with 10 second timeout + * 3. Fire-and-forget delete (with small delay to ensure request goes through) + */ +export const cleanupTestSandbox = async ( + client: any | undefined, + sandboxId: string | undefined, + sdk: any +): Promise => { + if (client) { + try { + await client.disconnect(); + client.dispose(); + } catch (error) { + console.error("Failed to disconnect client:", error); + } + } + + if (sandboxId) { + await sdk.sandboxes.shutdown(sandboxId); + await sdk.sandboxes.delete(sandboxId); } - nock.cleanAll() -} \ No newline at end of file +}; diff --git a/vitest.config.ts b/vitest.config.ts index cba2eed..6cd2bf3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { environment: 'node', include: ['tests/**/*.test.ts'], + testTimeout: 10000, // Doubled from default 5000ms }, define: { CSB_SDK_VERSION: JSON.stringify('2.1.0-rc.4'), From 598d80234d1e5a69c056d25cc9a0363ad61923ff Mon Sep 17 00:00:00 2001 From: Christian Alfoni Date: Wed, 3 Dec 2025 15:33:52 +0100 Subject: [PATCH 11/46] add instructions on local testing --- README.md | 19 +++++++++++++++++++ tests/e2e/helpers.ts | 7 ++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ab84dd4..2706958 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,25 @@ const output = await client.commands.run("echo 'Hello World'"); console.log(output); // Hello World ``` +## Running tests + +### All tests + +- Run all tests with `npm run test` +- Run specific test file `npm run test -- filesystem` + +### E2E production + +- Run e2e tests with `npm run test:e2e` +- Run specific test file `npm run test -- filesystem` + +### E2E local + +- Clone the sandbox templates repo (https://github.com/codesandbox/sandbox-templates) +- Build template with `csb build ../sandbox-templates/nextjs` +- Run e2e tests with `CSB_BASE_URL=https://api.codesandbox.dev CSB_TEMPLATE_ID=$NEXTJS_TEMPLATE_ID npm run test:e2e` +- Run specific test file `CSB_BASE_URL=https://api.codesandbox.dev CSB_TEMPLATE_ID=$NEXTJS_TEMPLATE_ID npm run test -- filesystem` + ## Efficient Sandbox Retrieval When you need to retrieve metadata for specific sandboxes by their IDs, you can use the efficient retrieval methods instead of listing and filtering all sandboxes: diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 6f33ed6..8490afc 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -3,14 +3,15 @@ import { CodeSandbox } from "../../src/index.js"; /** * Test template ID used across e2e tests */ -export const TEST_TEMPLATE_ID = process.env.CSB_TEST_TEMPLATE_ID ?? ""; +export const TEST_TEMPLATE_ID = + process.env.CSB_TEST_TEMPLATE_ID ?? "pt_FXCz5KGvDQsafzZz7awrSe"; /** * Initialize SDK with API key from environment */ export function initializeSDK(): CodeSandbox { - return new CodeSandbox("csb_v1_devbox", { - baseUrl: "http://codesandbox.dev", + return new CodeSandbox(process.env.CSB_API_KEY, { + baseUrl: process.env.CSB_BASE_URL, }); } From 28020c8d574be3aa97245a2f50b3b39880c12751 Mon Sep 17 00:00:00 2001 From: Christian Alfoni Date: Wed, 3 Dec 2025 15:40:15 +0100 Subject: [PATCH 12/46] fix dynamic use of base url --- tests/e2e/helpers.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 8490afc..c5af65b 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -10,9 +10,13 @@ export const TEST_TEMPLATE_ID = * Initialize SDK with API key from environment */ export function initializeSDK(): CodeSandbox { - return new CodeSandbox(process.env.CSB_API_KEY, { - baseUrl: process.env.CSB_BASE_URL, - }); + if (process.env.CSB_BASE_URL) { + return new CodeSandbox(process.env.CSB_API_KEY, { + baseUrl: process.env.CSB_BASE_URL, + }); + } + + return new CodeSandbox(process.env.CSB_API_KEY); } /** From 72441cc006448095068a2ed25e38c978a46eb24a Mon Sep 17 00:00:00 2001 From: mohaimen Date: Sat, 13 Dec 2025 22:15:20 +0100 Subject: [PATCH 13/46] update pint spec file --- pint-openapi-bundled.json | 191 +++++++++++++++++++++++++++++++------- 1 file changed, 155 insertions(+), 36 deletions(-) diff --git a/pint-openapi-bundled.json b/pint-openapi-bundled.json index a655213..b6da8e4 100644 --- a/pint-openapi-bundled.json +++ b/pint-openapi-bundled.json @@ -1820,6 +1820,116 @@ } } } + }, + "/api/v1/stream/directories/watcher/{path}": { + "get": { + "summary": "Watch directory changes using Server-Sent Events (SSE)", + "tags": [ + "streams", + "files" + ], + "description": "Watch a directory for file system changes and stream events via SSE.", + "operationId": "CreateWatcher", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "description": "Directory path to watch", + "schema": { + "type": "string" + }, + "example": "workspace/src/main.go" + }, + { + "name": "recursive", + "in": "query", + "required": false, + "description": "Whether to watch directories recursively", + "schema": { + "type": "boolean" + }, + "example": true + }, + { + "name": "ignorePatterns", + "in": "query", + "required": false, + "description": "Glob patterns to ignore certain files or directories (can be specified multiple times)", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "style": "form", + "explode": true, + "example": [ + "*.log", + "temp/*", + "node_modules/*" + ] + } + ], + "responses": { + "200": { + "description": "Directory watcher stream started successfully", + "content": { + "text/event-stream": { + "schema": { + "type": "string", + "description": "Server-Sent Events stream of directory files updates" + } + } + } + }, + "400": { + "description": "Bad Request - Path is required or invalid path", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error - Failed to create file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "default": { + "description": "Unexpected Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } } }, "components": { @@ -2020,6 +2130,10 @@ "type": "boolean", "description": "Whether the exec is interactive" }, + "pty": { + "type": "boolean", + "description": "Whether the exec is using a pty" + }, "exitCode": { "type": "integer", "description": "Exit code of the process (only present when process has exited)" @@ -2032,6 +2146,7 @@ "status", "pid", "interactive", + "pty", "exitCode" ] }, @@ -2071,6 +2186,10 @@ "interactive": { "type": "boolean", "description": "Whether to start interactive shell session or not (defaults to false)" + }, + "pty": { + "type": "boolean", + "description": "Whether to start pty shell session or not (defaults to false)" } }, "required": [ @@ -2105,6 +2224,42 @@ "message" ] }, + "ExecStdout": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Type of the exec output", + "enum": [ + "stdout", + "stderr" + ] + }, + "output": { + "type": "string", + "description": "Data associated with the exec output" + }, + "sequence": { + "type": "integer", + "format": "int32", + "description": "Sequence number of the output message" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "Timestamp of when the output was generated" + }, + "exitCode": { + "type": "integer", + "description": "Exit code of the process (only present when process has exited)" + } + }, + "required": [ + "type", + "output", + "sequence" + ] + }, "ExecStdin": { "type": "object", "properties": { @@ -2400,42 +2555,6 @@ "ports" ] }, - "ExecStdout": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "Type of the exec output", - "enum": [ - "stdout", - "stderr" - ] - }, - "output": { - "type": "string", - "description": "Data associated with the exec output" - }, - "sequence": { - "type": "integer", - "format": "int32", - "description": "Sequence number of the output message" - }, - "timestamp": { - "type": "string", - "format": "date-time", - "description": "Timestamp of when the output was generated" - }, - "exitCode": { - "type": "integer", - "description": "Exit code of the process (only present when process has exited)" - } - }, - "required": [ - "type", - "output", - "sequence" - ] - }, "Task": { "$ref": "#/components/schemas/TaskItem" } From df65792a8187947fc3cf0eeee4726841d9617fd5 Mon Sep 17 00:00:00 2001 From: mohaimen Date: Sat, 13 Dec 2025 22:31:38 +0100 Subject: [PATCH 14/46] integrate pint watcher api --- openapi.json | 31 +------- src/PintClient/fs.ts | 53 +++++++++++++- src/api-clients/client/types.gen.ts | 27 +------ src/api-clients/pint/sdk.gen.ts | 19 ++++- src/api-clients/pint/types.gen.ts | 105 ++++++++++++++++++++++------ 5 files changed, 153 insertions(+), 82 deletions(-) diff --git a/openapi.json b/openapi.json index 4f107f7..caee078 100644 --- a/openapi.json +++ b/openapi.json @@ -130,36 +130,6 @@ "example": "pt_1234567890", "type": "string" }, - "image": { - "description": "Container image to use as template", - "properties": { - "architecture": { - "description": "The architecture of the image. Required for multi-platform images", - "type": "string" - }, - "name": { - "description": "The image name (for example 'nginx').", - "type": "string" - }, - "registry": { - "default": "docker.io", - "description": "The container registry where the image is stored.", - "type": "string" - }, - "repository": { - "default": "library", - "description": "The repository or namespace where the image is stored.", - "type": "string" - }, - "tag": { - "default": "latest", - "description": "The image tag.", - "type": "string" - } - }, - "required": ["name"], - "type": "object" - }, "tags": { "default": [], "description": "Tags to set on the new sandbox, if any. Will not inherit tags from the source sandbox.", @@ -173,6 +143,7 @@ "type": "string" } }, + "required": ["forkOf"], "title": "TemplateCreateRequest", "type": "object" }, diff --git a/src/PintClient/fs.ts b/src/PintClient/fs.ts index e1392bc..fabea8a 100644 --- a/src/PintClient/fs.ts +++ b/src/PintClient/fs.ts @@ -3,6 +3,9 @@ import { IAgentClientFS, PickRawFsResult, } from "../agent-client-interface"; +import { fs } from "../pitcher-protocol"; +import { Disposable } from "../utils/disposable"; +import { parseStreamEvent } from "./utils"; import { createFile, readFile, @@ -11,6 +14,7 @@ import { createDirectory, deleteDirectory, getFileStat, + createWatcher, } from "../api-clients/pint"; export class PintFsClient implements IAgentClientFS { constructor(private apiClient: Client) {} @@ -325,12 +329,57 @@ export class PintFsClient implements IAgentClientFS { readonly recursive?: boolean; readonly excludes?: readonly string[]; }, - onEvent: (watchEvent: any) => void + onEvent: (watchEvent: fs.FSWatchEvent) => void ): Promise< | (PickRawFsResult<"fs/watch"> & { type: "error" }) | { type: "success"; dispose(): void } > { - throw new Error("Not implemented"); + try { + const abortController = new AbortController(); + + const response = createWatcher({ + client: this.apiClient, + path: { + path: path, + }, + query: { + recursive: options.recursive, + ignorePatterns: options.excludes ? [...options.excludes] : undefined, + }, + signal: abortController.signal, + }); + + // Start listening to the stream in the background + response.then(async ({ stream }) => { + try { + for await (const evt of stream) { + try { + const watchEvent = parseStreamEvent(evt); + onEvent(watchEvent); + } catch (error) { + console.warn('Failed to parse filesystem watch event:', error); + } + } + } catch (error) { + console.error('Filesystem watch stream error:', error); + } + }).catch((error) => { + console.error('Failed to start filesystem watcher:', error); + }); + + return { + type: "success", + dispose(): void { + abortController.abort(); + }, + }; + } catch (error) { + return { + type: "error", + error: error instanceof Error ? error.message : "Unknown error", + errno: null, + }; + } } async download(path?: string): Promise<{ downloadUrl: string }> { diff --git a/src/api-clients/client/types.gen.ts b/src/api-clients/client/types.gen.ts index 22996d4..4f1cad1 100644 --- a/src/api-clients/client/types.gen.ts +++ b/src/api-clients/client/types.gen.ts @@ -80,32 +80,7 @@ export type TemplateCreateRequest = { /** * Short ID of the sandbox to fork. */ - forkOf?: string; - /** - * Container image to use as template - */ - image?: { - /** - * The architecture of the image. Required for multi-platform images - */ - architecture?: string; - /** - * The image name (for example 'nginx'). - */ - name: string; - /** - * The container registry where the image is stored. - */ - registry?: string; - /** - * The repository or namespace where the image is stored. - */ - repository?: string; - /** - * The image tag. - */ - tag?: string; - }; + forkOf: string; /** * Tags to set on the new sandbox, if any. Will not inherit tags from the source sandbox. */ diff --git a/src/api-clients/pint/sdk.gen.ts b/src/api-clients/pint/sdk.gen.ts index 89545b6..a3b14e9 100644 --- a/src/api-clients/pint/sdk.gen.ts +++ b/src/api-clients/pint/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { ConnectToExecWebSocketData, ConnectToExecWebSocketErrors, ConnectToExecWebSocketResponses, CreateDirectoryData, CreateDirectoryErrors, CreateDirectoryResponses, CreateExecData, CreateExecErrors, CreateExecResponses, CreateFileData, CreateFileErrors, CreateFileResponses, DeleteDirectoryData, DeleteDirectoryErrors, DeleteDirectoryResponses, DeleteExecData, DeleteExecErrors, DeleteExecResponses, DeleteFileData, DeleteFileErrors, DeleteFileResponses, ExecExecStdinData, ExecExecStdinErrors, ExecExecStdinResponses, ExecuteTaskActionData, ExecuteTaskActionErrors, ExecuteTaskActionResponses, GetExecData, GetExecErrors, GetExecOutputData, GetExecOutputErrors, GetExecOutputResponses, GetExecResponses, GetFileStatData, GetFileStatErrors, GetFileStatResponses, GetTaskData, GetTaskErrors, GetTaskResponses, ListDirectoryData, ListDirectoryErrors, ListDirectoryResponses, ListExecsData, ListExecsErrors, ListExecsResponses, ListPortsData, ListPortsErrors, ListPortsResponses, ListSetupTasksData, ListSetupTasksErrors, ListSetupTasksResponses, ListTasksData, ListTasksErrors, ListTasksResponses, PerformFileActionData, PerformFileActionErrors, PerformFileActionResponses, ReadFileData, ReadFileErrors, ReadFileResponses, StreamExecsListData, StreamExecsListErrors, StreamExecsListResponses, StreamPortsListData, StreamPortsListErrors, StreamPortsListResponses, UpdateExecData, UpdateExecErrors, UpdateExecResponses } from './types.gen'; +import type { ConnectToExecWebSocketData, ConnectToExecWebSocketErrors, ConnectToExecWebSocketResponses, CreateDirectoryData, CreateDirectoryErrors, CreateDirectoryResponses, CreateExecData, CreateExecErrors, CreateExecResponses, CreateFileData, CreateFileErrors, CreateFileResponses, CreateWatcherData, CreateWatcherErrors, CreateWatcherResponses, DeleteDirectoryData, DeleteDirectoryErrors, DeleteDirectoryResponses, DeleteExecData, DeleteExecErrors, DeleteExecResponses, DeleteFileData, DeleteFileErrors, DeleteFileResponses, ExecExecStdinData, ExecExecStdinErrors, ExecExecStdinResponses, ExecuteTaskActionData, ExecuteTaskActionErrors, ExecuteTaskActionResponses, GetExecData, GetExecErrors, GetExecOutputData, GetExecOutputErrors, GetExecOutputResponses, GetExecResponses, GetFileStatData, GetFileStatErrors, GetFileStatResponses, GetTaskData, GetTaskErrors, GetTaskResponses, ListDirectoryData, ListDirectoryErrors, ListDirectoryResponses, ListExecsData, ListExecsErrors, ListExecsResponses, ListPortsData, ListPortsErrors, ListPortsResponses, ListSetupTasksData, ListSetupTasksErrors, ListSetupTasksResponses, ListTasksData, ListTasksErrors, ListTasksResponses, PerformFileActionData, PerformFileActionErrors, PerformFileActionResponses, ReadFileData, ReadFileErrors, ReadFileResponses, StreamExecsListData, StreamExecsListErrors, StreamExecsListResponses, StreamPortsListData, StreamPortsListErrors, StreamPortsListResponses, UpdateExecData, UpdateExecErrors, UpdateExecResponses } from './types.gen'; export type Options = Options2 & { /** @@ -442,3 +442,20 @@ export const streamPortsList = (options?: ...options }); }; + +/** + * Watch directory changes using Server-Sent Events (SSE) + * Watch a directory for file system changes and stream events via SSE. + */ +export const createWatcher = (options: Options) => { + return (options.client ?? client).sse.get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/stream/directories/watcher/{path}', + ...options + }); +}; diff --git a/src/api-clients/pint/types.gen.ts b/src/api-clients/pint/types.gen.ts index cc3faa0..8a0363c 100644 --- a/src/api-clients/pint/types.gen.ts +++ b/src/api-clients/pint/types.gen.ts @@ -123,6 +123,10 @@ export type ExecItem = { * Whether the exec is interactive */ interactive: boolean; + /** + * Whether the exec is using a pty + */ + pty: boolean; /** * Exit code of the process (only present when process has exited) */ @@ -153,6 +157,10 @@ export type CreateExecRequest = { * Whether to start interactive shell session or not (defaults to false) */ interactive?: boolean; + /** + * Whether to start pty shell session or not (defaults to false) + */ + pty?: boolean; }; export type UpdateExecRequest = { @@ -169,6 +177,29 @@ export type ExecDeleteResponse = { message: string; }; +export type ExecStdout = { + /** + * Type of the exec output + */ + type: 'stdout' | 'stderr'; + /** + * Data associated with the exec output + */ + output: string; + /** + * Sequence number of the output message + */ + sequence: number; + /** + * Timestamp of when the output was generated + */ + timestamp?: string; + /** + * Exit code of the process (only present when process has exited) + */ + exitCode?: number; +}; + export type ExecStdin = { /** * Type of the exec input @@ -298,29 +329,6 @@ export type PortsListResponse = { ports: Array; }; -export type ExecStdout = { - /** - * Type of the exec output - */ - type: 'stdout' | 'stderr'; - /** - * Data associated with the exec output - */ - output: string; - /** - * Sequence number of the output message - */ - sequence: number; - /** - * Timestamp of when the output was generated - */ - timestamp?: string; - /** - * Exit code of the process (only present when process has exited) - */ - exitCode?: number; -}; - export type Task = TaskItem; export type DeleteFileData = { @@ -1270,3 +1278,54 @@ export type StreamPortsListResponses = { }; export type StreamPortsListResponse = StreamPortsListResponses[keyof StreamPortsListResponses]; + +export type CreateWatcherData = { + body?: never; + path: { + /** + * Directory path to watch + */ + path: string; + }; + query?: { + /** + * Whether to watch directories recursively + */ + recursive?: boolean; + /** + * Glob patterns to ignore certain files or directories (can be specified multiple times) + */ + ignorePatterns?: Array; + }; + url: '/api/v1/stream/directories/watcher/{path}'; +}; + +export type CreateWatcherErrors = { + /** + * Bad Request - Path is required or invalid path + */ + 400: _Error; + /** + * Unauthorized + */ + 401: _Error; + /** + * Internal Server Error - Failed to create file + */ + 500: _Error; + /** + * Unexpected Error + */ + default: _Error; +}; + +export type CreateWatcherError = CreateWatcherErrors[keyof CreateWatcherErrors]; + +export type CreateWatcherResponses = { + /** + * Server-Sent Events stream of directory files updates + */ + 200: string; +}; + +export type CreateWatcherResponse = CreateWatcherResponses[keyof CreateWatcherResponses]; From 0b4e11099682f0f804cc9bd7ec10ab17e685c5f7 Mon Sep 17 00:00:00 2001 From: mohaimen Date: Sun, 14 Dec 2025 21:23:52 +0100 Subject: [PATCH 15/46] update watcher api and create unit tests --- src/PintClient/execs.ts | 2 +- src/PintClient/fs.ts | 10 +- tests/pint-fs-watcher.test.ts | 268 ++++++++++++++++++++++++++++++++++ 3 files changed, 273 insertions(+), 7 deletions(-) create mode 100644 tests/pint-fs-watcher.test.ts diff --git a/src/PintClient/execs.ts b/src/PintClient/execs.ts index a3d7490..3f8e5b7 100644 --- a/src/PintClient/execs.ts +++ b/src/PintClient/execs.ts @@ -32,6 +32,7 @@ import { IDisposable } from "@xterm/headless"; export class PintShellsClient implements IAgentClientShells { private execs: ExecItem[] = []; + constructor(private apiClient: Client, private sandboxId: string) {} private subscribeAndEvaluateExecsUpdates( execId: string, compare: ( @@ -73,7 +74,6 @@ export class PintShellsClient implements IAgentClientShells { abortController.abort(); }); } - constructor(private apiClient: Client, private sandboxId: string) {} private convertExecToShellDTO(exec: ExecItem) { return { isSystemShell: true, diff --git a/src/PintClient/fs.ts b/src/PintClient/fs.ts index fabea8a..99076ed 100644 --- a/src/PintClient/fs.ts +++ b/src/PintClient/fs.ts @@ -337,7 +337,7 @@ export class PintFsClient implements IAgentClientFS { try { const abortController = new AbortController(); - const response = createWatcher({ + const response = await createWatcher({ client: this.apiClient, path: { path: path, @@ -350,9 +350,9 @@ export class PintFsClient implements IAgentClientFS { }); // Start listening to the stream in the background - response.then(async ({ stream }) => { + (async () => { try { - for await (const evt of stream) { + for await (const evt of response.stream) { try { const watchEvent = parseStreamEvent(evt); onEvent(watchEvent); @@ -363,9 +363,7 @@ export class PintFsClient implements IAgentClientFS { } catch (error) { console.error('Filesystem watch stream error:', error); } - }).catch((error) => { - console.error('Failed to start filesystem watcher:', error); - }); + })(); return { type: "success", diff --git a/tests/pint-fs-watcher.test.ts b/tests/pint-fs-watcher.test.ts new file mode 100644 index 0000000..5acf273 --- /dev/null +++ b/tests/pint-fs-watcher.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { PintFsClient } from '../src/PintClient/fs' +import { Client } from '../src/api-clients/pint/client' +import * as pintApi from '../src/api-clients/pint' + +// Mock the pint API functions +vi.mock('../src/api-clients/pint', () => ({ + createWatcher: vi.fn(), + createFile: vi.fn(), + readFile: vi.fn(), + listDirectory: vi.fn(), + deleteDirectory: vi.fn(), + createDirectory: vi.fn(), + getFileStat: vi.fn(), + performFileAction: vi.fn(), +})) + +describe('PintFsClient filesystem watcher', () => { + let fsClient: PintFsClient + let mockApiClient: Client + let mockCreateWatcher: any + + beforeEach(() => { + // Create a mock API client + mockApiClient = {} as Client + + // Create instance of PintFsClient + fsClient = new PintFsClient(mockApiClient) + + // Get reference to mocked functions + mockCreateWatcher = vi.mocked(pintApi.createWatcher) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('should successfully start watching a directory', async () => { + const path = '/test/directory' + const options = { recursive: true, excludes: ['*.log', 'node_modules/*'] } + const onEvent = vi.fn() + + // Mock the stream generator + async function* mockStream() { + yield 'data: {"paths": ["/test/directory/file1.txt"], "type": "add"}' + yield 'data: {"paths": ["/test/directory/file2.txt"], "type": "change"}' + } + + // Mock createWatcher to return a stream + mockCreateWatcher.mockResolvedValue({ + stream: mockStream() + }) + + // Call watch method + const result = await fsClient.watch(path, options, onEvent) + + // Verify the result + expect(result.type).toBe('success') + expect(result).toHaveProperty('dispose') + + // Verify createWatcher was called with correct parameters + expect(mockCreateWatcher).toHaveBeenCalledWith({ + client: mockApiClient, + path: { path }, + query: { + recursive: true, + ignorePatterns: ['*.log', 'node_modules/*'] + }, + signal: expect.any(AbortSignal) + }) + + // Wait a bit for the async stream processing + await new Promise(resolve => setTimeout(resolve, 100)) + + // Verify events were parsed and fired + expect(onEvent).toHaveBeenCalledTimes(2) + expect(onEvent).toHaveBeenCalledWith({ + paths: ['/test/directory/file1.txt'], + type: 'add' + }) + expect(onEvent).toHaveBeenCalledWith({ + paths: ['/test/directory/file2.txt'], + type: 'change' + }) + }) + + it('should handle watcher with minimal options', async () => { + const path = '/simple/path' + const options = {} + const onEvent = vi.fn() + + // Mock empty stream + async function* mockStream() { + // Empty stream + } + + mockCreateWatcher.mockResolvedValue({ + stream: mockStream() + }) + + const result = await fsClient.watch(path, options, onEvent) + + expect(result.type).toBe('success') + expect(mockCreateWatcher).toHaveBeenCalledWith({ + client: mockApiClient, + path: { path }, + query: { + recursive: undefined, + ignorePatterns: undefined + }, + signal: expect.any(AbortSignal) + }) + }) + + it('should handle filesystem events correctly', async () => { + const path = '/test/path' + const options = { recursive: false } + const onEvent = vi.fn() + + // Mock stream with different event types + async function* mockStream() { + yield 'data: {"paths": ["/test/path/new-file.txt"], "type": "add"}' + yield 'data: {"paths": ["/test/path/modified-file.txt"], "type": "change"}' + yield 'data: {"paths": ["/test/path/deleted-file.txt"], "type": "remove"}' + } + + mockCreateWatcher.mockResolvedValue({ + stream: mockStream() + }) + + const result = await fsClient.watch(path, options, onEvent) + expect(result.type).toBe('success') + + // Wait for stream processing + await new Promise(resolve => setTimeout(resolve, 100)) + + // Verify all event types were handled + expect(onEvent).toHaveBeenCalledTimes(3) + expect(onEvent).toHaveBeenNthCalledWith(1, { + paths: ['/test/path/new-file.txt'], + type: 'add' + }) + expect(onEvent).toHaveBeenNthCalledWith(2, { + paths: ['/test/path/modified-file.txt'], + type: 'change' + }) + expect(onEvent).toHaveBeenNthCalledWith(3, { + paths: ['/test/path/deleted-file.txt'], + type: 'remove' + }) + }) + + it('should handle malformed stream events gracefully', async () => { + const path = '/test/path' + const options = {} + const onEvent = vi.fn() + + // Mock stream with malformed data + async function* mockStream() { + yield 'data: {"paths": ["/test/path/good-file.txt"], "type": "add"}' + yield 'data: invalid json' + yield 'data: {"paths": ["/test/path/another-good-file.txt"], "type": "change"}' + } + + mockCreateWatcher.mockResolvedValue({ + stream: mockStream() + }) + + // Spy on console.warn to verify error handling + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const result = await fsClient.watch(path, options, onEvent) + expect(result.type).toBe('success') + + // Wait for stream processing + await new Promise(resolve => setTimeout(resolve, 100)) + + // Verify only valid events were processed + expect(onEvent).toHaveBeenCalledTimes(2) + expect(onEvent).toHaveBeenNthCalledWith(1, { + paths: ['/test/path/good-file.txt'], + type: 'add' + }) + expect(onEvent).toHaveBeenNthCalledWith(2, { + paths: ['/test/path/another-good-file.txt'], + type: 'change' + }) + + // Verify warning was logged for malformed data + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to parse filesystem watch event:', + expect.any(Error) + ) + + consoleSpy.mockRestore() + }) + + it('should allow disposal of watcher', async () => { + const path = '/test/path' + const options = {} + const onEvent = vi.fn() + + // Mock stream that would run indefinitely + async function* mockStream() { + let count = 0 + while (true) { + yield `data: {"paths": ["/test/path/file${count}.txt"], "type": "add"}` + count++ + // Add a small delay to prevent tight loop + await new Promise(resolve => setTimeout(resolve, 10)) + } + } + + mockCreateWatcher.mockResolvedValue({ + stream: mockStream() + }) + + const result = await fsClient.watch(path, options, onEvent) + expect(result.type).toBe('success') + + if (result.type === 'success') { + expect(typeof result.dispose).toBe('function') + + // Let it run for a bit + await new Promise(resolve => setTimeout(resolve, 50)) + + // Dispose the watcher + result.dispose() + + // The dispose function should abort the controller + expect(() => result.dispose()).not.toThrow() + } + }) + + it('should handle createWatcher promise rejection', async () => { + const path = '/test/path' + const options = {} + const onEvent = vi.fn() + + // Mock createWatcher to reject + mockCreateWatcher.mockRejectedValue(new Error('Network error')) + + const result = await fsClient.watch(path, options, onEvent) + + expect(result.type).toBe('error') + if (result.type === 'error') { + expect(result.error).toBe('Network error') + expect(result.errno).toBe(null) + } + }) + + it('should handle unknown errors', async () => { + const path = '/test/path' + const options = {} + const onEvent = vi.fn() + + // Mock createWatcher to reject with non-Error + mockCreateWatcher.mockRejectedValue('String error') + + const result = await fsClient.watch(path, options, onEvent) + + expect(result.type).toBe('error') + if (result.type === 'error') { + expect(result.error).toBe('Unknown error') + expect(result.errno).toBe(null) + } + }) +}) \ No newline at end of file From 84090cfae20e95880b178bc3b862623ce66d788a Mon Sep 17 00:00:00 2001 From: Christian Alfoni Date: Fri, 16 Jan 2026 10:00:11 +0100 Subject: [PATCH 16/46] fix execs --- src/PintClient/execs.ts | 34 ++++++++++++++++------------------ src/SandboxClient/commands.ts | 5 ++++- src/SandboxClient/index.ts | 12 ++++++++---- 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/src/PintClient/execs.ts b/src/PintClient/execs.ts index 3f8e5b7..3137b6e 100644 --- a/src/PintClient/execs.ts +++ b/src/PintClient/execs.ts @@ -1,5 +1,4 @@ import { Client } from "../api-clients/pint/client"; -import { Emitter, EmitterSubscription } from "../utils/event"; import { Disposable } from "../utils/disposable"; import { parseStreamEvent } from "./utils"; import { @@ -37,7 +36,7 @@ export class PintShellsClient implements IAgentClientShells { execId: string, compare: ( nextExec: ExecItem, - prevExec: ExecItem | undefined, + prevExec: ExecItem, prevExecs: ExecItem[] ) => void ) { @@ -53,20 +52,23 @@ export class PintShellsClient implements IAgentClientShells { for await (const evt of stream) { const execListResponse = parseStreamEvent(evt); const execs = execListResponse.execs; + const newExec = execs.find((exec) => exec.id === execId); + const currentExec = this.execs.find((exec) => exec.id === execId); - execs.forEach((exec) => { - if (exec.id !== execId) { - return; - } + // Removed + if (!newExec && currentExec) { + this.execs.splice(this.execs.indexOf(currentExec), 1); + } + // Added + else if (newExec && !currentExec) { + this.execs.push(newExec); + } + // Updated + else if (newExec && currentExec) { + compare(newExec, currentExec, this.execs); - const prevExec = this.execs.find( - (execItem) => execItem.id === exec.id - ); - - compare(exec, prevExec, this.execs); - }); - - this.execs = execs; + this.execs[this.execs.indexOf(currentExec)] = newExec; + } } }); @@ -128,10 +130,6 @@ export class PintShellsClient implements IAgentClientShells { listener: (event: SubscribeShellEvent) => void ): IDisposable { return this.subscribeAndEvaluateExecsUpdates(shellId, (exec, prevExec) => { - if (!prevExec) { - return; - } - if (prevExec.status === "RUNNING" && exec.status === "EXITED") { listener({ type: "exit", diff --git a/src/SandboxClient/commands.ts b/src/SandboxClient/commands.ts index 722c59f..37525a2 100644 --- a/src/SandboxClient/commands.ts +++ b/src/SandboxClient/commands.ts @@ -389,12 +389,13 @@ export class Command { this.barrier.open(); } else { const barrier = new Barrier(); - this.agentClient.shells.subscribeOutput( + const disposer = this.agentClient.shells.subscribeOutput( this.shell.shellId, DEFAULT_SHELL_SIZE, (event) => { this.output.push(event.out); if (event.exitCode !== undefined) { + disposer.dispose(); barrier.open(); } } @@ -519,6 +520,8 @@ export class Command { "" ); + this.disposable.dispose(); + if (this.status === "FINISHED") { return cleaned; } diff --git a/src/SandboxClient/index.ts b/src/SandboxClient/index.ts index 4a3159a..3c7637f 100644 --- a/src/SandboxClient/index.ts +++ b/src/SandboxClient/index.ts @@ -45,10 +45,14 @@ export class SandboxClient { if (session.isPint) { const pintClient = await PintClient.create(session); const progress = await pintClient.setup.getProgress(); - return new SandboxClient(pintClient, { - hostToken: session.hostToken, - tracer, - }, progress); + return new SandboxClient( + pintClient, + { + hostToken: session.hostToken, + tracer, + }, + progress + ); } const { client: agentClient, joinResult } = await AgentClient.create({ From 72965c40a7c74af8000fd82338dbb8be787f8d26 Mon Sep 17 00:00:00 2001 From: Joji Augustine Date: Tue, 27 Jan 2026 21:43:13 +0100 Subject: [PATCH 17/46] increase beta build wait for port timeout to 30s from 10s --- src/bin/commands/build.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bin/commands/build.ts b/src/bin/commands/build.ts index 8f69b92..8d3967b 100644 --- a/src/bin/commands/build.ts +++ b/src/bin/commands/build.ts @@ -747,12 +747,12 @@ export async function betaCodeSandboxBuild(argv: yargs.ArgumentsCamelCase { if (!client) throw new Error('Failed to connect to sandbox to wait for ports'); const portInfo = await client.ports.waitForPort(port, { - timeoutMs: 10_000, + timeoutMs: 30_000, }); }) ); } else { - templateBuildSpinner.text = `Preparing template snapshot: No ports specified, waiting 10 seconds for tasks to run...`; + templateBuildSpinner.text = `Preparing template snapshot: No ports specified, waiting 0 seconds for tasks to run...`; await sleep(10000); } From 6944b3631ee00bb72031398d5e93ea24e6107c10 Mon Sep 17 00:00:00 2001 From: Joji Augustine Date: Thu, 29 Jan 2026 20:26:29 +0100 Subject: [PATCH 18/46] run only e2e tests --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7ee1a76..6a8ca72 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "build-openapi-pint": "node_modules/.bin/openapi-ts -i ./pint-openapi-bundled.json -o src/api-clients/pint -c @hey-api/client-fetch", "clean": "rimraf ./dist", "test": "vitest", - "test:e2e": "vitest run", + "test:e2e": "vitest run tests/e2e", "typecheck": "tsc --noEmit", "format": "prettier '**/*.{md,js,jsx,json,ts,tsx}' --write", "postbuild": "rimraf {lib,es}/**/__tests__ {lib,es}/**/*.{spec,test}.{js,d.ts,js.map}", From f2c6b096b8a78c931b965c97d638f306508a81b4 Mon Sep 17 00:00:00 2001 From: Joji Augustine Date: Thu, 29 Jan 2026 20:34:17 +0100 Subject: [PATCH 19/46] seperate config for e2e tests --- package.json | 2 +- vitest.e2e.config.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 vitest.e2e.config.ts diff --git a/package.json b/package.json index 6a8ca72..d6fc635 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "build-openapi-pint": "node_modules/.bin/openapi-ts -i ./pint-openapi-bundled.json -o src/api-clients/pint -c @hey-api/client-fetch", "clean": "rimraf ./dist", "test": "vitest", - "test:e2e": "vitest run tests/e2e", + "test:e2e": "vitest run --config vitest.e2e.config.ts", "typecheck": "tsc --noEmit", "format": "prettier '**/*.{md,js,jsx,json,ts,tsx}' --write", "postbuild": "rimraf {lib,es}/**/__tests__ {lib,es}/**/*.{spec,test}.{js,d.ts,js.map}", diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 0000000..b2aba9a --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/e2e/**/*.test.ts'] + }, + define: { + CSB_SDK_VERSION: JSON.stringify('2.1.0-rc.4'), + }, +}) \ No newline at end of file From 43c59c4cf82ee0be28c47e543f48638104763f5d Mon Sep 17 00:00:00 2001 From: Christian Alfoni Date: Thu, 5 Feb 2026 10:35:18 +0100 Subject: [PATCH 20/46] clean up tests, get ready to test new infra --- src/API.ts | 7 +- src/PintClient/execs.ts | 6 + src/PintClient/index.ts | 13 +- src/SandboxClient/commands.ts | 37 ++- src/bin/commands/build.ts | 95 ++++--- tests/e2e/helpers.ts | 35 ++- tests/e2e/sandbox-apis.test.ts | 38 +-- tests/e2e/sandbox-commands.test.ts | 134 +++++----- tests/e2e/sandbox-filesystem.test.ts | 334 ++++++++++++++----------- tests/e2e/sandbox-hosts.test.ts | 71 +++--- tests/e2e/sandbox-interpreters.test.ts | 76 +++--- tests/e2e/sandbox-ports.test.ts | 68 +++-- tests/e2e/sandbox-setup.test.ts | 61 +++-- tests/e2e/sandbox-tasks.test.ts | 64 ++--- tests/e2e/sandbox-terminals.test.ts | 6 +- tests/pint-shells-client.test.ts | 3 +- 16 files changed, 599 insertions(+), 449 deletions(-) diff --git a/src/API.ts b/src/API.ts index bd04ba8..9539c48 100644 --- a/src/API.ts +++ b/src/API.ts @@ -57,7 +57,6 @@ import type { } from "./api-clients/client"; import { PitcherManagerResponse } from "./types"; - export interface APIOptions { apiKey: string; config?: Config; @@ -336,8 +335,8 @@ export class API { ); return { - bootupType: - handledResponse.bootup_type as PitcherManagerResponse["bootupType"], + bootupType: + handledResponse.bootup_type as PitcherManagerResponse["bootupType"], cluster: handledResponse.cluster, pitcherURL: handledResponse.pitcher_url, workspacePath: handledResponse.workspace_path, @@ -347,7 +346,7 @@ export class API { latestPitcherVersion: handledResponse.latest_pitcher_version, pitcherToken: handledResponse.pitcher_token, pintToken: handledResponse.pint_token, - pintURL: handledResponse.pint_url, + pintURL: handledResponse.pint_url, vmAgentType: handledResponse.vm_agent_type, }; } diff --git a/src/PintClient/execs.ts b/src/PintClient/execs.ts index 3137b6e..f72aff4 100644 --- a/src/PintClient/execs.ts +++ b/src/PintClient/execs.ts @@ -42,6 +42,8 @@ export class PintShellsClient implements IAgentClientShells { ) { const abortController = new AbortController(); + console.log("Subscribing to execs!"); + streamExecsList({ client: this.apiClient, signal: abortController.signal, @@ -49,7 +51,9 @@ export class PintShellsClient implements IAgentClientShells { headers: { Accept: "text/event-stream" }, }, }).then(async ({ stream }) => { + console.log("LIST STREAM READY"); for await (const evt of stream) { + console.log("Got list event"); const execListResponse = parseStreamEvent(evt); const execs = execListResponse.execs; const newExec = execs.find((exec) => exec.id === execId); @@ -155,7 +159,9 @@ export class PintShellsClient implements IAgentClientShells { Accept: "text/event-stream", }, }).then(async ({ stream }) => { + console.log("OUTPUT STREAM READY"); for await (const evt of stream) { + console.log("Got output event"); const data = parseStreamEvent<{ type: "stdout" | "stderr"; output: ""; diff --git a/src/PintClient/index.ts b/src/PintClient/index.ts index 3431c93..6b6cc91 100644 --- a/src/PintClient/index.ts +++ b/src/PintClient/index.ts @@ -3,9 +3,9 @@ import { Emitter, EmitterSubscription } from "../utils/event"; import { SandboxSession } from "../types"; import { Disposable } from "../utils/disposable"; import { Client, createClient, createConfig } from "../api-clients/pint/client"; -import { PintClientTasks, PintClientSetup, PintClientSystem} from "./tasks"; -import {PintFsClient} from "./fs"; -import {PintShellsClient} from "./execs"; +import { PintClientTasks, PintClientSetup, PintClientSystem } from "./tasks"; +import { PintFsClient } from "./fs"; +import { PintShellsClient } from "./execs"; import { parseStreamEvent } from "./utils"; import { IAgentClient, @@ -69,7 +69,6 @@ class PintPortsClient implements IAgentClientPorts { } } - export class PintClient implements IAgentClient { static async create(session: SandboxSession) { return new PintClient(session); @@ -101,9 +100,9 @@ export class PintClient implements IAgentClient { const apiClient = createClient( createConfig({ - baseUrl: session.pitcherURL, + baseUrl: session.pintURL, headers: { - Authorization: `Bearer ${session.pitcherToken}`, + Authorization: `Bearer ${session.pintToken}`, }, }) ); @@ -120,4 +119,4 @@ export class PintClient implements IAgentClient { async reconnect(): Promise {} async disconnect(): Promise {} dispose(): void {} -} \ No newline at end of file +} diff --git a/src/SandboxClient/commands.ts b/src/SandboxClient/commands.ts index 37525a2..9fdd8a1 100644 --- a/src/SandboxClient/commands.ts +++ b/src/SandboxClient/commands.ts @@ -382,30 +382,23 @@ export class Command { this.tracer = tracer; if (shell.status === "RUNNING") { + console.log(this.shell.shellId, "Listening for output"); this.disposable.addDisposable( - agentClient.shells.subscribe(shell.shellId, async (event) => { - if (event.type === "terminate") { - this.status = "KILLED"; - this.barrier.open(); - } else { - const barrier = new Barrier(); - const disposer = this.agentClient.shells.subscribeOutput( - this.shell.shellId, - DEFAULT_SHELL_SIZE, - (event) => { - this.output.push(event.out); - if (event.exitCode !== undefined) { - disposer.dispose(); - barrier.open(); - } - } - ); - await barrier.wait(); - this.exitCode = event.exitCode; - this.status = event.exitCode === 0 ? "FINISHED" : "ERROR"; - this.barrier.open(); + this.agentClient.shells.subscribeOutput( + this.shell.shellId, + DEFAULT_SHELL_SIZE, + (event) => { + this.output.push(event.out); + if (event.exitCode === 0) { + this.exitCode = event.exitCode; + this.status = event.exitCode === 0 ? "FINISHED" : "ERROR"; + this.barrier.open(); + } else if (typeof event.exitCode === "number") { + this.status = "KILLED"; + this.barrier.open(); + } } - }) + ) ); } } diff --git a/src/bin/commands/build.ts b/src/bin/commands/build.ts index 8f69b92..1501ede 100644 --- a/src/bin/commands/build.ts +++ b/src/bin/commands/build.ts @@ -13,11 +13,20 @@ import { } from "@codesandbox/sdk"; import { VmUpdateSpecsRequest } from "../../api-clients/client"; import { getDefaultTemplateId, retryWithDelay } from "../../utils/api"; -import { getInferredApiKey, getInferredRegistryUrl, isBetaAllowed, isLocalEnvironment } from "../../utils/constants"; +import { + getInferredApiKey, + getInferredRegistryUrl, + isBetaAllowed, + isLocalEnvironment, +} from "../../utils/constants"; import { hashDirectory as getFilePaths } from "../utils/files"; import { mkdir, writeFile } from "fs/promises"; import { sleep } from "../../utils/sleep"; -import { buildDockerImage, prepareDockerBuild, pushDockerImage } from "../utils/docker"; +import { + buildDockerImage, + prepareDockerBuild, + pushDockerImage, +} from "../utils/docker"; import { randomUUID } from "crypto"; export type BuildCommandArgs = { @@ -182,7 +191,6 @@ export const buildCommand: yargs.CommandModule< }), handler: async (argv) => { - // Beta build process using Docker // This uses the new architecture using bartender and gvisor if (argv.beta && isBetaAllowed()) { @@ -253,7 +261,8 @@ export const buildCommand: yargs.CommandModule< spinner.start( updateSpinnerMessage( index, - `Running setup ${steps.indexOf(step) + 1} / ${steps.length + `Running setup ${steps.indexOf(step) + 1} / ${ + steps.length } - ${step.name}...` ) ); @@ -466,9 +475,9 @@ export const buildCommand: yargs.CommandModule< argv.ci ? String(error) : "Failed, please manually verify at https://codesandbox.io/s/" + - id + - " - " + - String(error) + id + + " - " + + String(error) ) ); @@ -628,7 +637,9 @@ function createAlias(directory: string, alias: string) { * Build a CodeSandbox Template using Docker for use in gvisor-based sandboxes. * @param argv arguments to csb build command */ -export async function betaCodeSandboxBuild(argv: yargs.ArgumentsCamelCase): Promise { +export async function betaCodeSandboxBuild( + argv: yargs.ArgumentsCamelCase +): Promise { let dockerFileCleanupFn: (() => Promise) | undefined; let client: SandboxClient | undefined; @@ -662,19 +673,23 @@ export async function betaCodeSandboxBuild(argv: yargs.ArgumentsCamelCase { - dockerBuildPrepareSpinner.text = `Preparing build environment: (${output})`; - }); + const result = await prepareDockerBuild( + resolvedDirectory, + (output: string) => { + dockerBuildPrepareSpinner.text = `Preparing build environment: (${output})`; + } + ); dockerFileCleanupFn = result.cleanupFn; dockerfilePath = result.dockerfilePath; dockerBuildPrepareSpinner.succeed("Build environment ready."); } catch (error) { - dockerBuildPrepareSpinner.fail(`Failed to prepare build environment: ${(error as Error).message}`); + dockerBuildPrepareSpinner.fail( + `Failed to prepare build environment: ${(error as Error).message}` + ); throw error; } - // Docker Build const dockerBuildSpinner = ora({ stream: process.stdout }); dockerBuildSpinner.start("Building template docker image..."); @@ -690,7 +705,9 @@ export async function betaCodeSandboxBuild(argv: yargs.ArgumentsCamelCase { - const cleanOutput = stripAnsiCodes(output); - imagePushSpinner.text = `Pushing template Docker image to CodeSandbox: (${cleanOutput})`; - }, - ); + await pushDockerImage(fullImageName, (output: string) => { + const cleanOutput = stripAnsiCodes(output); + imagePushSpinner.text = `Pushing template Docker image to CodeSandbox: (${cleanOutput})`; + }); } catch (error) { - imagePushSpinner.fail(`Failed to push template Docker image: ${(error as Error).message}`); + imagePushSpinner.fail( + `Failed to push template Docker image: ${(error as Error).message}` + ); throw error; } imagePushSpinner.succeed("Template Docker image pushed to CodeSandbox."); - // Create Template with Docker Image const templateData = await api.createTemplate({ forkOf: argv.fromSandbox || getDefaultTemplateId(api.getClient()), @@ -725,7 +740,7 @@ export async function betaCodeSandboxBuild(argv: yargs.ArgumentsCamelCase 0) { - templateBuildSpinner.text = `Preparing template snapshot: Waiting for ports ${argv.ports.join(', ')} to be ready...`; + templateBuildSpinner.text = `Preparing template snapshot: Waiting for ports ${argv.ports.join( + ", " + )} to be ready...`; await Promise.all( argv.ports.map(async (port) => { - if (!client) throw new Error('Failed to connect to sandbox to wait for ports'); + if (!client) + throw new Error("Failed to connect to sandbox to wait for ports"); const portInfo = await client.ports.waitForPort(port, { timeoutMs: 10_000, }); @@ -756,15 +776,20 @@ export async function betaCodeSandboxBuild(argv: yargs.ArgumentsCamelCase { +describe("Sandbox APIs", () => { let sdk: CodeSandbox; let sandboxId: string | undefined; @@ -10,9 +10,7 @@ describe('Sandbox APIs', () => { sdk = initializeSDK(); // Create a sandbox for testing - const sandbox = await sdk.sandboxes.create({ - id: TEST_TEMPLATE_ID, - }); + const sandbox = await createSandbox(sdk); sandboxId = sandbox.id; }); @@ -23,20 +21,24 @@ describe('Sandbox APIs', () => { await sdk.sandboxes.shutdown(sandboxId); await sdk.sandboxes.delete(sandboxId); } catch (error) { - console.error('Failed to cleanup test sandbox:', sandboxId, error); + console.error("Failed to cleanup test sandbox:", sandboxId, error); // Try to force delete even if shutdown fails try { await sdk.sandboxes.delete(sandboxId); } catch (deleteError) { - console.error('Failed to force delete sandbox:', sandboxId, deleteError); + console.error( + "Failed to force delete sandbox:", + sandboxId, + deleteError + ); } } } }); - it('should find sandbox in list', async () => { + it("should find sandbox in list", async () => { expect(sandboxId).toBeDefined(); - if (!sandboxId) throw new Error('Sandbox not created'); + if (!sandboxId) throw new Error("Sandbox not created"); const sandboxes = await sdk.sandboxes.list(); expect(sandboxes).toBeDefined(); @@ -46,13 +48,13 @@ describe('Sandbox APIs', () => { expect(found).toBeDefined(); }); - it('should find sandbox in running list by filter', async () => { + it("should find sandbox in running list by filter", async () => { expect(sandboxId).toBeDefined(); - if (!sandboxId) throw new Error('Sandbox not created'); + if (!sandboxId) throw new Error("Sandbox not created"); const foundInList = await retryUntil(60000, 3000, async () => { const runningSandboxesByFilter = await sdk.sandboxes.list({ - status: 'running', + status: "running", }); return runningSandboxesByFilter.sandboxes.find((s) => s.id === sandboxId); }); @@ -60,9 +62,9 @@ describe('Sandbox APIs', () => { expect(foundInList).toBeDefined(); }, 70000); - it('should find sandbox in running list by API', async () => { + it("should find sandbox in running list by API", async () => { expect(sandboxId).toBeDefined(); - if (!sandboxId) throw new Error('Sandbox not created'); + if (!sandboxId) throw new Error("Sandbox not created"); const foundByAPI = await retryUntil(60000, 3000, async () => { const runningSandboxByAPI = await sdk.sandboxes.listRunning(); @@ -72,9 +74,9 @@ describe('Sandbox APIs', () => { expect(foundByAPI).toBeDefined(); }, 70000); - it('should get sandbox by ID', async () => { + it("should get sandbox by ID", async () => { expect(sandboxId).toBeDefined(); - if (!sandboxId) throw new Error('Sandbox not created'); + if (!sandboxId) throw new Error("Sandbox not created"); const fetchedSandbox = await sdk.sandboxes.get(sandboxId); expect(fetchedSandbox).toBeDefined(); diff --git a/tests/e2e/sandbox-commands.test.ts b/tests/e2e/sandbox-commands.test.ts index 58a2bbf..cc0f2c0 100644 --- a/tests/e2e/sandbox-commands.test.ts +++ b/tests/e2e/sandbox-commands.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { CodeSandbox } from '../../src/index.js'; -import { Sandbox } from '../../src/Sandbox.js'; -import { SandboxClient } from '../../src/SandboxClient/index.js'; -import { initializeSDK, TEST_TEMPLATE_ID } from './helpers.js'; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { CodeSandbox } from "../../src/index.js"; +import { Sandbox } from "../../src/Sandbox.js"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { createSandbox, initializeSDK } from "./helpers.js"; -describe('Sandbox Commands', () => { +describe("Sandbox Commands", () => { let sdk: CodeSandbox; let sandbox: Sandbox | undefined; let client: SandboxClient | undefined; @@ -13,9 +13,7 @@ describe('Sandbox Commands', () => { sdk = initializeSDK(); // Create a sandbox for testing - sandbox = await sdk.sandboxes.create({ - id: TEST_TEMPLATE_ID, - }); + sandbox = await createSandbox(sdk); // Connect to sandbox client = await sandbox.connect(); @@ -31,7 +29,7 @@ describe('Sandbox Commands', () => { client = undefined; } } catch (error) { - console.error('Failed to dispose client:', error); + console.error("Failed to dispose client:", error); } if (sandboxId) { @@ -39,46 +37,54 @@ describe('Sandbox Commands', () => { await sdk.sandboxes.shutdown(sandboxId); await sdk.sandboxes.delete(sandboxId); } catch (error) { - console.error('Failed to cleanup test sandbox:', sandboxId, error); + console.error("Failed to cleanup test sandbox:", sandboxId, error); try { await sdk.sandboxes.delete(sandboxId); } catch (deleteError) { - console.error('Failed to force delete sandbox:', sandboxId, deleteError); + console.error( + "Failed to force delete sandbox:", + sandboxId, + deleteError + ); } } } }); - describe('Command execution', () => { - it('should run a simple command and get output', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Command execution", () => { + it("should run a simple command and get output", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const output = await client.commands.run('echo "Hello from sandbox"'); - expect(output).toContain('Hello from sandbox'); + expect(output).toContain("Hello from sandbox"); }); - it('should get output from pwd command', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should get output from pwd command", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - const output = await client.commands.run('pwd'); + const output = await client.commands.run("pwd"); expect(output).toBeTruthy(); expect(output.trim()).toMatch(/^\//); // Should start with / }); - it('should run multiple commands sequentially', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should run multiple commands sequentially", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const output1 = await client.commands.run('echo "first"'); const output2 = await client.commands.run('echo "second"'); const output3 = await client.commands.run('echo "third"'); - expect(output1).toContain('first'); - expect(output2).toContain('second'); - expect(output3).toContain('third'); + expect(output1).toContain("first"); + expect(output2).toContain("second"); + expect(output3).toContain("third"); }); - it('should run multiple commands with array syntax', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should run multiple commands with array syntax", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); // Array of commands should be joined with && const output = await client.commands.run([ @@ -87,27 +93,31 @@ describe('Sandbox Commands', () => { 'echo "third"', ]); - expect(output).toContain('first'); - expect(output).toContain('second'); - expect(output).toContain('third'); + expect(output).toContain("first"); + expect(output).toContain("second"); + expect(output).toContain("third"); }); }); - describe('Background commands', () => { - it('should run command in background', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Background commands", () => { + it("should run command in background", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - const command = await client.commands.runBackground('sleep 1 && echo "done"'); + const command = await client.commands.runBackground( + 'sleep 1 && echo "done"' + ); expect(command).toBeDefined(); - expect(command.status).toBe('RUNNING'); + expect(command.status).toBe("RUNNING"); // Wait for completion const output = await command.waitUntilComplete(); - expect(output).toContain('done'); + expect(output).toContain("done"); }, 10000); - it('should run multiple commands in background with array syntax', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it.only("should run multiple commands in background with array syntax", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); // Array of commands should be joined with && const command = await client.commands.runBackground([ @@ -116,19 +126,20 @@ describe('Sandbox Commands', () => { 'echo "third"', ]); expect(command).toBeDefined(); - expect(command.status).toBe('RUNNING'); + expect(command.status).toBe("RUNNING"); // Wait for completion const output = await command.waitUntilComplete(); - expect(output).toContain('first'); - expect(output).toContain('second'); - expect(output).toContain('third'); + expect(output).toContain("first"); + expect(output).toContain("second"); + expect(output).toContain("third"); }, 10000); - it('should be able to kill background command', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should be able to kill background command", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - const command = await client.commands.runBackground('sleep 30'); + const command = await client.commands.runBackground("sleep 30"); expect(command).toBeDefined(); await command.kill(); @@ -138,38 +149,41 @@ describe('Sandbox Commands', () => { }, 10000); }); - describe('Command listing', () => { - it('should get all commands', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Command listing", () => { + it("should get all commands", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const commands = await client.commands.getAll(); expect(Array.isArray(commands)).toBe(true); }); }); - describe('Working directory', () => { - it('should run command in specified directory', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Working directory", () => { + it("should run command in specified directory", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); // Create a test directory - await client.fs.mkdir('/test-cwd'); + await client.fs.mkdir("/test-cwd"); - const output = await client.commands.run('pwd', { cwd: '/test-cwd' }); - expect(output).toContain('/test-cwd'); + const output = await client.commands.run("pwd", { cwd: "/test-cwd" }); + expect(output).toContain("/test-cwd"); // Cleanup - await client.fs.remove('/test-cwd'); + await client.fs.remove("/test-cwd"); }); }); - describe('Environment variables', () => { - it('should run command with custom environment variables', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Environment variables", () => { + it("should run command with custom environment variables", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - const output = await client.commands.run('echo $TEST_VAR', { - env: { TEST_VAR: 'custom_value' }, + const output = await client.commands.run("echo $TEST_VAR", { + env: { TEST_VAR: "custom_value" }, }); - expect(output).toContain('custom_value'); + expect(output).toContain("custom_value"); }); }); }); diff --git a/tests/e2e/sandbox-filesystem.test.ts b/tests/e2e/sandbox-filesystem.test.ts index b43ba5d..a9a44a8 100644 --- a/tests/e2e/sandbox-filesystem.test.ts +++ b/tests/e2e/sandbox-filesystem.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { CodeSandbox } from '../../src/index.js'; -import { Sandbox } from '../../src/Sandbox.js'; -import { SandboxClient } from '../../src/SandboxClient/index.js'; -import { initializeSDK, TEST_TEMPLATE_ID } from './helpers.js'; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { CodeSandbox } from "../../src/index.js"; +import { Sandbox } from "../../src/Sandbox.js"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { initializeSDK, TEST_TEMPLATE_ID } from "./helpers.js"; -describe('Sandbox Filesystem', () => { +describe("Sandbox Filesystem", () => { let sdk: CodeSandbox; let sandbox: Sandbox | undefined; let client: SandboxClient | undefined; @@ -13,16 +13,18 @@ describe('Sandbox Filesystem', () => { sdk = initializeSDK(); // Create a sandbox for testing + /* sandbox = await sdk.sandboxes.create({ id: TEST_TEMPLATE_ID, }); + */ + sandbox = await sdk.sandboxes.resume("7s847p"); // Connect to sandbox client = await sandbox.connect(); - }, 60000); // 1 minute timeout for setup + }, 60000); afterAll(async () => { - // Cleanup: disconnect, shutdown and delete the sandbox const sandboxId = sandbox?.id; try { @@ -32,278 +34,320 @@ describe('Sandbox Filesystem', () => { client = undefined; } } catch (error) { - console.error('Failed to dispose client:', error); + console.error("Failed to dispose client:", error); } + /* if (sandboxId) { try { await sdk.sandboxes.shutdown(sandboxId); await sdk.sandboxes.delete(sandboxId); } catch (error) { - console.error('Failed to cleanup test sandbox:', sandboxId, error); - // Try to force delete even if shutdown fails + console.error("Failed to cleanup test sandbox:", sandboxId, error); try { await sdk.sandboxes.delete(sandboxId); } catch (deleteError) { - console.error('Failed to force delete sandbox:', sandboxId, deleteError); + console.error( + "Failed to force delete sandbox:", + sandboxId, + deleteError + ); } } } + */ }); - describe('File operations', () => { - it('should write and read a file', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("File operations", () => { + it("should write and read a file", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - await client.fs.writeTextFile('/test-file.txt', 'Hello, Sandbox!'); - const fileContent = await client.fs.readTextFile('/test-file.txt'); + await client.fs.writeTextFile("/test-file.txt", "Hello, Sandbox!"); + const fileContent = await client.fs.readTextFile("/test-file.txt"); - expect(fileContent).toBe('Hello, Sandbox!'); + expect(fileContent).toBe("Hello, Sandbox!"); }); - it('should list files in directory', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should list files in directory", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - const files = await client.fs.readdir('/'); + const files = await client.fs.readdir("/"); expect(files).toBeDefined(); - const testFile = files.find((f) => f.name === 'test-file.txt' && f.type === 'file'); + const testFile = files.find( + (f) => f.name === "test-file.txt" && f.type === "file" + ); expect(testFile).toBeDefined(); }); - it('should delete a file', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should delete a file", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - await client.fs.remove('/test-file.txt'); + await client.fs.remove("/test-file.txt"); - const filesAfterDeletion = await client.fs.readdir('/'); - const testFile = filesAfterDeletion.find((f) => f.name === 'test-file.txt'); + const filesAfterDeletion = await client.fs.readdir("/"); + const testFile = filesAfterDeletion.find( + (f) => f.name === "test-file.txt" + ); expect(testFile).toBeUndefined(); }); }); - describe('Directory operations', () => { - it('should create a directory', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Directory operations", () => { + it("should create a directory", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - await client.fs.mkdir('/test-dir'); + await client.fs.mkdir("/test-dir"); - const dirs = await client.fs.readdir('/'); - const testDir = dirs.find((d) => d.name === 'test-dir' && d.type === 'directory'); + const dirs = await client.fs.readdir("/"); + const testDir = dirs.find( + (d) => d.name === "test-dir" && d.type === "directory" + ); expect(testDir).toBeDefined(); }); - it('should delete a directory', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should delete a directory", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - await client.fs.remove('/test-dir'); + await client.fs.remove("/test-dir"); - const dirsAfterDeletion = await client.fs.readdir('/'); - const testDir = dirsAfterDeletion.find((d) => d.name === 'test-dir'); + const dirsAfterDeletion = await client.fs.readdir("/"); + const testDir = dirsAfterDeletion.find((d) => d.name === "test-dir"); expect(testDir).toBeUndefined(); }); }); - describe('Binary file operations', () => { - it('should write and read binary files', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Binary file operations", () => { + it("should write and read binary files", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const binaryData = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello" in bytes - await client.fs.writeFile('/test-binary.bin', binaryData); + await client.fs.writeFile("/test-binary.bin", binaryData); - const readData = await client.fs.readFile('/test-binary.bin'); + const readData = await client.fs.readFile("/test-binary.bin"); // Compare values instead of object types (readFile may return Buffer in Node.js) expect(Array.from(readData)).toEqual(Array.from(binaryData)); // Cleanup - await client.fs.remove('/test-binary.bin'); + await client.fs.remove("/test-binary.bin"); }); }); - describe('File stat operations', () => { - it('should get file stats', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("File stat operations", () => { + it("should get file stats", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - await client.fs.writeTextFile('/stat-test.txt', 'test content'); + await client.fs.writeTextFile("/stat-test.txt", "test content"); - const stats = await client.fs.stat('/stat-test.txt'); + const stats = await client.fs.stat("/stat-test.txt"); expect(stats).toBeDefined(); - expect(stats.type).toBe('file'); + expect(stats.type).toBe("file"); expect(stats.size).toBeGreaterThan(0); // Cleanup - await client.fs.remove('/stat-test.txt'); + await client.fs.remove("/stat-test.txt"); }); - it('should get directory stats', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should get directory stats", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - await client.fs.mkdir('/stat-dir'); + await client.fs.mkdir("/stat-dir"); - const stats = await client.fs.stat('/stat-dir'); + const stats = await client.fs.stat("/stat-dir"); expect(stats).toBeDefined(); - expect(stats.type).toBe('directory'); + expect(stats.type).toBe("directory"); // Cleanup - await client.fs.remove('/stat-dir'); + await client.fs.remove("/stat-dir"); }); }); - describe('Copy operations', () => { - it('should copy a file', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Copy operations", () => { + it("should copy a file", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - await client.fs.writeTextFile('/copy-source.txt', 'copy test'); - await client.fs.copy('/copy-source.txt', '/copy-dest.txt'); + await client.fs.writeTextFile("/copy-source.txt", "copy test"); + await client.fs.copy("/copy-source.txt", "/copy-dest.txt"); - const content = await client.fs.readTextFile('/copy-dest.txt'); - expect(content).toBe('copy test'); + const content = await client.fs.readTextFile("/copy-dest.txt"); + expect(content).toBe("copy test"); // Cleanup - await client.fs.remove('/copy-source.txt'); - await client.fs.remove('/copy-dest.txt'); + await client.fs.remove("/copy-source.txt"); + await client.fs.remove("/copy-dest.txt"); }); - it('should copy a directory recursively', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should copy a directory recursively", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - await client.fs.mkdir('/copy-dir'); - await client.fs.writeTextFile('/copy-dir/file.txt', 'nested file'); - await client.fs.copy('/copy-dir', '/copy-dir-dest', true); + await client.fs.mkdir("/copy-dir"); + await client.fs.writeTextFile("/copy-dir/file.txt", "nested file"); + await client.fs.copy("/copy-dir", "/copy-dir-dest", true); - const content = await client.fs.readTextFile('/copy-dir-dest/file.txt'); - expect(content).toBe('nested file'); + const content = await client.fs.readTextFile("/copy-dir-dest/file.txt"); + expect(content).toBe("nested file"); // Cleanup - await client.fs.remove('/copy-dir', true); - await client.fs.remove('/copy-dir-dest', true); + await client.fs.remove("/copy-dir", true); + await client.fs.remove("/copy-dir-dest", true); }); }); - describe('Rename operations', () => { - it('should rename a file', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Rename operations", () => { + it("should rename a file", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - await client.fs.writeTextFile('/rename-old.txt', 'rename test'); - await client.fs.rename('/rename-old.txt', '/rename-new.txt'); + await client.fs.writeTextFile("/rename-old.txt", "rename test"); + await client.fs.rename("/rename-old.txt", "/rename-new.txt"); - const content = await client.fs.readTextFile('/rename-new.txt'); - expect(content).toBe('rename test'); + const content = await client.fs.readTextFile("/rename-new.txt"); + expect(content).toBe("rename test"); - const files = await client.fs.readdir('/'); - expect(files.find((f) => f.name === 'rename-old.txt')).toBeUndefined(); - expect(files.find((f) => f.name === 'rename-new.txt')).toBeDefined(); + const files = await client.fs.readdir("/"); + expect(files.find((f) => f.name === "rename-old.txt")).toBeUndefined(); + expect(files.find((f) => f.name === "rename-new.txt")).toBeDefined(); // Cleanup - await client.fs.remove('/rename-new.txt'); + await client.fs.remove("/rename-new.txt"); }); - it('should rename a directory', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should rename a directory", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - await client.fs.mkdir('/rename-dir-old'); - await client.fs.writeTextFile('/rename-dir-old/file.txt', 'content'); - await client.fs.rename('/rename-dir-old', '/rename-dir-new'); + await client.fs.mkdir("/rename-dir-old"); + await client.fs.writeTextFile("/rename-dir-old/file.txt", "content"); + await client.fs.rename("/rename-dir-old", "/rename-dir-new"); - const content = await client.fs.readTextFile('/rename-dir-new/file.txt'); - expect(content).toBe('content'); + const content = await client.fs.readTextFile("/rename-dir-new/file.txt"); + expect(content).toBe("content"); - const dirs = await client.fs.readdir('/'); - expect(dirs.find((d) => d.name === 'rename-dir-old')).toBeUndefined(); - expect(dirs.find((d) => d.name === 'rename-dir-new')).toBeDefined(); + const dirs = await client.fs.readdir("/"); + expect(dirs.find((d) => d.name === "rename-dir-old")).toBeUndefined(); + expect(dirs.find((d) => d.name === "rename-dir-new")).toBeDefined(); // Cleanup - await client.fs.remove('/rename-dir-new', true); + await client.fs.remove("/rename-dir-new", true); }); }); - describe.skip('Batch write operations', () => { + describe.skip("Batch write operations", () => { // Skip these tests - batchWrite uses zip/unzip which may not be available in all sandbox environments - it('should write multiple files at once', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should write multiple files at once", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - await client.fs.mkdir('/batch-test'); + await client.fs.mkdir("/batch-test"); await client.fs.batchWrite([ - { path: '/batch-test/file1.txt', content: 'content 1' }, - { path: '/batch-test/file2.txt', content: 'content 2' }, - { path: '/batch-test/file3.txt', content: 'content 3' }, + { path: "/batch-test/file1.txt", content: "content 1" }, + { path: "/batch-test/file2.txt", content: "content 2" }, + { path: "/batch-test/file3.txt", content: "content 3" }, ]); - const content1 = await client.fs.readTextFile('/batch-test/file1.txt'); - const content2 = await client.fs.readTextFile('/batch-test/file2.txt'); - const content3 = await client.fs.readTextFile('/batch-test/file3.txt'); + const content1 = await client.fs.readTextFile("/batch-test/file1.txt"); + const content2 = await client.fs.readTextFile("/batch-test/file2.txt"); + const content3 = await client.fs.readTextFile("/batch-test/file3.txt"); - expect(content1).toBe('content 1'); - expect(content2).toBe('content 2'); - expect(content3).toBe('content 3'); + expect(content1).toBe("content 1"); + expect(content2).toBe("content 2"); + expect(content3).toBe("content 3"); // Cleanup - await client.fs.remove('/batch-test', true); + await client.fs.remove("/batch-test", true); }); - it('should write nested directories in batch', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should write nested directories in batch", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); await client.fs.batchWrite([ - { path: '/batch-nested/dir1/file.txt', content: 'nested 1' }, - { path: '/batch-nested/dir2/file.txt', content: 'nested 2' }, + { path: "/batch-nested/dir1/file.txt", content: "nested 1" }, + { path: "/batch-nested/dir2/file.txt", content: "nested 2" }, ]); - const content1 = await client.fs.readTextFile('/batch-nested/dir1/file.txt'); - const content2 = await client.fs.readTextFile('/batch-nested/dir2/file.txt'); + const content1 = await client.fs.readTextFile( + "/batch-nested/dir1/file.txt" + ); + const content2 = await client.fs.readTextFile( + "/batch-nested/dir2/file.txt" + ); - expect(content1).toBe('nested 1'); - expect(content2).toBe('nested 2'); + expect(content1).toBe("nested 1"); + expect(content2).toBe("nested 2"); // Cleanup - await client.fs.remove('/batch-nested', true); + await client.fs.remove("/batch-nested", true); }); }); - describe('Recursive operations', () => { - it('should create nested directories', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Recursive operations", () => { + it("should create nested directories", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - await client.fs.mkdir('/nested/deep/path', true); + await client.fs.mkdir("/nested/deep/path", true); - const stats = await client.fs.stat('/nested/deep/path'); - expect(stats.type).toBe('directory'); + const stats = await client.fs.stat("/nested/deep/path"); + expect(stats.type).toBe("directory"); // Cleanup - await client.fs.remove('/nested', true); + await client.fs.remove("/nested", true); }); - it('should remove directory with contents recursively', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should remove directory with contents recursively", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - await client.fs.mkdir('/recursive-remove'); - await client.fs.writeTextFile('/recursive-remove/file1.txt', 'content'); - await client.fs.mkdir('/recursive-remove/subdir'); - await client.fs.writeTextFile('/recursive-remove/subdir/file2.txt', 'content'); + await client.fs.mkdir("/recursive-remove"); + await client.fs.writeTextFile("/recursive-remove/file1.txt", "content"); + await client.fs.mkdir("/recursive-remove/subdir"); + await client.fs.writeTextFile( + "/recursive-remove/subdir/file2.txt", + "content" + ); - await client.fs.remove('/recursive-remove', true); + await client.fs.remove("/recursive-remove", true); - const files = await client.fs.readdir('/'); - expect(files.find((f) => f.name === 'recursive-remove')).toBeUndefined(); + const files = await client.fs.readdir("/"); + expect(files.find((f) => f.name === "recursive-remove")).toBeUndefined(); }); }); - describe('File watching', () => { - it('should detect file system changes', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("File watching", () => { + it.only("should detect file system changes", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - await client.fs.mkdir('/watch-dir'); + try { + await client.fs.remove("/watch-dir"); + } catch {} + + await client.fs.mkdir("/watch-dir"); let changeDetected = false; - const watcher = await client.fs.watch('/watch-dir', { recursive: true }); + const watcher = await client.fs.watch("/watch-dir", { recursive: true }); const eventDisposable = watcher.onEvent((event) => { - if (event.paths.some((p) => p.includes('watched-file.txt'))) { + if (event.paths.some((p) => p.includes("watched-file.txt"))) { changeDetected = true; } }); - await client.fs.writeTextFile('/watch-dir/watched-file.txt', 'Watching this file'); + await client.fs.writeTextFile( + "/watch-dir/watched-file.txt", + "Watching this file" + ); // Wait up to 10 seconds for the change to be detected const maxWaitTime = 10000; @@ -320,8 +364,8 @@ describe('Sandbox Filesystem', () => { watcher.dispose(); // Cleanup - await client.fs.remove('/watch-dir/watched-file.txt'); - await client.fs.remove('/watch-dir'); + await client.fs.remove("/watch-dir/watched-file.txt"); + await client.fs.remove("/watch-dir"); }, 30000); }); }); diff --git a/tests/e2e/sandbox-hosts.test.ts b/tests/e2e/sandbox-hosts.test.ts index f5ffe70..05e78bb 100644 --- a/tests/e2e/sandbox-hosts.test.ts +++ b/tests/e2e/sandbox-hosts.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { CodeSandbox } from '../../src/index.js'; -import { Sandbox } from '../../src/Sandbox.js'; -import { SandboxClient } from '../../src/SandboxClient/index.js'; -import { initializeSDK, TEST_TEMPLATE_ID } from './helpers.js'; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { CodeSandbox } from "../../src/index.js"; +import { Sandbox } from "../../src/Sandbox.js"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { createSandbox, initializeSDK } from "./helpers.js"; -describe('Sandbox Hosts', () => { +describe("Sandbox Hosts", () => { let sdk: CodeSandbox; let sandbox: Sandbox | undefined; let client: SandboxClient | undefined; @@ -13,9 +13,7 @@ describe('Sandbox Hosts', () => { sdk = initializeSDK(); // Create a sandbox for testing - sandbox = await sdk.sandboxes.create({ - id: TEST_TEMPLATE_ID, - }); + sandbox = await createSandbox(sdk); // Connect to sandbox client = await sandbox.connect(); @@ -31,7 +29,7 @@ describe('Sandbox Hosts', () => { client = undefined; } } catch (error) { - console.error('Failed to dispose client:', error); + console.error("Failed to dispose client:", error); } if (sandboxId) { @@ -39,59 +37,68 @@ describe('Sandbox Hosts', () => { await sdk.sandboxes.shutdown(sandboxId); await sdk.sandboxes.delete(sandboxId); } catch (error) { - console.error('Failed to cleanup test sandbox:', sandboxId, error); + console.error("Failed to cleanup test sandbox:", sandboxId, error); try { await sdk.sandboxes.delete(sandboxId); } catch (deleteError) { - console.error('Failed to force delete sandbox:', sandboxId, deleteError); + console.error( + "Failed to force delete sandbox:", + sandboxId, + deleteError + ); } } } }); - describe('Host URL generation', () => { - it('should generate URL for a port', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Host URL generation", () => { + it("should generate URL for a port", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const url = client.hosts.getUrl(3000); expect(url).toBeTruthy(); - expect(url).toContain('csb.app'); - expect(url).toContain('3000'); + expect(url).toContain("csb.app"); + expect(url).toContain("3000"); expect(url).toContain(sandbox.id); }); - it('should generate URL with custom protocol', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should generate URL with custom protocol", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - const url = client.hosts.getUrl(8080, 'http'); + const url = client.hosts.getUrl(8080, "http"); expect(url).toBeTruthy(); - expect(url.startsWith('http://')).toBe(true); - expect(url).toContain('8080'); + expect(url.startsWith("http://")).toBe(true); + expect(url).toContain("8080"); }); - it('should generate URL with https by default', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should generate URL with https by default", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const url = client.hosts.getUrl(4000); - expect(url.startsWith('https://')).toBe(true); + expect(url.startsWith("https://")).toBe(true); }); }); - describe('Host headers and cookies', () => { - it('should get headers', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Host headers and cookies", () => { + it("should get headers", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const headers = client.hosts.getHeaders(); expect(headers).toBeDefined(); - expect(typeof headers).toBe('object'); + expect(typeof headers).toBe("object"); }); - it('should get cookies', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should get cookies", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const cookies = client.hosts.getCookies(); expect(cookies).toBeDefined(); - expect(typeof cookies).toBe('object'); + expect(typeof cookies).toBe("object"); }); }); }); diff --git a/tests/e2e/sandbox-interpreters.test.ts b/tests/e2e/sandbox-interpreters.test.ts index e385613..5928d44 100644 --- a/tests/e2e/sandbox-interpreters.test.ts +++ b/tests/e2e/sandbox-interpreters.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { CodeSandbox } from '../../src/index.js'; -import { Sandbox } from '../../src/Sandbox.js'; -import { SandboxClient } from '../../src/SandboxClient/index.js'; -import { initializeSDK, TEST_TEMPLATE_ID } from './helpers.js'; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { CodeSandbox } from "../../src/index.js"; +import { Sandbox } from "../../src/Sandbox.js"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { createSandbox, initializeSDK, TEST_TEMPLATE_ID } from "./helpers.js"; -describe('Sandbox Interpreters', () => { +describe("Sandbox Interpreters", () => { let sdk: CodeSandbox; let sandbox: Sandbox | undefined; let client: SandboxClient | undefined; @@ -13,9 +13,7 @@ describe('Sandbox Interpreters', () => { sdk = initializeSDK(); // Create a sandbox for testing - sandbox = await sdk.sandboxes.create({ - id: TEST_TEMPLATE_ID, - }); + sandbox = await createSandbox(sdk); // Connect to sandbox client = await sandbox.connect(); @@ -31,7 +29,7 @@ describe('Sandbox Interpreters', () => { client = undefined; } } catch (error) { - console.error('Failed to dispose client:', error); + console.error("Failed to dispose client:", error); } if (sandboxId) { @@ -39,72 +37,82 @@ describe('Sandbox Interpreters', () => { await sdk.sandboxes.shutdown(sandboxId); await sdk.sandboxes.delete(sandboxId); } catch (error) { - console.error('Failed to cleanup test sandbox:', sandboxId, error); + console.error("Failed to cleanup test sandbox:", sandboxId, error); try { await sdk.sandboxes.delete(sandboxId); } catch (deleteError) { - console.error('Failed to force delete sandbox:', sandboxId, deleteError); + console.error( + "Failed to force delete sandbox:", + sandboxId, + deleteError + ); } } } }); - describe('JavaScript interpreter', () => { - it('should execute simple JavaScript code', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("JavaScript interpreter", () => { + it("should execute simple JavaScript code", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - const result = await client.interpreters.javascript('2 + 2'); - expect(result).toContain('4'); + const result = await client.interpreters.javascript("2 + 2"); + expect(result).toContain("4"); }); - it('should execute JavaScript with variables', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should execute JavaScript with variables", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const result = await client.interpreters.javascript(` const x = 10; const y = 20; console.log(x + y); `); - expect(result).toContain('30'); + expect(result).toContain("30"); }); - it('should execute JavaScript with return statement', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should execute JavaScript with return statement", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const result = await client.interpreters.javascript(` const greeting = 'Hello from JavaScript'; console.log(greeting); `); - expect(result).toContain('Hello from JavaScript'); + expect(result).toContain("Hello from JavaScript"); }); }); - describe('Python interpreter', () => { - it('should execute simple Python code', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Python interpreter", () => { + it("should execute simple Python code", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); - const result = await client.interpreters.python('2 + 2'); - expect(result).toContain('4'); + const result = await client.interpreters.python("2 + 2"); + expect(result).toContain("4"); }); - it('should execute Python with variables', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should execute Python with variables", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const result = await client.interpreters.python(` x = 10 y = 20 print(x + y)`); - expect(result).toContain('30'); + expect(result).toContain("30"); }); - it('should execute Python with print statement', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should execute Python with print statement", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const result = await client.interpreters.python(` message = 'Hello from Python' print(message) `); - expect(result).toContain('Hello from Python'); + expect(result).toContain("Hello from Python"); }); }); }); diff --git a/tests/e2e/sandbox-ports.test.ts b/tests/e2e/sandbox-ports.test.ts index 5d6b4d4..47f3d5c 100644 --- a/tests/e2e/sandbox-ports.test.ts +++ b/tests/e2e/sandbox-ports.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { CodeSandbox } from '../../src/index.js'; -import { Sandbox } from '../../src/Sandbox.js'; -import { SandboxClient } from '../../src/SandboxClient/index.js'; -import { initializeSDK, TEST_TEMPLATE_ID } from './helpers.js'; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { CodeSandbox } from "../../src/index.js"; +import { Sandbox } from "../../src/Sandbox.js"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { createSandbox, initializeSDK } from "./helpers.js"; -describe('Sandbox Ports', () => { +describe("Sandbox Ports", () => { let sdk: CodeSandbox; let sandbox: Sandbox | undefined; let client: SandboxClient | undefined; @@ -13,9 +13,7 @@ describe('Sandbox Ports', () => { sdk = initializeSDK(); // Create a sandbox for testing - sandbox = await sdk.sandboxes.create({ - id: TEST_TEMPLATE_ID, - }); + sandbox = await createSandbox(sdk); // Connect to sandbox client = await sandbox.connect(); @@ -31,7 +29,7 @@ describe('Sandbox Ports', () => { client = undefined; } } catch (error) { - console.error('Failed to dispose client:', error); + console.error("Failed to dispose client:", error); } if (sandboxId) { @@ -39,36 +37,46 @@ describe('Sandbox Ports', () => { await sdk.sandboxes.shutdown(sandboxId); await sdk.sandboxes.delete(sandboxId); } catch (error) { - console.error('Failed to cleanup test sandbox:', sandboxId, error); + console.error("Failed to cleanup test sandbox:", sandboxId, error); try { await sdk.sandboxes.delete(sandboxId); } catch (deleteError) { - console.error('Failed to force delete sandbox:', sandboxId, deleteError); + console.error( + "Failed to force delete sandbox:", + sandboxId, + deleteError + ); } } } }); - describe('Port listing', () => { - it('should get all open ports', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Port listing", () => { + it("should get all open ports", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const ports = await client.ports.getAll(); expect(Array.isArray(ports)).toBe(true); }); }); - describe('Port operations with server', () => { + describe("Port operations with server", () => { // Skipped - these tests have shell lifecycle management issues - it('should detect when a port opens', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should detect when a port opens", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); // Start a simple HTTP server in the background - const serverCommand = await client.commands.runBackground(`node -e 'require("http").createServer((req, res) => res.end("hello")).listen(8888)'`); + const serverCommand = await client.commands.runBackground( + `node -e 'require("http").createServer((req, res) => res.end("hello")).listen(8888)'` + ); try { // Wait for port to open (with timeout) - const portInfo = await client.ports.waitForPort(8888, { timeoutMs: 20000 }); + const portInfo = await client.ports.waitForPort(8888, { + timeoutMs: 20000, + }); expect(portInfo).toBeDefined(); expect(portInfo.port).toBe(8888); expect(portInfo.host).toBeTruthy(); @@ -78,11 +86,14 @@ describe('Sandbox Ports', () => { } }, 40000); - it('should get port information', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should get port information", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); // Start a server - const serverCommand = await client.commands.runBackground(`node -e 'require("http").createServer((req, res) => res.end("test")).listen(9999)'`); + const serverCommand = await client.commands.runBackground( + `node -e 'require("http").createServer((req, res) => res.end("test")).listen(9999)'` + ); try { // Wait for port to open @@ -102,10 +113,11 @@ describe('Sandbox Ports', () => { }, 40000); }); - describe('Port events', () => { + describe("Port events", () => { // Skipped - these tests have shell lifecycle management issues - it('should listen to port opened events', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should listen to port opened events", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); let portOpened = false; let openedPort = 0; @@ -119,7 +131,9 @@ describe('Sandbox Ports', () => { }); // Start a server - const serverCommand = await client.commands.runBackground(`node -e 'require("http").createServer((req, res) => res.end("test")).listen(7777)'`); + const serverCommand = await client.commands.runBackground( + `node -e 'require("http").createServer((req, res) => res.end("test")).listen(7777)'` + ); try { // Wait for the port to be detected diff --git a/tests/e2e/sandbox-setup.test.ts b/tests/e2e/sandbox-setup.test.ts index 995bef9..94278e4 100644 --- a/tests/e2e/sandbox-setup.test.ts +++ b/tests/e2e/sandbox-setup.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { CodeSandbox } from '../../src/index.js'; -import { Sandbox } from '../../src/Sandbox.js'; -import { SandboxClient } from '../../src/SandboxClient/index.js'; -import { initializeSDK, TEST_TEMPLATE_ID } from './helpers.js'; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { CodeSandbox } from "../../src/index.js"; +import { Sandbox } from "../../src/Sandbox.js"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { createSandbox, initializeSDK } from "./helpers.js"; -describe('Sandbox Setup', () => { +describe("Sandbox Setup", () => { let sdk: CodeSandbox; let sandbox: Sandbox | undefined; let client: SandboxClient | undefined; @@ -13,9 +13,7 @@ describe('Sandbox Setup', () => { sdk = initializeSDK(); // Create a sandbox for testing - sandbox = await sdk.sandboxes.create({ - id: TEST_TEMPLATE_ID, - }); + sandbox = await createSandbox(sdk); // Connect to sandbox client = await sandbox.connect(); @@ -31,7 +29,7 @@ describe('Sandbox Setup', () => { client = undefined; } } catch (error) { - console.error('Failed to dispose client:', error); + console.error("Failed to dispose client:", error); } if (sandboxId) { @@ -39,53 +37,62 @@ describe('Sandbox Setup', () => { await sdk.sandboxes.shutdown(sandboxId); await sdk.sandboxes.delete(sandboxId); } catch (error) { - console.error('Failed to cleanup test sandbox:', sandboxId, error); + console.error("Failed to cleanup test sandbox:", sandboxId, error); try { await sdk.sandboxes.delete(sandboxId); } catch (deleteError) { - console.error('Failed to force delete sandbox:', sandboxId, deleteError); + console.error( + "Failed to force delete sandbox:", + sandboxId, + deleteError + ); } } } }); - describe('Setup operations', () => { - it('should get setup status', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Setup operations", () => { + it("should get setup status", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const status = client.setup.status; expect(status).toBeDefined(); - expect(['RUNNING', 'FINISHED', 'STOPPED', 'IDLE']).toContain(status); + expect(["RUNNING", "FINISHED", "STOPPED", "IDLE"]).toContain(status); }); - it('should get setup steps', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should get setup steps", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const steps = client.setup.getSteps(); expect(Array.isArray(steps)).toBe(true); }); - it('should get current step index', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should get current step index", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const currentStepIndex = client.setup.currentStepIndex; - expect(typeof currentStepIndex).toBe('number'); + expect(typeof currentStepIndex).toBe("number"); }); - it('should wait until setup completes', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should wait until setup completes", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); // If setup is already finished, this should resolve immediately await client.setup.waitUntilComplete(); const status = client.setup.status; - expect(status).toBe('FINISHED'); + expect(status).toBe("FINISHED"); }, 60000); }); - describe('Setup steps', () => { - it('should have step properties', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Setup steps", () => { + it("should have step properties", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const steps = client.setup.getSteps(); diff --git a/tests/e2e/sandbox-tasks.test.ts b/tests/e2e/sandbox-tasks.test.ts index 210212b..626eae5 100644 --- a/tests/e2e/sandbox-tasks.test.ts +++ b/tests/e2e/sandbox-tasks.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { CodeSandbox } from '../../src/index.js'; -import { Sandbox } from '../../src/Sandbox.js'; -import { SandboxClient } from '../../src/SandboxClient/index.js'; -import { initializeSDK, TEST_TEMPLATE_ID } from './helpers.js'; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { CodeSandbox } from "../../src/index.js"; +import { Sandbox } from "../../src/Sandbox.js"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { createSandbox, initializeSDK } from "./helpers.js"; -describe('Sandbox Tasks', () => { +describe("Sandbox Tasks", () => { let sdk: CodeSandbox; let sandbox: Sandbox | undefined; let client: SandboxClient | undefined; @@ -13,9 +13,7 @@ describe('Sandbox Tasks', () => { sdk = initializeSDK(); // Create a sandbox for testing - sandbox = await sdk.sandboxes.create({ - id: TEST_TEMPLATE_ID, - }); + sandbox = await createSandbox(sdk); // Connect to sandbox client = await sandbox.connect(); @@ -31,7 +29,7 @@ describe('Sandbox Tasks', () => { client = undefined; } } catch (error) { - console.error('Failed to dispose client:', error); + console.error("Failed to dispose client:", error); } if (sandboxId) { @@ -39,26 +37,32 @@ describe('Sandbox Tasks', () => { await sdk.sandboxes.shutdown(sandboxId); await sdk.sandboxes.delete(sandboxId); } catch (error) { - console.error('Failed to cleanup test sandbox:', sandboxId, error); + console.error("Failed to cleanup test sandbox:", sandboxId, error); try { await sdk.sandboxes.delete(sandboxId); } catch (deleteError) { - console.error('Failed to force delete sandbox:', sandboxId, deleteError); + console.error( + "Failed to force delete sandbox:", + sandboxId, + deleteError + ); } } } }); - describe('Task listing', () => { - it('should get all tasks', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Task listing", () => { + it("should get all tasks", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const tasks = await client.tasks.getAll(); expect(Array.isArray(tasks)).toBe(true); }); - it('should get task by ID if tasks exist', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should get task by ID if tasks exist", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const tasks = await client.tasks.getAll(); @@ -75,9 +79,10 @@ describe('Sandbox Tasks', () => { }); }); - describe('Task properties', () => { - it('should have task properties', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + describe("Task properties", () => { + it("should have task properties", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const tasks = await client.tasks.getAll(); @@ -86,18 +91,19 @@ describe('Sandbox Tasks', () => { expect(task.id).toBeTruthy(); expect(task.name).toBeTruthy(); expect(task.command).toBeTruthy(); - expect(typeof task.runAtStart).toBe('boolean'); + expect(typeof task.runAtStart).toBe("boolean"); expect(task.status).toBeDefined(); expect(Array.isArray(task.ports)).toBe(true); } }); }); - describe('Task operations', () => { + describe("Task operations", () => { // These tests are skipped as they require specific task configurations // and may interfere with running tasks - it('should run a task', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should run a task", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const tasks = await client.tasks.getAll(); @@ -112,8 +118,9 @@ describe('Sandbox Tasks', () => { } }); - it('should stop a running task', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should stop a running task", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const tasks = await client.tasks.getAll(); @@ -125,8 +132,9 @@ describe('Sandbox Tasks', () => { } }); - it('should restart a task', async () => { - if (!client || !sandbox) throw new Error('Client or sandbox not initialized'); + it("should restart a task", async () => { + if (!client || !sandbox) + throw new Error("Client or sandbox not initialized"); const tasks = await client.tasks.getAll(); diff --git a/tests/e2e/sandbox-terminals.test.ts b/tests/e2e/sandbox-terminals.test.ts index 92b6354..8332908 100644 --- a/tests/e2e/sandbox-terminals.test.ts +++ b/tests/e2e/sandbox-terminals.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { CodeSandbox } from "../../src/index.js"; import { Sandbox } from "../../src/Sandbox.js"; import { SandboxClient } from "../../src/SandboxClient/index.js"; -import { initializeSDK, TEST_TEMPLATE_ID } from "./helpers.js"; +import { createSandbox, initializeSDK, TEST_TEMPLATE_ID } from "./helpers.js"; describe("Sandbox Terminals", () => { let sdk: CodeSandbox; @@ -13,9 +13,7 @@ describe("Sandbox Terminals", () => { sdk = initializeSDK(); // Create a sandbox for testing - sandbox = await sdk.sandboxes.create({ - id: TEST_TEMPLATE_ID, - }); + sandbox = await createSandbox(sdk); // Connect to sandbox client = await sandbox.connect(); diff --git a/tests/pint-shells-client.test.ts b/tests/pint-shells-client.test.ts index 347ba2a..8bfaad8 100644 --- a/tests/pint-shells-client.test.ts +++ b/tests/pint-shells-client.test.ts @@ -3,7 +3,6 @@ import { PintShellsClient } from "../src/PintClient/execs"; import { Client } from "../src/api-clients/pint/client"; import * as pintApi from "../src/api-clients/pint"; import { ExecItem } from "../src/api-clients/pint"; -import { IDisposable } from "../src/utils/disposable"; // Mock the API functions vi.mock("../src/api-clients/pint", () => ({ @@ -44,6 +43,7 @@ const createMockExecItem = (overrides: Partial = {}): ExecItem => ({ status: "RUNNING", exitCode: 0, pid: 1234, + pty: false, ...overrides, }); @@ -279,7 +279,6 @@ describe("PintShellsClient", () => { }); }); - describe("rename", () => { it("should return null as rename is not implemented", async () => { const result = await client.rename("exec-123", "new-name"); From a12e2e7d253801553c06ced42a82ff527282b774 Mon Sep 17 00:00:00 2001 From: Joji Augustine Date: Fri, 6 Feb 2026 12:59:43 +0100 Subject: [PATCH 21/46] add docker login --- openapi.json | 95 +++++++++++++++++++---------- src/api-clients/client/types.gen.ts | 74 ++++++++++++++-------- src/bin/commands/build.ts | 31 +++++++++- src/bin/utils/docker.ts | 66 ++++++++++++++++++++ 4 files changed, 210 insertions(+), 56 deletions(-) diff --git a/openapi.json b/openapi.json index caee078..3360326 100644 --- a/openapi.json +++ b/openapi.json @@ -117,36 +117,6 @@ "title": "VMAssignTagAliasResponse", "type": "object" }, - "TemplateCreateRequest": { - "properties": { - "description": { - "default": "[Template description]", - "description": "Template description. Maximum 255 characters. Defaults to description of original sandbox.", - "maxLength": 255, - "type": "string" - }, - "forkOf": { - "description": "Short ID of the sandbox to fork.", - "example": "pt_1234567890", - "type": "string" - }, - "tags": { - "default": [], - "description": "Tags to set on the new sandbox, if any. Will not inherit tags from the source sandbox.", - "items": { "type": "string" }, - "type": "array" - }, - "title": { - "default": "[Template title]", - "description": "Template title. Maximum 255 characters. Defaults to title of original sandbox with (forked).", - "maxLength": 255, - "type": "string" - } - }, - "required": ["forkOf"], - "title": "TemplateCreateRequest", - "type": "object" - }, "PreviewToken": { "properties": { "expires_at": { "nullable": true, "type": "string" }, @@ -212,6 +182,66 @@ "title": "PreviewTokenRevokeAllResponse", "type": "object" }, + "TemplateCreateRequestCommon": { + "properties": { + "description": { + "default": "[Template description]", + "description": "Template description. Maximum 255 characters. Defaults to description of original sandbox.", + "maxLength": 255, + "type": "string" + }, + "forkOf": { + "description": "Short ID of the sandbox to fork.", + "example": "pt_1234567890", + "type": "string" + }, + "image": { + "description": "Container image to use as template", + "properties": { + "architecture": { + "description": "The architecture of the image. Required for multi-platform images", + "type": "string" + }, + "name": { + "description": "The image name (for example 'nginx').", + "type": "string" + }, + "registry": { + "default": "docker.io", + "description": "The container registry where the image is stored.", + "type": "string" + }, + "repository": { + "default": "library", + "description": "The repository or namespace where the image is stored.", + "type": "string" + }, + "tag": { + "default": "latest", + "description": "The image tag.", + "type": "string" + } + }, + "required": ["name"], + "type": "object" + }, + "tags": { + "default": [], + "description": "Tags to set on the new sandbox, if any. Will not inherit tags from the source sandbox.", + "items": { "type": "string" }, + "type": "array" + }, + "title": { + "default": "[Template title]", + "description": "Template title. Maximum 255 characters. Defaults to title of original sandbox with (forked).", + "maxLength": 255, + "type": "string" + } + }, + "required": ["forkOf"], + "title": "TemplateCreateRequestCommon", + "type": "object" + }, "Sandbox": { "properties": { "created_at": { "format": "date-time", "type": "string" }, @@ -552,6 +582,7 @@ "properties": { "scopes": { "items": { "type": "string" }, "type": "array" }, "team": { "format": "uuid", "nullable": true, "type": "string" }, + "team_shortid": { "nullable": true, "type": "string" }, "version": { "type": "string" } }, "required": ["scopes", "team", "version"], @@ -2138,7 +2169,9 @@ "requestBody": { "content": { "application/json": { - "schema": { "$ref": "#/components/schemas/TemplateCreateRequest" } + "schema": { + "$ref": "#/components/schemas/TemplateCreateRequestCommon" + } } }, "description": "Template Create Request", diff --git a/src/api-clients/client/types.gen.ts b/src/api-clients/client/types.gen.ts index 4f1cad1..7027c8f 100644 --- a/src/api-clients/client/types.gen.ts +++ b/src/api-clients/client/types.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts export type ClientOptions = { - baseUrl: 'https://api.codesandbox.io' | (string & {}); + baseUrl: 'http://localhost' | (string & {}); }; /** @@ -69,28 +69,6 @@ export type VmAssignTagAliasResponse = { }; }; -/** - * TemplateCreateRequest - */ -export type TemplateCreateRequest = { - /** - * Template description. Maximum 255 characters. Defaults to description of original sandbox. - */ - description?: string; - /** - * Short ID of the sandbox to fork. - */ - forkOf: string; - /** - * Tags to set on the new sandbox, if any. Will not inherit tags from the source sandbox. - */ - tags?: Array; - /** - * Template title. Maximum 255 characters. Defaults to title of original sandbox with (forked). - */ - title?: string; -}; - /** * PreviewToken */ @@ -135,6 +113,53 @@ export type PreviewTokenRevokeAllResponse = { }; }; +/** + * TemplateCreateRequestCommon + */ +export type TemplateCreateRequestCommon = { + /** + * Template description. Maximum 255 characters. Defaults to description of original sandbox. + */ + description?: string; + /** + * Short ID of the sandbox to fork. + */ + forkOf: string; + /** + * Container image to use as template + */ + image?: { + /** + * The architecture of the image. Required for multi-platform images + */ + architecture?: string; + /** + * The image name (for example 'nginx'). + */ + name: string; + /** + * The container registry where the image is stored. + */ + registry?: string; + /** + * The repository or namespace where the image is stored. + */ + repository?: string; + /** + * The image tag. + */ + tag?: string; + }; + /** + * Tags to set on the new sandbox, if any. Will not inherit tags from the source sandbox. + */ + tags?: Array; + /** + * Template title. Maximum 255 characters. Defaults to title of original sandbox with (forked). + */ + title?: string; +}; + /** * Sandbox */ @@ -351,6 +376,7 @@ export type MetaInformation = { auth?: { scopes: Array; team: string | null; + team_shortid?: string | null; version: string; }; /** @@ -1287,7 +1313,7 @@ export type TemplatesCreateData = { /** * Template Create Request */ - body?: TemplateCreateRequest; + body?: TemplateCreateRequestCommon; path?: never; query?: never; url: '/templates'; diff --git a/src/bin/commands/build.ts b/src/bin/commands/build.ts index 8eb1b67..b7a39a1 100644 --- a/src/bin/commands/build.ts +++ b/src/bin/commands/build.ts @@ -26,6 +26,7 @@ import { buildDockerImage, prepareDockerBuild, pushDockerImage, + dockerLogin, } from "../utils/docker"; import { randomUUID } from "crypto"; @@ -653,8 +654,15 @@ export async function betaCodeSandboxBuild( const resolvedDirectory = path.resolve(argv.directory); + const metaInfo = await api.getMetaInfo(); + const teamShortId = metaInfo.data?.auth?.team_shortid; + + if (!teamShortId) { + throw new Error("Failed to fetch team information for for the provided CSB_API_KEY. Please ensure your API key is correct and has access to a team."); + } + const registry = getInferredRegistryUrl(); - const repository = "templates"; + const repository = teamShortId; const imageName = `image-${randomUUID().toLowerCase()}`; const tag = "latest"; const fullImageName = `${registry}/${repository}/${imageName}:${tag}`; @@ -712,6 +720,27 @@ export async function betaCodeSandboxBuild( } dockerBuildSpinner.succeed("Template Docker image built successfully."); + // Docker Login + const dockerLoginSpinner = ora({ stream: process.stdout }); + dockerLoginSpinner.start("Authenticating with CodeSandbox Docker registry..."); + try { + await dockerLogin({ + registry: registry, + username: "_token", + password: apiKey, + onOutput: (output: string) => { + const cleanOutput = stripAnsiCodes(output); + dockerLoginSpinner.text = `Authenticating with Docker registry: (${cleanOutput})`; + }, + }); + dockerLoginSpinner.succeed("Docker registry authentication successful."); + } catch (error) { + dockerLoginSpinner.fail( + `Failed to authenticate with Docker registry: ${(error as Error).message}` + ); + throw error; + } + // Push Docker Image const imagePushSpinner = ora({ stream: process.stdout }); imagePushSpinner.start("Pushing template Docker image to CodeSandbox..."); diff --git a/src/bin/utils/docker.ts b/src/bin/utils/docker.ts index e4ac90d..66c0d27 100644 --- a/src/bin/utils/docker.ts +++ b/src/bin/utils/docker.ts @@ -144,6 +144,72 @@ export async function buildDockerImage(options: DockerBuildOptions): Promise void; +}; + +export async function dockerLogin(options: DockerLoginOptions): Promise { + const { registry, username, password, onOutput = () => { } } = options; + + await new Promise((resolve, reject) => { + const args = ["login"]; + + if (registry) { + args.push(registry); + } + + args.push("--username", username, "--password-stdin"); + + const loginProcess = spawn("docker", args, { + stdio: ["pipe", "pipe", "pipe"], + }); + + // Write password to stdin + loginProcess.stdin?.write(password); + loginProcess.stdin?.end(); + + let outputBuffer = ""; + + loginProcess.stdout?.on("data", (data) => { + const output = data.toString(); + outputBuffer += output; + const lines = output.trim().split("\n"); + const lastLine = lines[lines.length - 1]; + if (lastLine) { + onOutput(lastLine); + } + }); + + loginProcess.stderr?.on("data", (data) => { + const output = data.toString(); + outputBuffer += output; + const lines = output.trim().split("\n"); + const lastLine = lines[lines.length - 1]; + if (lastLine) { + onOutput(lastLine); + } + }); + + loginProcess.on("close", (code) => { + if (code === 0) { + onOutput(`Docker login successful${registry ? ` to ${registry}` : ""}`); + resolve(); + } else { + reject( + new Error(`Docker login failed with exit code ${code}\n${outputBuffer}`) + ); + } + }); + + loginProcess.on("error", (error) => { + reject(new Error(`Docker login failed: ${error.message}`)); + }); + }); +} + export async function pushDockerImage(imageName: string, onOutput?: (output: string) => void): Promise { onOutput = onOutput || (() => { }); From b823d48e053b39f5b1bd268e77b5265666919823 Mon Sep 17 00:00:00 2001 From: Joji Augustine Date: Fri, 6 Feb 2026 15:20:32 +0100 Subject: [PATCH 22/46] Add feedback for docker login --- src/bin/commands/build.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/bin/commands/build.ts b/src/bin/commands/build.ts index b7a39a1..1cb0617 100644 --- a/src/bin/commands/build.ts +++ b/src/bin/commands/build.ts @@ -757,6 +757,9 @@ export async function betaCodeSandboxBuild( } imagePushSpinner.succeed("Template Docker image pushed to CodeSandbox."); + + const templateCreateSpinner = ora({ stream: process.stdout }); + templateCreateSpinner.start("Creating template with Docker image..."); // Create Template with Docker Image const templateData = await api.createTemplate({ forkOf: argv.fromSandbox || getDefaultTemplateId(api.getClient()), @@ -766,12 +769,13 @@ export async function betaCodeSandboxBuild( // @ts-ignore image: { registry: registry, - repository: "templates", + repository: repository, name: imageName, tag: "latest", architecture: architecture, }, }); + templateCreateSpinner.succeed("Template created with Docker image."); // Create a memory snapshot from the template sandboxes const templateBuildSpinner = ora({ stream: process.stdout }); From a9edffbe1e632cded7f4d448b674cedfb75007bf Mon Sep 17 00:00:00 2001 From: Joji Augustine Date: Fri, 6 Feb 2026 17:05:28 +0100 Subject: [PATCH 23/46] base32 encoded image --- src/bin/commands/build.ts | 9 ++++--- src/utils/encoding.ts | 54 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 src/utils/encoding.ts diff --git a/src/bin/commands/build.ts b/src/bin/commands/build.ts index 1cb0617..349208d 100644 --- a/src/bin/commands/build.ts +++ b/src/bin/commands/build.ts @@ -29,6 +29,7 @@ import { dockerLogin, } from "../utils/docker"; import { randomUUID } from "crypto"; +import { base32Encode } from "../../utils/encoding"; export type BuildCommandArgs = { directory: string; @@ -655,14 +656,16 @@ export async function betaCodeSandboxBuild( const resolvedDirectory = path.resolve(argv.directory); const metaInfo = await api.getMetaInfo(); - const teamShortId = metaInfo.data?.auth?.team_shortid; + const teamId = metaInfo.data?.auth?.team; - if (!teamShortId) { + if (!teamId) { throw new Error("Failed to fetch team information for for the provided CSB_API_KEY. Please ensure your API key is correct and has access to a team."); } + const base32EncodedTeamId = base32Encode(teamId); + const registry = getInferredRegistryUrl(); - const repository = teamShortId; + const repository = base32EncodedTeamId; const imageName = `image-${randomUUID().toLowerCase()}`; const tag = "latest"; const fullImageName = `${registry}/${repository}/${imageName}:${tag}`; diff --git a/src/utils/encoding.ts b/src/utils/encoding.ts new file mode 100644 index 0000000..1c37e9a --- /dev/null +++ b/src/utils/encoding.ts @@ -0,0 +1,54 @@ +/** + * Base32 encoding utilities following RFC 4648 standard + */ + +const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +/** + * Encodes a string to base32 (RFC 4648) + * @param input - The string to encode + * @param lowercase - Whether to return lowercase encoding (default: true) + * @param removePadding - Whether to remove padding characters (default: true) + * @returns Base32 encoded string + */ +export function base32Encode( + input: string, + lowercase: boolean = true, + removePadding: boolean = true +): string { + const buffer = Buffer.from(input, "utf-8"); + let bits = 0; + let value = 0; + let output = ""; + + for (let i = 0; i < buffer.length; i++) { + value = (value << 8) | buffer[i]; + bits += 8; + + while (bits >= 5) { + output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + + if (bits > 0) { + output += BASE32_ALPHABET[(value << (5 - bits)) & 31]; + } + + // Add padding + while (output.length % 8 !== 0) { + output += "="; + } + + // Remove padding if requested + if (removePadding) { + output = output.replace(/=+$/, ""); + } + + // Convert to lowercase if requested + if (lowercase) { + output = output.toLowerCase(); + } + + return output; +} From a5c8d2a8619bb056b0d5a5de1811ada530f6f3a0 Mon Sep 17 00:00:00 2001 From: Joji Augustine Date: Fri, 6 Feb 2026 17:09:06 +0100 Subject: [PATCH 24/46] fix short id and and url --- openapi.json | 1 - src/api-clients/client/types.gen.ts | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/openapi.json b/openapi.json index 3360326..ee14101 100644 --- a/openapi.json +++ b/openapi.json @@ -582,7 +582,6 @@ "properties": { "scopes": { "items": { "type": "string" }, "type": "array" }, "team": { "format": "uuid", "nullable": true, "type": "string" }, - "team_shortid": { "nullable": true, "type": "string" }, "version": { "type": "string" } }, "required": ["scopes", "team", "version"], diff --git a/src/api-clients/client/types.gen.ts b/src/api-clients/client/types.gen.ts index 7027c8f..33c88b5 100644 --- a/src/api-clients/client/types.gen.ts +++ b/src/api-clients/client/types.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts export type ClientOptions = { - baseUrl: 'http://localhost' | (string & {}); + baseUrl: 'https://api.codesandbox.io' | (string & {}); }; /** @@ -376,7 +376,6 @@ export type MetaInformation = { auth?: { scopes: Array; team: string | null; - team_shortid?: string | null; version: string; }; /** From b9b701d6a65acbd24b90410cc82c51cda09bf1c6 Mon Sep 17 00:00:00 2001 From: Joji Augustine Date: Fri, 6 Feb 2026 17:09:30 +0100 Subject: [PATCH 25/46] Apply suggestion from @fertapric-togetherai Co-authored-by: Fernando Tapia Rico --- src/bin/commands/build.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bin/commands/build.ts b/src/bin/commands/build.ts index 349208d..aaaaa8d 100644 --- a/src/bin/commands/build.ts +++ b/src/bin/commands/build.ts @@ -659,7 +659,7 @@ export async function betaCodeSandboxBuild( const teamId = metaInfo.data?.auth?.team; if (!teamId) { - throw new Error("Failed to fetch team information for for the provided CSB_API_KEY. Please ensure your API key is correct and has access to a team."); + throw new Error("Failed to fetch team information for the provided CSB_API_KEY. Please ensure your API key is correct and has access to a team."); } const base32EncodedTeamId = base32Encode(teamId); From ae84e2df94b8fc1dbc491078b6ac11cc382cfdd1 Mon Sep 17 00:00:00 2001 From: Joji Augustine Date: Fri, 6 Feb 2026 21:35:13 +0100 Subject: [PATCH 26/46] temporarily replace hibernate with shutdown for beta template builds --- src/bin/commands/build.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/bin/commands/build.ts b/src/bin/commands/build.ts index aaaaa8d..80d0c84 100644 --- a/src/bin/commands/build.ts +++ b/src/bin/commands/build.ts @@ -814,7 +814,9 @@ export async function betaCodeSandboxBuild( templateBuildSpinner.text = "Preparing template snapshot: Sandbox is ready. Creating snapshot..."; - await sdk.sandboxes.hibernate(sandboxId); + // TODO: Change back to hibernate once we fix hibernate resume with nydus + // await sdk.sandboxes.hibernate(sandboxId); + await sdk.sandboxes.shutdown(sandboxId); templateBuildSpinner.succeed("Template snapshot created."); } catch (error) { From f77aad84e9c3b6e82edd2d2b8ad1ab6f79e96ac5 Mon Sep 17 00:00:00 2001 From: Christian Alfoni Date: Mon, 9 Feb 2026 14:27:44 +0100 Subject: [PATCH 27/46] remove logs --- src/PintClient/execs.ts | 6 ----- src/PintClient/tasks.ts | 49 ++++++++++++++++++++++++++--------------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/src/PintClient/execs.ts b/src/PintClient/execs.ts index f72aff4..3137b6e 100644 --- a/src/PintClient/execs.ts +++ b/src/PintClient/execs.ts @@ -42,8 +42,6 @@ export class PintShellsClient implements IAgentClientShells { ) { const abortController = new AbortController(); - console.log("Subscribing to execs!"); - streamExecsList({ client: this.apiClient, signal: abortController.signal, @@ -51,9 +49,7 @@ export class PintShellsClient implements IAgentClientShells { headers: { Accept: "text/event-stream" }, }, }).then(async ({ stream }) => { - console.log("LIST STREAM READY"); for await (const evt of stream) { - console.log("Got list event"); const execListResponse = parseStreamEvent(evt); const execs = execListResponse.execs; const newExec = execs.find((exec) => exec.id === execId); @@ -159,9 +155,7 @@ export class PintShellsClient implements IAgentClientShells { Accept: "text/event-stream", }, }).then(async ({ stream }) => { - console.log("OUTPUT STREAM READY"); for await (const evt of stream) { - console.log("Got output event"); const data = parseStreamEvent<{ type: "stdout" | "stderr"; output: ""; diff --git a/src/PintClient/tasks.ts b/src/PintClient/tasks.ts index 2bdeb1a..21729e7 100644 --- a/src/PintClient/tasks.ts +++ b/src/PintClient/tasks.ts @@ -56,7 +56,9 @@ export class PintClientTasks implements IAgentClientTasks { return { tasks: {}, setupTasks: [], - validationErrors: [error instanceof Error ? error.message : "Unknown error"], + validationErrors: [ + error instanceof Error ? error.message : "Unknown error", + ], }; } } @@ -168,28 +170,39 @@ export class PintClientSetup implements IAgentClientSetup { if (response.data) { // Convert API setup tasks to setup progress format - const steps: setup.Step[] = response.data.setupTasks.map((setupTask) => ({ - name: setupTask.name, - command: setupTask.command, - shellId: setupTask.execId || null, - finishStatus: setupTask.status === 'FINISHED' ? 'SUCCEEDED' : - setupTask.status === 'ERROR' ? 'FAILED' : null, - })); + const steps: setup.Step[] = response.data.setupTasks.map( + (setupTask) => ({ + name: setupTask.name, + command: setupTask.command, + shellId: setupTask.execId || null, + finishStatus: + setupTask.status === "FINISHED" + ? "SUCCEEDED" + : setupTask.status === "ERROR" + ? "FAILED" + : null, + }) + ); // Determine overall state based on task statuses - let state: setup.SetupProgress['state'] = 'IDLE'; + let state: setup.SetupProgress["state"] = "IDLE"; let currentStepIndex = 0; - const hasRunningTask = response.data.setupTasks.some(task => task.status === 'RUNNING'); - const allFinished = response.data.setupTasks.every(task => - task.status === 'FINISHED' || task.status === 'ERROR'); + const hasRunningTask = response.data.setupTasks.some( + (task) => task.status === "RUNNING" + ); + const allFinished = response.data.setupTasks.every( + (task) => task.status === "FINISHED" || task.status === "ERROR" + ); if (hasRunningTask) { - state = 'IN_PROGRESS'; + state = "IN_PROGRESS"; // Find the first running task - currentStepIndex = response.data.setupTasks.findIndex(task => task.status === 'RUNNING'); + currentStepIndex = response.data.setupTasks.findIndex( + (task) => task.status === "RUNNING" + ); } else if (allFinished) { - state = 'FINISHED'; + state = "FINISHED"; currentStepIndex = steps.length - 1; } @@ -201,7 +214,7 @@ export class PintClientSetup implements IAgentClientSetup { } else { // Return empty setup progress if no data return { - state: 'IDLE', + state: "IDLE", steps: [], currentStepIndex: 0, }; @@ -209,7 +222,7 @@ export class PintClientSetup implements IAgentClientSetup { } catch (error) { console.error("Failed to get setup progress:", error); return { - state: 'IDLE', + state: "IDLE", steps: [], currentStepIndex: 0, }; @@ -235,4 +248,4 @@ export class PintClientSystem implements IAgentClientSystem { async update(): Promise> { return {}; } -} \ No newline at end of file +} From cffd36f1b2c8618d5c9e06c4bd4004231f891ae5 Mon Sep 17 00:00:00 2001 From: Christian Alfoni Date: Mon, 9 Feb 2026 14:32:19 +0100 Subject: [PATCH 28/46] remove more logs --- src/SandboxClient/commands.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/SandboxClient/commands.ts b/src/SandboxClient/commands.ts index 9fdd8a1..503c1cd 100644 --- a/src/SandboxClient/commands.ts +++ b/src/SandboxClient/commands.ts @@ -382,7 +382,6 @@ export class Command { this.tracer = tracer; if (shell.status === "RUNNING") { - console.log(this.shell.shellId, "Listening for output"); this.disposable.addDisposable( this.agentClient.shells.subscribeOutput( this.shell.shellId, From 061bf563d8563e35c316a654fe9eeb42b8459a3c Mon Sep 17 00:00:00 2001 From: Christian Alfoni Date: Tue, 10 Feb 2026 15:02:27 +0100 Subject: [PATCH 29/46] force template id --- tests/e2e/helpers.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 1d571f8..36cab99 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -3,12 +3,11 @@ import { CodeSandbox, Sandbox } from "../../src/index.js"; /** * Test template ID used across e2e tests */ -export const TEST_TEMPLATE_ID = - process.env.CSB_TEST_TEMPLATE_ID ?? - // Old infra on stream - "pt_FXCz5KGvDQsafzZz7awrSe"; +export const TEST_TEMPLATE_ID = process.env.CSB_TEST_TEMPLATE_ID; -export const USE_PINT = Boolean(process.env.USE_PINT ?? false); +if (!TEST_TEMPLATE_ID) { + throw new Error("You have to provide a test template id"); +} /** * Initialize SDK with API key from environment @@ -20,13 +19,15 @@ export function initializeSDK(): CodeSandbox { }); } + console.warn("No CSB_BASE_URL provided, defaulting to PRODUCTION"); + return new CodeSandbox(process.env.CSB_API_KEY, { - baseUrl: "https://api.codesandbox.stream", + baseUrl: "https://api.codesandbox.io", }); } export async function createSandbox(sdk: CodeSandbox) { - const templateId = TEST_TEMPLATE_ID; + const templateId = TEST_TEMPLATE_ID!; const tags = ["sdk"]; let path = "/e2e-tests"; @@ -35,9 +36,6 @@ export async function createSandbox(sdk: CodeSandbox) { tags, path, private_preview: false, - // This is just for testing, not official api - // @ts-ignore - use_pint: USE_PINT, }); const startResponse = await sdk.sandboxes["api"].startVm( From 5a19dfe8d0140c70c9cd09a702d5f38e95138dc9 Mon Sep 17 00:00:00 2001 From: Christian Alfoni Date: Fri, 13 Feb 2026 10:28:00 +0100 Subject: [PATCH 30/46] Pitcher finally running --- .env.example | 15 +++ .gitignore | 1 - README.md | 9 +- TODO.md | 112 ---------------- src/AgentClient/index.ts | 170 ++++++++++++++++++------- src/PintClient/execs.ts | 4 + src/SandboxClient/commands.ts | 42 +++--- src/SandboxClient/filesystem.ts | 5 +- src/SandboxClient/terminals.ts | 53 ++++---- src/agent-client-interface.ts | 1 + test-template/.codesandbox/Dockerfile | 12 ++ test-template/.codesandbox/tasks.json | 7 + tests/e2e/helpers.ts | 39 +++++- tests/e2e/sandbox-apis.test.ts | 68 +++------- tests/e2e/sandbox-commands.test.ts | 66 ++-------- tests/e2e/sandbox-filesystem.test.ts | 94 +++----------- tests/e2e/sandbox-hosts.test.ts | 51 ++------ tests/e2e/sandbox-interpreters.test.ts | 52 ++------ tests/e2e/sandbox-lifecycle.test.ts | 31 ++--- tests/e2e/sandbox-ports.test.ts | 46 +------ tests/e2e/sandbox-setup.test.ts | 49 ++----- tests/e2e/sandbox-tasks.test.ts | 52 ++------ tests/e2e/sandbox-terminals.test.ts | 61 ++------- 23 files changed, 386 insertions(+), 654 deletions(-) create mode 100644 .env.example delete mode 100644 TODO.md create mode 100644 test-template/.codesandbox/Dockerfile create mode 100644 test-template/.codesandbox/tasks.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3980f23 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# API KEYS +export CSB_API_KEY=... # Production +# export CSB_API_KEY=... # Stream + +# BASE URLS +export CSB_BASE_URL=https://api.codesandbox.io # Production +# export CSB_BASE_URL=https://api.codesandbox.stream # Stream + +# TEMPLATES +export CSB_TEMPLATE_ID=... # Production (Pitcher) +# export CSB_TEMPLATE_ID=... # Production (Pint) +# export CSB_TEMPLATE_ID=... # Stream (Pitcher) +# export CSB_TEMPLATE_ID=... # Stream (Pint) + + diff --git a/.gitignore b/.gitignore index 68e2089..4bd75d5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ # Generated stuff dist test.ts -test-template ### macOS ### *.DS_Store diff --git a/README.md b/README.md index 2706958..8d5d4e3 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,12 @@ console.log(output); // Hello World ### E2E local - Clone the sandbox templates repo (https://github.com/codesandbox/sandbox-templates) -- Build template with `csb build ../sandbox-templates/nextjs` -- Run e2e tests with `CSB_BASE_URL=https://api.codesandbox.dev CSB_TEMPLATE_ID=$NEXTJS_TEMPLATE_ID npm run test:e2e` -- Run specific test file `CSB_BASE_URL=https://api.codesandbox.dev CSB_TEMPLATE_ID=$NEXTJS_TEMPLATE_ID npm run test -- filesystem` +- Create the `.env` based on example and populate it +- Run `source .env` to export the env variables +- Build template for Pitcher `./dist/bin/codesandbox.mjs build ./test-template` +- Build template for Pint `./dist/bin/codesandbox.mjs build ./test-template --beta` +- Run e2e tests with `npm run test:e2e` +- Run specific test file `npm run test -- filesystem` ## Efficient Sandbox Retrieval diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 7272249..0000000 --- a/TODO.md +++ /dev/null @@ -1,112 +0,0 @@ -## QUESTIONS - -- Should Snapshot Tags work like NPM? - - - Create Sandbox with no wakeup config - - Write files - - Wait for condition - - Hibernate - - New endpoint to tag it - - Sandbox is tagged (Check if hibernated?) - - BIG QUESTION: Should we force prevent starting the Sandbox? What about TanStack - - - New endpoint to create an alias to any tag - - - Change endpoint for Sandbox creation to allow tags/aliases as id - - - What is Tag / Alias format? - -## USER QUESTIONS - -## TODO - -- Highlight snapshot building in docs -- https://github.com/codesandbox/codesandbox-applications/pull/4645 -- Publish browser-static-server - -# 1 New API - -```ts -const sdk = new CodeSandbox(apiToken); - -const sandbox = await sdk.sandbox.resume(id); -const sandbox = await sdk.sandbox.create(SandboxOptions & StartOptions); - -sandbox.isUpToDate; -sandbox.bootupType; -sandbox.cluster; -sandbox.connect(); -sandbox.createBrowserSession(); -sandbox.createRestClient(); -sandbox.updateTier(); -sandbox.updateHibernationTimeout(); - -sdk.sandbox.shutdown(id); -sdk.sandbox.previewTokens.create(id); - -const session = await sandbox.createBrowserSession(); -const client = sandbox.connect(); -const client = sandbox.createRestClient(); -``` - -# 2 Git clone support - -```ts -// Factory.ai -// Create base template -// const sbx = await sdk.sandbox.create(); -// sbx.git.clone(); -// -> /project/sandbox -// git set-remote origin ... -// git pull - -// /project/sandbox/.git -> /persisted/.git - -/** - * sdk.sandbox.create({ source: { - * type: 'git', - * url: 'https://github.com/sandbox-git/sandbox-git.git', - * branch: 'main', - * gitAccessToken: '...' - * } }) - * 1. create sandbox - * 2. ... - * 3. clone - * - * // Source = Dropbox - * // Source = Zip - * - * - * // API create zip - * - * sandbox.create({ - * source: { - * type: 'zip', - * url: 'https://example.com/my-zip-file.zip' - * } - * }); - */ - -await sandbox.git.clone({ - url: "https://github.com/sandbox-git/sandbox-git.git", - branch: "main", -}); - -// rm -rf /project/sandbox/* -// - -await sandbox.git.pull(); -await sandbox.git.checkout("main"); - -// -``` - -# 3 Snapshot Tagging - -```ts -sdk.sandbox.create({ - files: {}, -}); -``` - -# Export types properly diff --git a/src/AgentClient/index.ts b/src/AgentClient/index.ts index b59308e..95ba348 100644 --- a/src/AgentClient/index.ts +++ b/src/AgentClient/index.ts @@ -34,9 +34,70 @@ let PONG_DETECTION_TIMEOUT = 30_000; // When focusing the app we do a lower timeout to more quickly detect a potential disconnect const FOCUS_PONG_DETECTION_TIMEOUT = 5_000; +type ShellStateChangeEvent = + | { type: "out"; out: string } + | { type: "exit"; exitCode: number }; + +class ShellState { + private listener?: (event: ShellStateChangeEvent) => void; + // The buffer is populated when there is no listener yet, ensuring that we capture + // all output and return it + private buffer: string[] = []; + private exitCode?: number; + getBuffer() { + const bufferString = this.buffer.join("\n"); + this.buffer.length = 0; + return bufferString; + } + getExitCode() { + return this.exitCode; + } + addOut(out: string) { + if (!this.listener) { + this.buffer.push(out); + return; + } + + this.listener({ + type: "out", + out, + }); + } + setExitCode(exitCode: number) { + this.exitCode = exitCode; + + if (!this.listener) { + return; + } + + this.listener({ + type: "exit", + exitCode, + }); + } + onChange(listener: (event: ShellStateChangeEvent) => void) { + this.listener = listener; + + if (this.buffer.length) { + const bufferString = this.buffer.join("\n"); + this.buffer.length = 0; + listener({ + type: "out", + out: bufferString, + }); + } + + return () => { + this.listener = undefined; + }; + } +} + class AgentClientShells implements IAgentClientShells { disposeOutputListener: () => void; - private shellOutputs: Record = {}; + disposeExitListener: () => void; + disposeTerminateListener: () => void; + private shellStates: Record = {}; constructor(private agentConnection: AgentConnection) { // We use a common listener to keep track of all shell output to avoid race conditions. These // are then flushed. This does not work with multiple listeners, but you would not use multiple @@ -44,11 +105,31 @@ class AgentClientShells implements IAgentClientShells { this.disposeOutputListener = agentConnection.onNotification( "shell/out", (event) => { - if (!this.shellOutputs[event.shellId]) { - this.shellOutputs[event.shellId] = []; + if (!this.shellStates[event.shellId]) { + this.shellStates[event.shellId] = new ShellState(); } - this.shellOutputs[event.shellId].push(event.out); + this.shellStates[event.shellId].addOut(event.out); + } + ); + this.disposeExitListener = agentConnection.onNotification( + "shell/exit", + (event) => { + if (!this.shellStates[event.shellId]) { + this.shellStates[event.shellId] = new ShellState(); + } + + this.shellStates[event.shellId].setExitCode(event.exitCode); + } + ); + this.disposeTerminateListener = agentConnection.onNotification( + "shell/terminate", + (event) => { + if (!this.shellStates[event.shellId]) { + this.shellStates[event.shellId] = new ShellState(); + } + + this.shellStates[event.shellId].setExitCode(130); } ); } @@ -59,6 +140,7 @@ class AgentClientShells implements IAgentClientShells { type, isSystemShell, projectPath, + cwd, }: { command: string; args: string[]; @@ -66,15 +148,20 @@ class AgentClientShells implements IAgentClientShells { size: shell.ShellSize; type?: shell.ShellProcessType; isSystemShell?: boolean; + cwd?: string; }): Promise { + // Pitcher protocol expects a single command string, so we concatenate command and args + const fullCommand = + args.length > 0 ? `${command} ${args.join(" ")}` : command; + return this.agentConnection.request({ method: "shell/create", params: { - command: command + args.join(""), + command: fullCommand, size, type, isSystemShell, - cwd: projectPath, + cwd: cwd || projectPath, }, }); } @@ -133,9 +220,38 @@ class AgentClientShells implements IAgentClientShells { listener: (event: { out: string; exitCode?: number }) => void ): IDisposable { const disposable = new Disposable(); - let disposeOut: () => void; - let disposeExit: () => void; + if (!this.shellStates[shellId]) { + this.shellStates[shellId] = new ShellState(); + } + + const shellState = this.shellStates[shellId]; + + if (shellState.getExitCode() !== undefined) { + listener({ + out: shellState.getBuffer(), + exitCode: shellState.getExitCode(), + }); + + return disposable; + } + + const disposeChangeListener = shellState.onChange((event) => { + if (event.type === "out") { + listener({ + out: event.out, + }); + } else { + listener({ + out: shellState.getBuffer(), + exitCode: event.exitCode, + }); + } + }); + + disposable.onWillDispose(disposeChangeListener); + + // If subscribing to existing shell we need to open it to get events this.agentConnection .request({ method: "shell/open", @@ -149,40 +265,9 @@ class AgentClientShells implements IAgentClientShells { out: openShell.buffer.join("\n"), exitCode: openShell.exitCode, }); - - if (typeof openShell.exitCode === "number") { - return; - } - - disposeOut = this.agentConnection.onNotification( - "shell/out", - (params) => { - if (params.shellId === shellId) { - listener({ out: params.out, exitCode: openShell.exitCode }); - } - } - ); - disposeExit = this.agentConnection.onNotification( - "shell/exit", - (params) => { - if (params.shellId === shellId) { - listener({ out: "", exitCode: params.exitCode }); - } - } - ); }) .catch(() => { - // Pitcher requires a global shell listener for output to avoid race conditions. When running commands the shell can close - // before we get the output, so this just flushes the output gotten in between creating and subscribing - listener({ - out: this.shellOutputs[shellId] - ? this.shellOutputs[shellId].join("") - : "", - // We give a fake exit code, because pint gives an exit code on last event... but we do not know the exit code as the - // shell is already gone - exitCode: -1, - }); - this.shellOutputs[shellId].length = 0; + // The shell does not exist }); disposable.onDidDispose(() => { @@ -197,9 +282,6 @@ class AgentClientShells implements IAgentClientShells { .catch(() => { // We do not care }); - - disposeOut?.(); - disposeExit?.(); }); return disposable; @@ -580,6 +662,8 @@ export class AgentClient implements IAgentClient { } dispose() { this.shells.disposeOutputListener(); + this.shells.disposeExitListener(); + this.shells.disposeTerminateListener(); this.agentConnection.dispose(); } } diff --git a/src/PintClient/execs.ts b/src/PintClient/execs.ts index 3137b6e..3222069 100644 --- a/src/PintClient/execs.ts +++ b/src/PintClient/execs.ts @@ -97,6 +97,7 @@ export class PintShellsClient implements IAgentClientShells { projectPath, size, type, + cwd, }: { command: string; args: string[]; @@ -104,6 +105,7 @@ export class PintShellsClient implements IAgentClientShells { size: ShellSize; type?: ShellProcessType; isSystemShell?: boolean; + cwd?: string; }): Promise { const exec = await createExec({ client: this.apiClient, @@ -111,6 +113,8 @@ export class PintShellsClient implements IAgentClientShells { args, command, interactive: type === "COMMAND" ? false : true, + // @ts-expect-error - cwd support will be added to Pint API shortly + cwd: cwd || projectPath, }, }); diff --git a/src/SandboxClient/commands.ts b/src/SandboxClient/commands.ts index 503c1cd..e71f410 100644 --- a/src/SandboxClient/commands.ts +++ b/src/SandboxClient/commands.ts @@ -41,7 +41,7 @@ export class CommandError extends Error { output: string; constructor(message: string, exitCode: number, output: string) { - super(message); + super(message + " " + output); this.name = "CommandError"; this.exitCode = exitCode; this.output = output; @@ -118,27 +118,30 @@ export class SandboxCommands { const escapedCommand = command.replace(/'/g, "'\\''"); - // TODO: use a new shell API that natively supports cwd & env - let commandWithEnv = Object.keys(passedEnv).length - ? `source $HOME/.private/.env 2>/dev/null || true && env ${Object.entries( - passedEnv - ) - .map(([key, value]) => { - const escapedValue = String(value).replace(/'/g, "'\\''"); - return `${key}='${escapedValue}'`; - }) - .join(" ")} bash -c '${escapedCommand}'` - : `source $HOME/.private/.env 2>/dev/null || true && bash -c '${escapedCommand}'`; + // Build bash args array + const args = ["source $HOME/.private/.env 2>/dev/null || true"]; + // Add cd command if cwd is specified (Pitcher doesn't support cwd parameter) if (opts?.cwd) { - commandWithEnv = `cd ${opts.cwd} && ${commandWithEnv}`; + args.push("&&", "cd", opts.cwd); + } + + if (Object.keys(passedEnv).length) { + args.push("&&", "env"); + Object.entries(passedEnv).forEach(([key, value]) => { + const escapedValue = String(value).replace(/'/g, "'\\''"); + args.push(`${key}='${escapedValue}'`); + }); + args.push("bash", "-c", `'${escapedCommand}'`); + } else { + args.push("&&", "bash", "-c", `'${escapedCommand}'`); } const shell = await this.agentClient.shells.create({ projectPath: this.agentClient.workspacePath, size: opts?.dimensions ?? DEFAULT_SHELL_SIZE, - command: commandWithEnv, - args: [], + command: "bash", + args: ["-c", args.join(" ")], type: opts?.asGlobalSession ? "COMMAND" : "TERMINAL", isSystemShell: true, }); @@ -208,6 +211,7 @@ export class SandboxCommands { }); } + // Add cd command if cwd is specified (Pitcher doesn't support cwd parameter) if (opts?.cwd) { args.push("&&", "cd", opts.cwd); } @@ -381,6 +385,11 @@ export class Command { this.name = details.name; this.tracer = tracer; + // This only happens on Pitcher, Pint will listen to output from lastSequene=0 + if (shell.buffer) { + this.output = shell.buffer; + } + if (shell.status === "RUNNING") { this.disposable.addDisposable( this.agentClient.shells.subscribeOutput( @@ -393,6 +402,7 @@ export class Command { this.status = event.exitCode === 0 ? "FINISHED" : "ERROR"; this.barrier.open(); } else if (typeof event.exitCode === "number") { + this.exitCode = event.exitCode; this.status = "KILLED"; this.barrier.open(); } @@ -519,7 +529,7 @@ export class Command { } throw new CommandError( - `Command failed with exit code ${this.exitCode ?? "unknown"}`, + `Command failed with exit code ${this.exitCode ?? "unknown"}.`, this.exitCode ?? 1, cleaned ); diff --git a/src/SandboxClient/filesystem.ts b/src/SandboxClient/filesystem.ts index cb33ef4..e8dfcf3 100644 --- a/src/SandboxClient/filesystem.ts +++ b/src/SandboxClient/filesystem.ts @@ -188,11 +188,10 @@ export class FileSystem { projectPath: this.agentClient.workspacePath, size: { cols: 128, rows: 24 }, command: "bash", - args: [ - `cd ${this.agentClient.workspacePath} && unzip -o ${tempZipPath}`, - ], + args: ["-c", `unzip -o ${tempZipPath}`], type: "COMMAND", isSystemShell: true, + cwd: this.agentClient.workspacePath, }); if (result.status === "ERROR" || result.status === "KILLED") { diff --git a/src/SandboxClient/terminals.ts b/src/SandboxClient/terminals.ts index ed6ab2c..2e40bc0 100644 --- a/src/SandboxClient/terminals.ts +++ b/src/SandboxClient/terminals.ts @@ -10,6 +10,14 @@ export type ShellSize = { cols: number; rows: number }; export const DEFAULT_SHELL_SIZE: ShellSize = { cols: 128, rows: 24 }; +function resolveCwd(workspacePath: string, cwd?: string): string | undefined { + if (!cwd) return undefined; + // Strip leading slash to ensure cwd is always relative to workspace + const relativeCwd = cwd.startsWith("/") ? cwd.slice(1) : cwd; + // Join with workspace path + return `${workspacePath}/${relativeCwd}`.replace(/\/+/g, "/"); +} + export class Terminals { private disposable = new Disposable(); private tracer?: Tracer; @@ -64,24 +72,30 @@ export class Terminals { ) { const allEnv = Object.assign(opts?.env ?? {}); - // TODO: use a new shell API that natively supports cwd & env - let commandWithEnv = Object.keys(allEnv).length - ? `source $HOME/.private/.env 2>/dev/null || true && env ${Object.entries( - allEnv - ) - .map(([key, value]) => `${key}=${value}`) - .join(" ")} ${command}` - : `source $HOME/.private/.env 2>/dev/null || true && ${command}`; + // Build the command args array + const args = ["source $HOME/.private/.env 2>/dev/null || true"]; + + // Add cd command if cwd is specified (Pitcher doesn't support cwd parameter) + const resolvedCwd = resolveCwd(this.agentClient.workspacePath, opts?.cwd); + if (resolvedCwd && resolvedCwd !== this.agentClient.workspacePath) { + args.push("&&", "cd", resolvedCwd); + } - if (opts?.cwd) { - commandWithEnv = `cd ${opts.cwd} && ${commandWithEnv}`; + if (Object.keys(allEnv).length) { + args.push("&&", "env"); + Object.entries(allEnv).forEach(([key, value]) => { + args.push(`${key}=${value}`); + }); + args.push(command); + } else { + args.push("&&", command); } const shell = await this.agentClient.shells.create({ projectPath: this.agentClient.workspacePath, size: opts?.dimensions ?? DEFAULT_SHELL_SIZE, - command: commandWithEnv, - args: [], + command: "bash", + args: ["-c", args.join(" ")], type: "TERMINAL", isSystemShell: true, }); @@ -122,26 +136,17 @@ export class Terminals { }); } - if (opts?.cwd) { - args.push("&&", "cd", opts.cwd); - } - const shell = await this.agentClient.shells.create({ projectPath: this.agentClient.workspacePath, size: opts?.dimensions ?? DEFAULT_SHELL_SIZE, command, - args: this.agentClient.type === "pint" ? [] : args, + args, type: "TERMINAL", isSystemShell: true, + cwd: resolveCwd(this.agentClient.workspacePath, opts?.cwd), }); - const terminal = new Terminal(shell, this.agentClient, this.tracer); - - if (this.agentClient.type === "pint") { - await terminal.write(args.join(" ") + "\n"); - } - - return terminal; + return new Terminal(shell, this.agentClient, this.tracer); } ); } diff --git a/src/agent-client-interface.ts b/src/agent-client-interface.ts index 2bcc33d..702e602 100644 --- a/src/agent-client-interface.ts +++ b/src/agent-client-interface.ts @@ -29,6 +29,7 @@ export interface IAgentClientShells { size: shell.ShellSize; type?: shell.ShellProcessType; isSystemShell?: boolean; + cwd?: string; }): Promise; rename(shellId: shell.ShellId, name: string): Promise; getShells(): Promise; diff --git a/test-template/.codesandbox/Dockerfile b/test-template/.codesandbox/Dockerfile new file mode 100644 index 0000000..89ad31b --- /dev/null +++ b/test-template/.codesandbox/Dockerfile @@ -0,0 +1,12 @@ +# PITCHER +FROM ghcr.io/codesandbox/devcontainers/universal:latest + + +# PINT +#FROM node:22-bookworm + +#RUN apt-get update && apt-get install -y git zsh curl && rm -rf /var/lib/apt/lists/* + +#RUN corepack enable + +#WORKDIR /workspace/project \ No newline at end of file diff --git a/test-template/.codesandbox/tasks.json b/test-template/.codesandbox/tasks.json new file mode 100644 index 0000000..e95b3dc --- /dev/null +++ b/test-template/.codesandbox/tasks.json @@ -0,0 +1,7 @@ +{ + // These tasks will run in order when initializing your CodeSandbox project. + "setupTasks": [""], + + // These tasks can be run from CodeSandbox. Running one will open a log in the app. + "tasks": {} +} diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 36cab99..fda37ae 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -1,14 +1,51 @@ +import { afterAll, beforeAll } from "vitest"; import { CodeSandbox, Sandbox } from "../../src/index.js"; /** * Test template ID used across e2e tests */ -export const TEST_TEMPLATE_ID = process.env.CSB_TEST_TEMPLATE_ID; +export const TEST_TEMPLATE_ID = process.env.CSB_TEMPLATE_ID; if (!TEST_TEMPLATE_ID) { throw new Error("You have to provide a test template id"); } +export function createTest() { + const test = {} as { + sdk: CodeSandbox; + sandbox: Sandbox; + }; + + beforeAll(async () => { + test.sdk = initializeSDK(); + + // Create a sandbox for testing + test.sandbox = await createSandbox(test.sdk); + }); + + afterAll(async () => { + // Shutdown and deletion can take more than 10 seconds, we prevent this using a timeout + await Promise.race([ + new Promise((resolve) => setTimeout(resolve, 9900)), + deleteSandbox(test.sdk, test.sandbox.id), + ]); + }, 10_000); + + return test; +} + +async function deleteSandbox(sdk: CodeSandbox, sandboxId: string) { + try { + await sdk.sandboxes.shutdown(sandboxId!); + await sdk.sandboxes.delete(sandboxId!); + } catch { + // Try to force delete even if shutdown fails + try { + await sdk.sandboxes.delete(sandboxId!); + } catch {} + } +} + /** * Initialize SDK with API key from environment */ diff --git a/tests/e2e/sandbox-apis.test.ts b/tests/e2e/sandbox-apis.test.ts index 9e979b6..3dcf56a 100644 --- a/tests/e2e/sandbox-apis.test.ts +++ b/tests/e2e/sandbox-apis.test.ts @@ -1,85 +1,51 @@ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { CodeSandbox } from "../../src/index.js"; -import { initializeSDK, retryUntil, createSandbox } from "./helpers.js"; +import { describe, it, expect } from "vitest"; +import { retryUntil, createTest } from "./helpers.js"; describe("Sandbox APIs", () => { - let sdk: CodeSandbox; - let sandboxId: string | undefined; - - beforeAll(async () => { - sdk = initializeSDK(); - - // Create a sandbox for testing - const sandbox = await createSandbox(sdk); - sandboxId = sandbox.id; - }); - - afterAll(async () => { - // Cleanup: shutdown and delete the sandbox - if (sandboxId) { - try { - await sdk.sandboxes.shutdown(sandboxId); - await sdk.sandboxes.delete(sandboxId); - } catch (error) { - console.error("Failed to cleanup test sandbox:", sandboxId, error); - // Try to force delete even if shutdown fails - try { - await sdk.sandboxes.delete(sandboxId); - } catch (deleteError) { - console.error( - "Failed to force delete sandbox:", - sandboxId, - deleteError - ); - } - } - } - }); + const test = createTest(); it("should find sandbox in list", async () => { - expect(sandboxId).toBeDefined(); - if (!sandboxId) throw new Error("Sandbox not created"); + expect(test.sandbox.id).toBeDefined(); - const sandboxes = await sdk.sandboxes.list(); + const sandboxes = await test.sdk.sandboxes.list({ limit: 10 }); expect(sandboxes).toBeDefined(); expect(sandboxes.sandboxes).toBeDefined(); - const found = sandboxes.sandboxes.find((s) => s.id === sandboxId); + const found = sandboxes.sandboxes.find((s) => s.id === test.sandbox.id); expect(found).toBeDefined(); }); it("should find sandbox in running list by filter", async () => { - expect(sandboxId).toBeDefined(); - if (!sandboxId) throw new Error("Sandbox not created"); + expect(test.sandbox.id).toBeDefined(); const foundInList = await retryUntil(60000, 3000, async () => { - const runningSandboxesByFilter = await sdk.sandboxes.list({ + const runningSandboxesByFilter = await test.sdk.sandboxes.list({ status: "running", }); - return runningSandboxesByFilter.sandboxes.find((s) => s.id === sandboxId); + return runningSandboxesByFilter.sandboxes.find( + (s) => s.id === test.sandbox.id + ); }); expect(foundInList).toBeDefined(); }, 70000); it("should find sandbox in running list by API", async () => { - expect(sandboxId).toBeDefined(); - if (!sandboxId) throw new Error("Sandbox not created"); + expect(test.sandbox.id).toBeDefined(); const foundByAPI = await retryUntil(60000, 3000, async () => { - const runningSandboxByAPI = await sdk.sandboxes.listRunning(); - return runningSandboxByAPI.vms.find((s) => s.id === sandboxId); + const runningSandboxByAPI = await test.sdk.sandboxes.listRunning(); + return runningSandboxByAPI.vms.find((s) => s.id === test.sandbox.id); }); expect(foundByAPI).toBeDefined(); }, 70000); it("should get sandbox by ID", async () => { - expect(sandboxId).toBeDefined(); - if (!sandboxId) throw new Error("Sandbox not created"); + expect(test.sandbox.id).toBeDefined(); - const fetchedSandbox = await sdk.sandboxes.get(sandboxId); + const fetchedSandbox = await test.sdk.sandboxes.get(test.sandbox.id); expect(fetchedSandbox).toBeDefined(); - expect(fetchedSandbox.id).toBe(sandboxId); + expect(fetchedSandbox.id).toBe(test.sandbox.id); }); }); diff --git a/tests/e2e/sandbox-commands.test.ts b/tests/e2e/sandbox-commands.test.ts index cc0f2c0..edf69f4 100644 --- a/tests/e2e/sandbox-commands.test.ts +++ b/tests/e2e/sandbox-commands.test.ts @@ -1,27 +1,17 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { CodeSandbox } from "../../src/index.js"; -import { Sandbox } from "../../src/Sandbox.js"; import { SandboxClient } from "../../src/SandboxClient/index.js"; -import { createSandbox, initializeSDK } from "./helpers.js"; +import { createTest } from "./helpers.js"; describe("Sandbox Commands", () => { - let sdk: CodeSandbox; - let sandbox: Sandbox | undefined; + const test = createTest(); let client: SandboxClient | undefined; beforeAll(async () => { - sdk = initializeSDK(); - - // Create a sandbox for testing - sandbox = await createSandbox(sdk); - // Connect to sandbox - client = await sandbox.connect(); + client = await test.sandbox.connect(); }, 60000); afterAll(async () => { - const sandboxId = sandbox?.id; - try { if (client) { await client.disconnect(); @@ -31,38 +21,18 @@ describe("Sandbox Commands", () => { } catch (error) { console.error("Failed to dispose client:", error); } - - if (sandboxId) { - try { - await sdk.sandboxes.shutdown(sandboxId); - await sdk.sandboxes.delete(sandboxId); - } catch (error) { - console.error("Failed to cleanup test sandbox:", sandboxId, error); - try { - await sdk.sandboxes.delete(sandboxId); - } catch (deleteError) { - console.error( - "Failed to force delete sandbox:", - sandboxId, - deleteError - ); - } - } - } }); describe("Command execution", () => { it("should run a simple command and get output", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const output = await client.commands.run('echo "Hello from sandbox"'); expect(output).toContain("Hello from sandbox"); }); it("should get output from pwd command", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const output = await client.commands.run("pwd"); expect(output).toBeTruthy(); @@ -70,8 +40,7 @@ describe("Sandbox Commands", () => { }); it("should run multiple commands sequentially", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const output1 = await client.commands.run('echo "first"'); const output2 = await client.commands.run('echo "second"'); @@ -83,8 +52,7 @@ describe("Sandbox Commands", () => { }); it("should run multiple commands with array syntax", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); // Array of commands should be joined with && const output = await client.commands.run([ @@ -101,8 +69,7 @@ describe("Sandbox Commands", () => { describe("Background commands", () => { it("should run command in background", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const command = await client.commands.runBackground( 'sleep 1 && echo "done"' @@ -115,9 +82,8 @@ describe("Sandbox Commands", () => { expect(output).toContain("done"); }, 10000); - it.only("should run multiple commands in background with array syntax", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + it("should run multiple commands in background with array syntax", async () => { + if (!client) throw new Error("Client not initialized"); // Array of commands should be joined with && const command = await client.commands.runBackground([ @@ -136,8 +102,7 @@ describe("Sandbox Commands", () => { }, 10000); it("should be able to kill background command", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const command = await client.commands.runBackground("sleep 30"); expect(command).toBeDefined(); @@ -151,8 +116,7 @@ describe("Sandbox Commands", () => { describe("Command listing", () => { it("should get all commands", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const commands = await client.commands.getAll(); expect(Array.isArray(commands)).toBe(true); @@ -161,8 +125,7 @@ describe("Sandbox Commands", () => { describe("Working directory", () => { it("should run command in specified directory", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); // Create a test directory await client.fs.mkdir("/test-cwd"); @@ -177,8 +140,7 @@ describe("Sandbox Commands", () => { describe("Environment variables", () => { it("should run command with custom environment variables", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const output = await client.commands.run("echo $TEST_VAR", { env: { TEST_VAR: "custom_value" }, diff --git a/tests/e2e/sandbox-filesystem.test.ts b/tests/e2e/sandbox-filesystem.test.ts index a9a44a8..29954fd 100644 --- a/tests/e2e/sandbox-filesystem.test.ts +++ b/tests/e2e/sandbox-filesystem.test.ts @@ -1,32 +1,17 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { CodeSandbox } from "../../src/index.js"; -import { Sandbox } from "../../src/Sandbox.js"; import { SandboxClient } from "../../src/SandboxClient/index.js"; -import { initializeSDK, TEST_TEMPLATE_ID } from "./helpers.js"; +import { createTest } from "./helpers.js"; describe("Sandbox Filesystem", () => { - let sdk: CodeSandbox; - let sandbox: Sandbox | undefined; + const test = createTest(); let client: SandboxClient | undefined; beforeAll(async () => { - sdk = initializeSDK(); - - // Create a sandbox for testing - /* - sandbox = await sdk.sandboxes.create({ - id: TEST_TEMPLATE_ID, - }); - */ - sandbox = await sdk.sandboxes.resume("7s847p"); - // Connect to sandbox - client = await sandbox.connect(); + client = await test.sandbox.connect(); }, 60000); afterAll(async () => { - const sandboxId = sandbox?.id; - try { if (client) { await client.disconnect(); @@ -36,32 +21,11 @@ describe("Sandbox Filesystem", () => { } catch (error) { console.error("Failed to dispose client:", error); } - - /* - if (sandboxId) { - try { - await sdk.sandboxes.shutdown(sandboxId); - await sdk.sandboxes.delete(sandboxId); - } catch (error) { - console.error("Failed to cleanup test sandbox:", sandboxId, error); - try { - await sdk.sandboxes.delete(sandboxId); - } catch (deleteError) { - console.error( - "Failed to force delete sandbox:", - sandboxId, - deleteError - ); - } - } - } - */ }); describe("File operations", () => { it("should write and read a file", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); await client.fs.writeTextFile("/test-file.txt", "Hello, Sandbox!"); const fileContent = await client.fs.readTextFile("/test-file.txt"); @@ -70,8 +34,7 @@ describe("Sandbox Filesystem", () => { }); it("should list files in directory", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const files = await client.fs.readdir("/"); @@ -83,8 +46,7 @@ describe("Sandbox Filesystem", () => { }); it("should delete a file", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); await client.fs.remove("/test-file.txt"); @@ -98,8 +60,7 @@ describe("Sandbox Filesystem", () => { describe("Directory operations", () => { it("should create a directory", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); await client.fs.mkdir("/test-dir"); @@ -111,8 +72,7 @@ describe("Sandbox Filesystem", () => { }); it("should delete a directory", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); await client.fs.remove("/test-dir"); @@ -124,8 +84,7 @@ describe("Sandbox Filesystem", () => { describe("Binary file operations", () => { it("should write and read binary files", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const binaryData = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello" in bytes await client.fs.writeFile("/test-binary.bin", binaryData); @@ -141,8 +100,7 @@ describe("Sandbox Filesystem", () => { describe("File stat operations", () => { it("should get file stats", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); await client.fs.writeTextFile("/stat-test.txt", "test content"); @@ -156,8 +114,7 @@ describe("Sandbox Filesystem", () => { }); it("should get directory stats", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); await client.fs.mkdir("/stat-dir"); @@ -172,8 +129,7 @@ describe("Sandbox Filesystem", () => { describe("Copy operations", () => { it("should copy a file", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); await client.fs.writeTextFile("/copy-source.txt", "copy test"); await client.fs.copy("/copy-source.txt", "/copy-dest.txt"); @@ -187,8 +143,7 @@ describe("Sandbox Filesystem", () => { }); it("should copy a directory recursively", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); await client.fs.mkdir("/copy-dir"); await client.fs.writeTextFile("/copy-dir/file.txt", "nested file"); @@ -205,8 +160,7 @@ describe("Sandbox Filesystem", () => { describe("Rename operations", () => { it("should rename a file", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); await client.fs.writeTextFile("/rename-old.txt", "rename test"); await client.fs.rename("/rename-old.txt", "/rename-new.txt"); @@ -223,8 +177,7 @@ describe("Sandbox Filesystem", () => { }); it("should rename a directory", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); await client.fs.mkdir("/rename-dir-old"); await client.fs.writeTextFile("/rename-dir-old/file.txt", "content"); @@ -245,8 +198,7 @@ describe("Sandbox Filesystem", () => { describe.skip("Batch write operations", () => { // Skip these tests - batchWrite uses zip/unzip which may not be available in all sandbox environments it("should write multiple files at once", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); await client.fs.mkdir("/batch-test"); @@ -269,8 +221,7 @@ describe("Sandbox Filesystem", () => { }); it("should write nested directories in batch", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); await client.fs.batchWrite([ { path: "/batch-nested/dir1/file.txt", content: "nested 1" }, @@ -294,8 +245,7 @@ describe("Sandbox Filesystem", () => { describe("Recursive operations", () => { it("should create nested directories", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); await client.fs.mkdir("/nested/deep/path", true); @@ -307,8 +257,7 @@ describe("Sandbox Filesystem", () => { }); it("should remove directory with contents recursively", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); await client.fs.mkdir("/recursive-remove"); await client.fs.writeTextFile("/recursive-remove/file1.txt", "content"); @@ -326,9 +275,8 @@ describe("Sandbox Filesystem", () => { }); describe("File watching", () => { - it.only("should detect file system changes", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + it("should detect file system changes", async () => { + if (!client) throw new Error("Client not initialized"); try { await client.fs.remove("/watch-dir"); diff --git a/tests/e2e/sandbox-hosts.test.ts b/tests/e2e/sandbox-hosts.test.ts index 05e78bb..e5dc5f9 100644 --- a/tests/e2e/sandbox-hosts.test.ts +++ b/tests/e2e/sandbox-hosts.test.ts @@ -1,27 +1,17 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { CodeSandbox } from "../../src/index.js"; -import { Sandbox } from "../../src/Sandbox.js"; import { SandboxClient } from "../../src/SandboxClient/index.js"; -import { createSandbox, initializeSDK } from "./helpers.js"; +import { createTest } from "./helpers.js"; describe("Sandbox Hosts", () => { - let sdk: CodeSandbox; - let sandbox: Sandbox | undefined; + const test = createTest(); let client: SandboxClient | undefined; beforeAll(async () => { - sdk = initializeSDK(); - - // Create a sandbox for testing - sandbox = await createSandbox(sdk); - // Connect to sandbox - client = await sandbox.connect(); + client = await test.sandbox.connect(); }, 60000); afterAll(async () => { - const sandboxId = sandbox?.id; - try { if (client) { await client.disconnect(); @@ -31,41 +21,21 @@ describe("Sandbox Hosts", () => { } catch (error) { console.error("Failed to dispose client:", error); } - - if (sandboxId) { - try { - await sdk.sandboxes.shutdown(sandboxId); - await sdk.sandboxes.delete(sandboxId); - } catch (error) { - console.error("Failed to cleanup test sandbox:", sandboxId, error); - try { - await sdk.sandboxes.delete(sandboxId); - } catch (deleteError) { - console.error( - "Failed to force delete sandbox:", - sandboxId, - deleteError - ); - } - } - } }); describe("Host URL generation", () => { it("should generate URL for a port", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const url = client.hosts.getUrl(3000); expect(url).toBeTruthy(); expect(url).toContain("csb.app"); expect(url).toContain("3000"); - expect(url).toContain(sandbox.id); + expect(url).toContain(test.sandbox.id); }); it("should generate URL with custom protocol", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const url = client.hosts.getUrl(8080, "http"); expect(url).toBeTruthy(); @@ -74,8 +44,7 @@ describe("Sandbox Hosts", () => { }); it("should generate URL with https by default", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const url = client.hosts.getUrl(4000); expect(url.startsWith("https://")).toBe(true); @@ -84,8 +53,7 @@ describe("Sandbox Hosts", () => { describe("Host headers and cookies", () => { it("should get headers", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const headers = client.hosts.getHeaders(); expect(headers).toBeDefined(); @@ -93,8 +61,7 @@ describe("Sandbox Hosts", () => { }); it("should get cookies", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const cookies = client.hosts.getCookies(); expect(cookies).toBeDefined(); diff --git a/tests/e2e/sandbox-interpreters.test.ts b/tests/e2e/sandbox-interpreters.test.ts index 5928d44..43fd2cb 100644 --- a/tests/e2e/sandbox-interpreters.test.ts +++ b/tests/e2e/sandbox-interpreters.test.ts @@ -1,27 +1,17 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { CodeSandbox } from "../../src/index.js"; -import { Sandbox } from "../../src/Sandbox.js"; import { SandboxClient } from "../../src/SandboxClient/index.js"; -import { createSandbox, initializeSDK, TEST_TEMPLATE_ID } from "./helpers.js"; +import { createTest } from "./helpers.js"; describe("Sandbox Interpreters", () => { - let sdk: CodeSandbox; - let sandbox: Sandbox | undefined; + const test = createTest(); let client: SandboxClient | undefined; beforeAll(async () => { - sdk = initializeSDK(); - - // Create a sandbox for testing - sandbox = await createSandbox(sdk); - // Connect to sandbox - client = await sandbox.connect(); + client = await test.sandbox.connect(); }, 60000); afterAll(async () => { - const sandboxId = sandbox?.id; - try { if (client) { await client.disconnect(); @@ -31,38 +21,18 @@ describe("Sandbox Interpreters", () => { } catch (error) { console.error("Failed to dispose client:", error); } - - if (sandboxId) { - try { - await sdk.sandboxes.shutdown(sandboxId); - await sdk.sandboxes.delete(sandboxId); - } catch (error) { - console.error("Failed to cleanup test sandbox:", sandboxId, error); - try { - await sdk.sandboxes.delete(sandboxId); - } catch (deleteError) { - console.error( - "Failed to force delete sandbox:", - sandboxId, - deleteError - ); - } - } - } }); describe("JavaScript interpreter", () => { it("should execute simple JavaScript code", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const result = await client.interpreters.javascript("2 + 2"); expect(result).toContain("4"); }); it("should execute JavaScript with variables", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const result = await client.interpreters.javascript(` const x = 10; @@ -73,8 +43,7 @@ describe("Sandbox Interpreters", () => { }); it("should execute JavaScript with return statement", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const result = await client.interpreters.javascript(` const greeting = 'Hello from JavaScript'; @@ -86,16 +55,14 @@ describe("Sandbox Interpreters", () => { describe("Python interpreter", () => { it("should execute simple Python code", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const result = await client.interpreters.python("2 + 2"); expect(result).toContain("4"); }); it("should execute Python with variables", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const result = await client.interpreters.python(` x = 10 @@ -105,8 +72,7 @@ print(x + y)`); }); it("should execute Python with print statement", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const result = await client.interpreters.python(` message = 'Hello from Python' diff --git a/tests/e2e/sandbox-lifecycle.test.ts b/tests/e2e/sandbox-lifecycle.test.ts index ad8a251..cb5bad8 100644 --- a/tests/e2e/sandbox-lifecycle.test.ts +++ b/tests/e2e/sandbox-lifecycle.test.ts @@ -1,20 +1,15 @@ -import { describe, it, expect, beforeAll } from 'vitest'; -import { CodeSandbox } from '../../src/index.js'; -import { initializeSDK, TEST_TEMPLATE_ID } from './helpers.js'; +import { describe, it, expect } from "vitest"; +import { createTest, TEST_TEMPLATE_ID } from "./helpers.js"; -describe('Sandbox Lifecycle', () => { - let sdk: CodeSandbox; - - beforeAll(() => { - sdk = initializeSDK(); - }); +describe("Sandbox Lifecycle", () => { + const test = createTest(); it('should complete full lifecycle: create, hibernate, resume, restart, shutdown, delete', async () => { let sandboxId: string | undefined; try { // Create sandbox - let sandbox = await sdk.sandboxes.create({ + let sandbox = await test.sdk.sandboxes.create({ id: TEST_TEMPLATE_ID, }); expect(sandbox).toBeDefined(); @@ -22,30 +17,30 @@ describe('Sandbox Lifecycle', () => { sandboxId = sandbox.id; // Hibernate sandbox - await sdk.sandboxes.hibernate(sandboxId); + await test.sdk.sandboxes.hibernate(sandboxId); // Resume sandbox - sandbox = await sdk.sandboxes.resume(sandboxId); + sandbox = await test.sdk.sandboxes.resume(sandboxId); expect(sandbox).toBeDefined(); expect(sandbox.id).toBe(sandboxId); // Restart sandbox - await sdk.sandboxes.restart(sandboxId); + await test.sdk.sandboxes.restart(sandboxId); // Shutdown sandbox - await sdk.sandboxes.shutdown(sandboxId); + await test.sdk.sandboxes.shutdown(sandboxId); // Delete sandbox - await sdk.sandboxes.delete(sandboxId); + await test.sdk.sandboxes.delete(sandboxId); sandboxId = undefined; // Mark as cleaned up } finally { // Ensure cleanup even on test failure if (sandboxId) { try { - await sdk.sandboxes.shutdown(sandboxId); - await sdk.sandboxes.delete(sandboxId); + await test.sdk.sandboxes.shutdown(sandboxId); + await test.sdk.sandboxes.delete(sandboxId); } catch (error) { - console.error('Failed to cleanup sandbox:', sandboxId, error); + console.error("Failed to cleanup sandbox:", sandboxId, error); } } } diff --git a/tests/e2e/sandbox-ports.test.ts b/tests/e2e/sandbox-ports.test.ts index 47f3d5c..bcd7a7d 100644 --- a/tests/e2e/sandbox-ports.test.ts +++ b/tests/e2e/sandbox-ports.test.ts @@ -1,27 +1,17 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { CodeSandbox } from "../../src/index.js"; -import { Sandbox } from "../../src/Sandbox.js"; import { SandboxClient } from "../../src/SandboxClient/index.js"; -import { createSandbox, initializeSDK } from "./helpers.js"; +import { createTest } from "./helpers.js"; describe("Sandbox Ports", () => { - let sdk: CodeSandbox; - let sandbox: Sandbox | undefined; + const test = createTest(); let client: SandboxClient | undefined; beforeAll(async () => { - sdk = initializeSDK(); - - // Create a sandbox for testing - sandbox = await createSandbox(sdk); - // Connect to sandbox - client = await sandbox.connect(); + client = await test.sandbox.connect(); }, 60000); afterAll(async () => { - const sandboxId = sandbox?.id; - try { if (client) { await client.disconnect(); @@ -31,30 +21,11 @@ describe("Sandbox Ports", () => { } catch (error) { console.error("Failed to dispose client:", error); } - - if (sandboxId) { - try { - await sdk.sandboxes.shutdown(sandboxId); - await sdk.sandboxes.delete(sandboxId); - } catch (error) { - console.error("Failed to cleanup test sandbox:", sandboxId, error); - try { - await sdk.sandboxes.delete(sandboxId); - } catch (deleteError) { - console.error( - "Failed to force delete sandbox:", - sandboxId, - deleteError - ); - } - } - } }); describe("Port listing", () => { it("should get all open ports", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const ports = await client.ports.getAll(); expect(Array.isArray(ports)).toBe(true); @@ -64,8 +35,7 @@ describe("Sandbox Ports", () => { describe("Port operations with server", () => { // Skipped - these tests have shell lifecycle management issues it("should detect when a port opens", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); // Start a simple HTTP server in the background const serverCommand = await client.commands.runBackground( @@ -87,8 +57,7 @@ describe("Sandbox Ports", () => { }, 40000); it("should get port information", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); // Start a server const serverCommand = await client.commands.runBackground( @@ -116,8 +85,7 @@ describe("Sandbox Ports", () => { describe("Port events", () => { // Skipped - these tests have shell lifecycle management issues it("should listen to port opened events", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); let portOpened = false; let openedPort = 0; diff --git a/tests/e2e/sandbox-setup.test.ts b/tests/e2e/sandbox-setup.test.ts index 94278e4..485606c 100644 --- a/tests/e2e/sandbox-setup.test.ts +++ b/tests/e2e/sandbox-setup.test.ts @@ -1,27 +1,17 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { CodeSandbox } from "../../src/index.js"; -import { Sandbox } from "../../src/Sandbox.js"; import { SandboxClient } from "../../src/SandboxClient/index.js"; -import { createSandbox, initializeSDK } from "./helpers.js"; +import { createTest } from "./helpers.js"; describe("Sandbox Setup", () => { - let sdk: CodeSandbox; - let sandbox: Sandbox | undefined; + const test = createTest(); let client: SandboxClient | undefined; beforeAll(async () => { - sdk = initializeSDK(); - - // Create a sandbox for testing - sandbox = await createSandbox(sdk); - // Connect to sandbox - client = await sandbox.connect(); + client = await test.sandbox.connect(); }, 60000); afterAll(async () => { - const sandboxId = sandbox?.id; - try { if (client) { await client.disconnect(); @@ -31,30 +21,11 @@ describe("Sandbox Setup", () => { } catch (error) { console.error("Failed to dispose client:", error); } - - if (sandboxId) { - try { - await sdk.sandboxes.shutdown(sandboxId); - await sdk.sandboxes.delete(sandboxId); - } catch (error) { - console.error("Failed to cleanup test sandbox:", sandboxId, error); - try { - await sdk.sandboxes.delete(sandboxId); - } catch (deleteError) { - console.error( - "Failed to force delete sandbox:", - sandboxId, - deleteError - ); - } - } - } }); describe("Setup operations", () => { it("should get setup status", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const status = client.setup.status; expect(status).toBeDefined(); @@ -62,24 +33,21 @@ describe("Sandbox Setup", () => { }); it("should get setup steps", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const steps = client.setup.getSteps(); expect(Array.isArray(steps)).toBe(true); }); it("should get current step index", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const currentStepIndex = client.setup.currentStepIndex; expect(typeof currentStepIndex).toBe("number"); }); it("should wait until setup completes", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); // If setup is already finished, this should resolve immediately await client.setup.waitUntilComplete(); @@ -91,8 +59,7 @@ describe("Sandbox Setup", () => { describe("Setup steps", () => { it("should have step properties", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const steps = client.setup.getSteps(); diff --git a/tests/e2e/sandbox-tasks.test.ts b/tests/e2e/sandbox-tasks.test.ts index 626eae5..456fb0b 100644 --- a/tests/e2e/sandbox-tasks.test.ts +++ b/tests/e2e/sandbox-tasks.test.ts @@ -1,27 +1,17 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { CodeSandbox } from "../../src/index.js"; -import { Sandbox } from "../../src/Sandbox.js"; import { SandboxClient } from "../../src/SandboxClient/index.js"; -import { createSandbox, initializeSDK } from "./helpers.js"; +import { createTest } from "./helpers.js"; describe("Sandbox Tasks", () => { - let sdk: CodeSandbox; - let sandbox: Sandbox | undefined; + const test = createTest(); let client: SandboxClient | undefined; beforeAll(async () => { - sdk = initializeSDK(); - - // Create a sandbox for testing - sandbox = await createSandbox(sdk); - // Connect to sandbox - client = await sandbox.connect(); + client = await test.sandbox.connect(); }, 60000); afterAll(async () => { - const sandboxId = sandbox?.id; - try { if (client) { await client.disconnect(); @@ -31,38 +21,18 @@ describe("Sandbox Tasks", () => { } catch (error) { console.error("Failed to dispose client:", error); } - - if (sandboxId) { - try { - await sdk.sandboxes.shutdown(sandboxId); - await sdk.sandboxes.delete(sandboxId); - } catch (error) { - console.error("Failed to cleanup test sandbox:", sandboxId, error); - try { - await sdk.sandboxes.delete(sandboxId); - } catch (deleteError) { - console.error( - "Failed to force delete sandbox:", - sandboxId, - deleteError - ); - } - } - } }); describe("Task listing", () => { it("should get all tasks", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const tasks = await client.tasks.getAll(); expect(Array.isArray(tasks)).toBe(true); }); it("should get task by ID if tasks exist", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const tasks = await client.tasks.getAll(); @@ -81,8 +51,7 @@ describe("Sandbox Tasks", () => { describe("Task properties", () => { it("should have task properties", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const tasks = await client.tasks.getAll(); @@ -102,8 +71,7 @@ describe("Sandbox Tasks", () => { // These tests are skipped as they require specific task configurations // and may interfere with running tasks it("should run a task", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const tasks = await client.tasks.getAll(); @@ -119,8 +87,7 @@ describe("Sandbox Tasks", () => { }); it("should stop a running task", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const tasks = await client.tasks.getAll(); @@ -133,8 +100,7 @@ describe("Sandbox Tasks", () => { }); it("should restart a task", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const tasks = await client.tasks.getAll(); diff --git a/tests/e2e/sandbox-terminals.test.ts b/tests/e2e/sandbox-terminals.test.ts index 8332908..65bc934 100644 --- a/tests/e2e/sandbox-terminals.test.ts +++ b/tests/e2e/sandbox-terminals.test.ts @@ -1,27 +1,17 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { CodeSandbox } from "../../src/index.js"; -import { Sandbox } from "../../src/Sandbox.js"; import { SandboxClient } from "../../src/SandboxClient/index.js"; -import { createSandbox, initializeSDK, TEST_TEMPLATE_ID } from "./helpers.js"; +import { createTest } from "./helpers.js"; describe("Sandbox Terminals", () => { - let sdk: CodeSandbox; - let sandbox: Sandbox | undefined; + const test = createTest(); let client: SandboxClient | undefined; beforeAll(async () => { - sdk = initializeSDK(); - - // Create a sandbox for testing - sandbox = await createSandbox(sdk); - // Connect to sandbox - client = await sandbox.connect(); + client = await test.sandbox.connect(); }, 60000); afterAll(async () => { - const sandboxId = sandbox?.id; - try { if (client) { await client.disconnect(); @@ -31,30 +21,11 @@ describe("Sandbox Terminals", () => { } catch (error) { console.error("Failed to dispose client:", error); } - - if (sandboxId) { - try { - await sdk.sandboxes.shutdown(sandboxId); - await sdk.sandboxes.delete(sandboxId); - } catch (error) { - console.error("Failed to cleanup test sandbox:", sandboxId, error); - try { - await sdk.sandboxes.delete(sandboxId); - } catch (deleteError) { - console.error( - "Failed to force delete sandbox:", - sandboxId, - deleteError - ); - } - } - } }); describe("Terminal creation", () => { it("should create a terminal", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const terminal = await client.terminals.create(); expect(terminal).toBeDefined(); @@ -65,8 +36,7 @@ describe("Sandbox Terminals", () => { }); it("should create terminal with custom dimensions", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const terminal = await client.terminals.create("bash", { dimensions: { cols: 120, rows: 40 }, @@ -81,8 +51,7 @@ describe("Sandbox Terminals", () => { describe("Terminal listing", () => { it("should get all terminals", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const terminal1 = await client.terminals.create(); const terminal2 = await client.terminals.create(); @@ -97,8 +66,7 @@ describe("Sandbox Terminals", () => { }, 15000); it("should get terminal by ID", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const terminal = await client.terminals.create(); const retrieved = await client.terminals.get(terminal.id); @@ -115,8 +83,7 @@ describe("Sandbox Terminals", () => { describe("Terminal operations", () => { it("should write to terminal", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const terminal = await client.terminals.create(); @@ -131,8 +98,7 @@ describe("Sandbox Terminals", () => { }); it("should run command in terminal", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const terminal = await client.terminals.create(); @@ -147,8 +113,7 @@ describe("Sandbox Terminals", () => { }); it("should receive output from terminal", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const terminal = await client.terminals.create(); let receivedOutput = false; @@ -179,8 +144,7 @@ describe("Sandbox Terminals", () => { describe("Terminal lifecycle", () => { it("should kill terminal", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const terminal = await client.terminals.create(); expect(terminal).toBeDefined(); @@ -193,8 +157,7 @@ describe("Sandbox Terminals", () => { }); it("should handle multiple terminals", async () => { - if (!client || !sandbox) - throw new Error("Client or sandbox not initialized"); + if (!client) throw new Error("Client not initialized"); const terminals = await Promise.all([ client.terminals.create(), From 7a0f88258a38f1e8a4737e537d2280633d245dc0 Mon Sep 17 00:00:00 2001 From: Christian Alfoni Date: Mon, 23 Feb 2026 09:23:13 +0100 Subject: [PATCH 31/46] testing updates --- .env.example | 4 ++++ README.md | 5 +++-- src/PintClient/execs.ts | 1 + src/bin/commands/build.ts | 13 +++++++++---- src/utils/constants.ts | 9 +++------ test-template-pint/.codesandbox/Dockerfile | 1 + .../.codesandbox/tasks.json | 0 test-template-pitcher/.codesandbox/Dockerfile | 1 + test-template-pitcher/.codesandbox/tasks.json | 7 +++++++ test-template/.codesandbox/Dockerfile | 12 ------------ tests/e2e/sandbox-commands.test.ts | 2 +- 11 files changed, 30 insertions(+), 25 deletions(-) create mode 100644 test-template-pint/.codesandbox/Dockerfile rename {test-template => test-template-pint}/.codesandbox/tasks.json (100%) create mode 100644 test-template-pitcher/.codesandbox/Dockerfile create mode 100644 test-template-pitcher/.codesandbox/tasks.json delete mode 100644 test-template/.codesandbox/Dockerfile diff --git a/.env.example b/.env.example index 3980f23..5781917 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,19 @@ # API KEYS export CSB_API_KEY=... # Production # export CSB_API_KEY=... # Stream +# export CSB_API_KEY=csb_v1_devbox # Devbox Local # BASE URLS export CSB_BASE_URL=https://api.codesandbox.io # Production # export CSB_BASE_URL=https://api.codesandbox.stream # Stream +# export CSB_BASE_URL=https://api.codesandbox.dev # Devbox Local + # TEMPLATES export CSB_TEMPLATE_ID=... # Production (Pitcher) # export CSB_TEMPLATE_ID=... # Production (Pint) # export CSB_TEMPLATE_ID=... # Stream (Pitcher) # export CSB_TEMPLATE_ID=... # Stream (Pint) +# export CSB_TEMPLATE_ID=... # Devbox Local (Pint) diff --git a/README.md b/README.md index 8d5d4e3..10ac2df 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,9 @@ console.log(output); // Hello World - Clone the sandbox templates repo (https://github.com/codesandbox/sandbox-templates) - Create the `.env` based on example and populate it - Run `source .env` to export the env variables -- Build template for Pitcher `./dist/bin/codesandbox.mjs build ./test-template` -- Build template for Pint `./dist/bin/codesandbox.mjs build ./test-template --beta` +- Build template for Pitcher `./dist/bin/codesandbox.mjs build ./test-template-pitcher` +- Build template for Pint `./dist/bin/codesandbox.mjs build ./test-template-pint --beta` +- Update `.env` with template id and `source .env` it again - Run e2e tests with `npm run test:e2e` - Run specific test file `npm run test -- filesystem` diff --git a/src/PintClient/execs.ts b/src/PintClient/execs.ts index 3222069..7c075d1 100644 --- a/src/PintClient/execs.ts +++ b/src/PintClient/execs.ts @@ -107,6 +107,7 @@ export class PintShellsClient implements IAgentClientShells { isSystemShell?: boolean; cwd?: string; }): Promise { + console.log(command, args); const exec = await createExec({ client: this.apiClient, body: { diff --git a/src/bin/commands/build.ts b/src/bin/commands/build.ts index 80d0c84..a698d38 100644 --- a/src/bin/commands/build.ts +++ b/src/bin/commands/build.ts @@ -659,7 +659,9 @@ export async function betaCodeSandboxBuild( const teamId = metaInfo.data?.auth?.team; if (!teamId) { - throw new Error("Failed to fetch team information for the provided CSB_API_KEY. Please ensure your API key is correct and has access to a team."); + throw new Error( + "Failed to fetch team information for the provided CSB_API_KEY. Please ensure your API key is correct and has access to a team." + ); } const base32EncodedTeamId = base32Encode(teamId); @@ -725,7 +727,9 @@ export async function betaCodeSandboxBuild( // Docker Login const dockerLoginSpinner = ora({ stream: process.stdout }); - dockerLoginSpinner.start("Authenticating with CodeSandbox Docker registry..."); + dockerLoginSpinner.start( + "Authenticating with CodeSandbox Docker registry..." + ); try { await dockerLogin({ registry: registry, @@ -739,7 +743,9 @@ export async function betaCodeSandboxBuild( dockerLoginSpinner.succeed("Docker registry authentication successful."); } catch (error) { dockerLoginSpinner.fail( - `Failed to authenticate with Docker registry: ${(error as Error).message}` + `Failed to authenticate with Docker registry: ${ + (error as Error).message + }` ); throw error; } @@ -760,7 +766,6 @@ export async function betaCodeSandboxBuild( } imagePushSpinner.succeed("Template Docker image pushed to CodeSandbox."); - const templateCreateSpinner = ora({ stream: process.stdout }); templateCreateSpinner.start("Creating template with Docker image..."); // Create Template with Docker Image diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 13a04e8..4f32c18 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -43,14 +43,11 @@ export function getInferredRegistryUrl() { export function isLocalEnvironment(): boolean { const apiHostName = getInferredApiHost(); - return apiHostName === "api.codesandbox.dev" + return apiHostName === "api.codesandbox.dev"; } -const BETA_ALLOWED_HOSTS = [ - "api.codesandbox.dev", - "api.codesandbox.stream", -]; +const BETA_ALLOWED_HOSTS = ["api.codesandbox.dev", "api.codesandbox.stream"]; export function isBetaAllowed(): boolean { const apiHostName = getInferredApiHost(); return BETA_ALLOWED_HOSTS.includes(apiHostName); -} \ No newline at end of file +} diff --git a/test-template-pint/.codesandbox/Dockerfile b/test-template-pint/.codesandbox/Dockerfile new file mode 100644 index 0000000..876e82f --- /dev/null +++ b/test-template-pint/.codesandbox/Dockerfile @@ -0,0 +1 @@ +FROM node:22-bookworm \ No newline at end of file diff --git a/test-template/.codesandbox/tasks.json b/test-template-pint/.codesandbox/tasks.json similarity index 100% rename from test-template/.codesandbox/tasks.json rename to test-template-pint/.codesandbox/tasks.json diff --git a/test-template-pitcher/.codesandbox/Dockerfile b/test-template-pitcher/.codesandbox/Dockerfile new file mode 100644 index 0000000..28d09f4 --- /dev/null +++ b/test-template-pitcher/.codesandbox/Dockerfile @@ -0,0 +1 @@ +FROM ghcr.io/codesandbox/devcontainers/universal:latest diff --git a/test-template-pitcher/.codesandbox/tasks.json b/test-template-pitcher/.codesandbox/tasks.json new file mode 100644 index 0000000..e95b3dc --- /dev/null +++ b/test-template-pitcher/.codesandbox/tasks.json @@ -0,0 +1,7 @@ +{ + // These tasks will run in order when initializing your CodeSandbox project. + "setupTasks": [""], + + // These tasks can be run from CodeSandbox. Running one will open a log in the app. + "tasks": {} +} diff --git a/test-template/.codesandbox/Dockerfile b/test-template/.codesandbox/Dockerfile deleted file mode 100644 index 89ad31b..0000000 --- a/test-template/.codesandbox/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -# PITCHER -FROM ghcr.io/codesandbox/devcontainers/universal:latest - - -# PINT -#FROM node:22-bookworm - -#RUN apt-get update && apt-get install -y git zsh curl && rm -rf /var/lib/apt/lists/* - -#RUN corepack enable - -#WORKDIR /workspace/project \ No newline at end of file diff --git a/tests/e2e/sandbox-commands.test.ts b/tests/e2e/sandbox-commands.test.ts index edf69f4..3a81cca 100644 --- a/tests/e2e/sandbox-commands.test.ts +++ b/tests/e2e/sandbox-commands.test.ts @@ -68,7 +68,7 @@ describe("Sandbox Commands", () => { }); describe("Background commands", () => { - it("should run command in background", async () => { + it.only("should run command in background", async () => { if (!client) throw new Error("Client not initialized"); const command = await client.commands.runBackground( From 351403cc556570f2897f29f4c13d0d962f582bb6 Mon Sep 17 00:00:00 2001 From: Christian Alfoni Date: Mon, 23 Feb 2026 14:21:29 +0100 Subject: [PATCH 32/46] fix: pitcher build batchWrite and long running test --- src/PintClient/execs.ts | 1 - src/SandboxClient/filesystem.ts | 4 ++-- tests/e2e/sandbox-commands.test.ts | 29 ++++++++++++++++++++++++++++- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/PintClient/execs.ts b/src/PintClient/execs.ts index 7c075d1..3222069 100644 --- a/src/PintClient/execs.ts +++ b/src/PintClient/execs.ts @@ -107,7 +107,6 @@ export class PintShellsClient implements IAgentClientShells { isSystemShell?: boolean; cwd?: string; }): Promise { - console.log(command, args); const exec = await createExec({ client: this.apiClient, body: { diff --git a/src/SandboxClient/filesystem.ts b/src/SandboxClient/filesystem.ts index e8dfcf3..d190f73 100644 --- a/src/SandboxClient/filesystem.ts +++ b/src/SandboxClient/filesystem.ts @@ -187,8 +187,8 @@ export class FileSystem { const result = await this.agentClient.shells.create({ projectPath: this.agentClient.workspacePath, size: { cols: 128, rows: 24 }, - command: "bash", - args: ["-c", `unzip -o ${tempZipPath}`], + command: `unzip -o ${tempZipPath}`, + args: [], type: "COMMAND", isSystemShell: true, cwd: this.agentClient.workspacePath, diff --git a/tests/e2e/sandbox-commands.test.ts b/tests/e2e/sandbox-commands.test.ts index 3a81cca..068bdab 100644 --- a/tests/e2e/sandbox-commands.test.ts +++ b/tests/e2e/sandbox-commands.test.ts @@ -68,7 +68,7 @@ describe("Sandbox Commands", () => { }); describe("Background commands", () => { - it.only("should run command in background", async () => { + it("should run command in background", async () => { if (!client) throw new Error("Client not initialized"); const command = await client.commands.runBackground( @@ -112,6 +112,33 @@ describe("Sandbox Commands", () => { // Command should be killed expect(command).toBeDefined(); }, 10000); + + it("should stream output from a long-running command via onOutput", async () => { + if (!client) throw new Error("Client not initialized"); + + const command = await client.commands.runBackground( + 'for i in 1 2 3; do echo "line $i"; sleep 1; done' + ); + expect(command.status).toBe("RUNNING"); + + // Register listener before open() so we don't miss chunks that arrive + // immediately after the first one unblocks the barrier + const receivedChunks: string[] = []; + command.onOutput((chunk) => { + receivedChunks.push(chunk); + }); + + // open() subscribes to output and enables the onOutput event + await command.open(); + + const output = await command.waitUntilComplete(); + + expect(output).toContain("line 1"); + expect(output).toContain("line 2"); + expect(output).toContain("line 3"); + // At least some chunks should have arrived incrementally via the event + expect(receivedChunks.length).toBeGreaterThan(0); + }, 15000); }); describe("Command listing", () => { From f5b730bc54fe45b8df0c0fefda509e0a640682af Mon Sep 17 00:00:00 2001 From: Christian Alfoni Date: Tue, 3 Mar 2026 09:50:48 +0100 Subject: [PATCH 33/46] more fixes --- src/PintClient/execs.ts | 10 ++++++- src/PintClient/fs.ts | 32 ++++++++++++++-------- test-template-pint/.codesandbox/tasks.json | 2 +- tests/e2e/sandbox-filesystem.test.ts | 3 +- 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/src/PintClient/execs.ts b/src/PintClient/execs.ts index 3222069..e09e64d 100644 --- a/src/PintClient/execs.ts +++ b/src/PintClient/execs.ts @@ -82,7 +82,15 @@ export class PintShellsClient implements IAgentClientShells { name: JSON.stringify({ type: "command", command: exec.command, - name: "", + name: exec.interactive + ? JSON.stringify({ + type: "terminal", + command: exec.command, + }) + : JSON.stringify({ + type: "command", + command: exec.command, + }), }), ownerUsername: "root", shellId: exec.id, diff --git a/src/PintClient/fs.ts b/src/PintClient/fs.ts index 99076ed..28b931c 100644 --- a/src/PintClient/fs.ts +++ b/src/PintClient/fs.ts @@ -1,8 +1,5 @@ import { Client } from "../api-clients/pint/client"; -import { - IAgentClientFS, - PickRawFsResult, -} from "../agent-client-interface"; +import { IAgentClientFS, PickRawFsResult } from "../agent-client-interface"; import { fs } from "../pitcher-protocol"; import { Disposable } from "../utils/disposable"; import { parseStreamEvent } from "./utils"; @@ -16,6 +13,7 @@ import { getFileStat, createWatcher, } from "../api-clients/pint"; +import { Barrier } from "../utils/barrier"; export class PintFsClient implements IAgentClientFS { constructor(private apiClient: Client) {} @@ -99,7 +97,7 @@ export class PintFsClient implements IAgentClientFS { create?: boolean, overwrite?: boolean ): Promise> { - try { + try { // Convert Uint8Array content to string for the API const decoder = new TextDecoder(); const contentString = decoder.decode(content); @@ -136,7 +134,7 @@ export class PintFsClient implements IAgentClientFS { } } - async remove( + async remove( path: string, recursive?: boolean ): Promise> { @@ -257,7 +255,7 @@ export class PintFsClient implements IAgentClientFS { path: from, }, body: { - action: 'copy', + action: "copy", destination: to, }, }); @@ -296,7 +294,7 @@ export class PintFsClient implements IAgentClientFS { path: from, }, body: { - action: 'move', + action: "move", destination: to, }, }); @@ -349,22 +347,32 @@ export class PintFsClient implements IAgentClientFS { signal: abortController.signal, }); + const barrier = new Barrier(); + // Start listening to the stream in the background (async () => { try { for await (const evt of response.stream) { try { const watchEvent = parseStreamEvent(evt); - onEvent(watchEvent); + + // @ts-ignore + if (watchEvent.type === "connected") { + barrier.open(); + } else { + onEvent(watchEvent); + } } catch (error) { - console.warn('Failed to parse filesystem watch event:', error); + console.warn("Failed to parse filesystem watch event:", error); } } } catch (error) { - console.error('Filesystem watch stream error:', error); + console.error("Filesystem watch stream error:", error); } })(); + await barrier.wait(); + return { type: "success", dispose(): void { @@ -383,4 +391,4 @@ export class PintFsClient implements IAgentClientFS { async download(path?: string): Promise<{ downloadUrl: string }> { throw new Error("Not implemented"); } -} \ No newline at end of file +} diff --git a/test-template-pint/.codesandbox/tasks.json b/test-template-pint/.codesandbox/tasks.json index e95b3dc..b34104d 100644 --- a/test-template-pint/.codesandbox/tasks.json +++ b/test-template-pint/.codesandbox/tasks.json @@ -1,6 +1,6 @@ { // These tasks will run in order when initializing your CodeSandbox project. - "setupTasks": [""], + "setupTasks": [], // These tasks can be run from CodeSandbox. Running one will open a log in the app. "tasks": {} diff --git a/tests/e2e/sandbox-filesystem.test.ts b/tests/e2e/sandbox-filesystem.test.ts index 29954fd..8de4f24 100644 --- a/tests/e2e/sandbox-filesystem.test.ts +++ b/tests/e2e/sandbox-filesystem.test.ts @@ -275,7 +275,7 @@ describe("Sandbox Filesystem", () => { }); describe("File watching", () => { - it("should detect file system changes", async () => { + it.only("should detect file system changes", async () => { if (!client) throw new Error("Client not initialized"); try { @@ -285,6 +285,7 @@ describe("Sandbox Filesystem", () => { await client.fs.mkdir("/watch-dir"); let changeDetected = false; + const watcher = await client.fs.watch("/watch-dir", { recursive: true }); const eventDisposable = watcher.onEvent((event) => { if (event.paths.some((p) => p.includes("watched-file.txt"))) { From f9245cdca5dec5ff627b7f473528f03620d95422 Mon Sep 17 00:00:00 2001 From: Christian Alfoni Date: Tue, 3 Mar 2026 11:51:25 +0100 Subject: [PATCH 34/46] added additional e2e tests --- tests/e2e/sandbox-error-handling.test.ts | 131 ++++++++++++ tests/e2e/sandbox-hibernate-state.test.ts | 96 +++++++++ tests/e2e/sandbox-performance.test.ts | 214 ++++++++++++++++++++ tests/e2e/sandbox-state-consistency.test.ts | 116 +++++++++++ tests/e2e/sandbox-vibe-coder.test.ts | 171 ++++++++++++++++ 5 files changed, 728 insertions(+) create mode 100644 tests/e2e/sandbox-error-handling.test.ts create mode 100644 tests/e2e/sandbox-hibernate-state.test.ts create mode 100644 tests/e2e/sandbox-performance.test.ts create mode 100644 tests/e2e/sandbox-state-consistency.test.ts create mode 100644 tests/e2e/sandbox-vibe-coder.test.ts diff --git a/tests/e2e/sandbox-error-handling.test.ts b/tests/e2e/sandbox-error-handling.test.ts new file mode 100644 index 0000000..4afec61 --- /dev/null +++ b/tests/e2e/sandbox-error-handling.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { createTest, TEST_TEMPLATE_ID } from "./helpers.js"; + +/** + * Scenario 7: Error handling and recovery + * + * Tests how the SDK handles invalid inputs, bad states, and interrupted operations. + */ +describe("Sandbox Error Handling", () => { + const test = createTest(); + + describe("Invalid inputs", () => { + it("should throw a clear error when creating from a nonexistent template", async () => { + const error = await test.sdk.sandboxes + .create({ id: "nonexistent-template-xyz-abc-123" }) + .catch((e) => e); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBeTruthy(); + console.log("Invalid template error:", error.message); + }, 30000); + }); + + describe("Double delete", () => { + it("should throw on deleting an already-deleted sandbox", async () => { + let sandboxId: string | undefined; + + try { + const sandbox = await test.sdk.sandboxes.create({ + id: TEST_TEMPLATE_ID, + title: "test-double-delete", + }); + sandboxId = sandbox.id; + + // First delete should succeed + await test.sdk.sandboxes.shutdown(sandboxId); + await test.sdk.sandboxes.delete(sandboxId); + sandboxId = undefined; + + // Second delete should throw + const error = await test.sdk.sandboxes + .delete(sandbox.id) + .catch((e) => e); + + expect(error).toBeInstanceOf(Error); + console.log("Double delete error:", error.message); + } finally { + if (sandboxId) { + try { + await test.sdk.sandboxes.shutdown(sandboxId); + await test.sdk.sandboxes.delete(sandboxId); + } catch {} + } + } + }, 60000); + }); + + describe("Resume behavior", () => { + it("should handle resume on an already-running sandbox without crashing", async () => { + // Resume on a running sandbox should either succeed (idempotent) or throw a clear error + const result = await test.sdk.sandboxes + .resume(test.sandbox.id) + .catch((e) => e); + + if (result instanceof Error) { + console.log("Resume-on-running error:", result.message); + expect(result.message).toBeTruthy(); + } else { + // Idempotent behavior: returned a sandbox object + expect(result.id).toBe(test.sandbox.id); + console.log("Resume-on-running returned sandbox, bootupType:", result.bootupType); + } + }, 30000); + }); + + describe("Concurrent commands", () => { + let client: SandboxClient | undefined; + + beforeAll(async () => { + client = await test.sandbox.connect(); + }, 60000); + + afterAll(async () => { + try { + if (client) { + await client.disconnect(); + client.dispose(); + client = undefined; + } + } catch {} + }); + + it("should complete all concurrent commands without deadlock", async () => { + if (!client) throw new Error("Client not initialized"); + + const results = await Promise.all([ + client.commands.run("echo cmd-1"), + client.commands.run("echo cmd-2"), + client.commands.run("echo cmd-3"), + client.commands.run("echo cmd-4"), + client.commands.run("echo cmd-5"), + ]); + + expect(results.length).toBe(5); + results.forEach((r, i) => { + expect(r).toContain(`cmd-${i + 1}`); + }); + }, 30000); + + it("should remain usable while a long-running background command is active", async () => { + if (!client) throw new Error("Client not initialized"); + + // Start a long-running command but don't await it + const longCmd = client.commands.runBackground("sleep 60"); + + try { + // We should still be able to run other commands + const other = await client.commands.run("echo still responsive"); + expect(other).toContain("still responsive"); + + const another = await client.commands.run("echo second command"); + expect(another).toContain("second command"); + } finally { + // Kill the long-running command + const cmd = await longCmd; + await cmd.kill(); + } + }, 30000); + }); +}); diff --git a/tests/e2e/sandbox-hibernate-state.test.ts b/tests/e2e/sandbox-hibernate-state.test.ts new file mode 100644 index 0000000..bd244b6 --- /dev/null +++ b/tests/e2e/sandbox-hibernate-state.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { createTest } from "./helpers.js"; + +/** + * Scenario 2: Hibernate and resume with state verification + * + * Verifies that sandbox state (files) persists through hibernate/resume cycles. + */ +describe("Sandbox Hibernate State", () => { + const test = createTest(); + + it("should preserve file content through a hibernate/resume cycle", async () => { + const client = await test.sandbox.connect(); + + try { + await client.fs.writeTextFile( + "/hibernate-state-test.txt", + "testing party 2026" + ); + const beforeContent = await client.fs.readTextFile( + "/hibernate-state-test.txt" + ); + expect(beforeContent).toBe("testing party 2026"); + } finally { + await client.disconnect(); + client.dispose(); + } + + await test.sdk.sandboxes.hibernate(test.sandbox.id); + + const resumeStart = Date.now(); + const resumed = await test.sdk.sandboxes.resume(test.sandbox.id); + console.log(`Resume time: ${Date.now() - resumeStart}ms`); + + const afterClient = await resumed.connect(); + try { + const afterContent = await afterClient.fs.readTextFile( + "/hibernate-state-test.txt" + ); + expect(afterContent).toBe("testing party 2026"); + + // Verify via command as well + const cmdOutput = await afterClient.commands.run( + "cat /hibernate-state-test.txt" + ); + expect(cmdOutput).toContain("testing party 2026"); + } finally { + await afterClient.disconnect(); + afterClient.dispose(); + } + }, 120000); + + it("should preserve file content through 3 consecutive hibernate/resume cycles", async () => { + const client = await test.sandbox.connect(); + try { + await client.fs.writeTextFile("/anchor.txt", "testing party 2026"); + } finally { + await client.disconnect(); + client.dispose(); + } + + let currentSandbox = test.sandbox; + + for (let i = 0; i < 3; i++) { + await test.sdk.sandboxes.hibernate(currentSandbox.id); + + const cycleStart = Date.now(); + currentSandbox = await test.sdk.sandboxes.resume(currentSandbox.id); + console.log(`Cycle ${i + 1} resume: ${Date.now() - cycleStart}ms`); + + const cycleClient = await currentSandbox.connect(); + try { + const content = await cycleClient.fs.readTextFile("/anchor.txt"); + expect(content).toBe("testing party 2026"); + } finally { + await cycleClient.disconnect(); + cycleClient.dispose(); + } + } + }, 300000); + + it("should report bootupType as RESUME after hibernation", async () => { + const client = await test.sandbox.connect(); + await client.disconnect(); + client.dispose(); + + await test.sdk.sandboxes.hibernate(test.sandbox.id); + const resumed = await test.sdk.sandboxes.resume(test.sandbox.id); + + expect(resumed.bootupType).toBe("RESUME"); + + const afterClient = await resumed.connect(); + await afterClient.disconnect(); + afterClient.dispose(); + }, 120000); +}); diff --git a/tests/e2e/sandbox-performance.test.ts b/tests/e2e/sandbox-performance.test.ts new file mode 100644 index 0000000..7cc1240 --- /dev/null +++ b/tests/e2e/sandbox-performance.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { createTest } from "./helpers.js"; + +/** + * Scenarios 3 & 5: File operations performance and agent-driven dev session + * + * Validates file read/write and command execution performance, plus git workflows. + */ +describe("Sandbox Performance", () => { + const test = createTest(); + let client: SandboxClient | undefined; + + beforeAll(async () => { + client = await test.sandbox.connect(); + }, 60000); + + afterAll(async () => { + try { + if (client) { + await client.disconnect(); + client.dispose(); + client = undefined; + } + } catch (error) { + console.error("Failed to dispose client:", error); + } + }); + + describe("Sequential file write performance", () => { + it("should write 20 files sequentially and read them back correctly", async () => { + if (!client) throw new Error("Client not initialized"); + + await client.fs.mkdir("/perf-test"); + + const latencies: number[] = []; + + for (let i = 0; i < 20; i++) { + const start = Date.now(); + await client.fs.writeTextFile( + `/perf-test/file_${i}.txt`, + `content for file ${i}` + ); + latencies.push(Date.now() - start); + } + + const maxLatency = Math.max(...latencies); + const avgLatency = + latencies.reduce((a, b) => a + b, 0) / latencies.length; + console.log( + `File write latencies: avg=${avgLatency.toFixed( + 1 + )}ms, max=${maxLatency}ms` + ); + + // Verify file count + const files = await client.fs.readdir("/perf-test"); + expect(files.filter((f) => f.type === "file").length).toBe(20); + + // Spot-check content + const content5 = await client.fs.readTextFile("/perf-test/file_5.txt"); + expect(content5).toBe("content for file 5"); + + const content19 = await client.fs.readTextFile("/perf-test/file_19.txt"); + expect(content19).toBe("content for file 19"); + + // Cleanup + await client.fs.remove("/perf-test", true); + }, 60000); + + it("should write 30 files and report P50/P99 latency", async () => { + if (!client) throw new Error("Client not initialized"); + + await client.fs.mkdir("/sdk-perf-workspace/src", true); + + const writeLatencies: number[] = []; + + for (let i = 0; i < 30; i++) { + const start = Date.now(); + await client.fs.writeTextFile( + `/sdk-perf-workspace/src/component_${i}.ts`, + `export const Component${i} = () => "component ${i}";` + ); + writeLatencies.push(Date.now() - start); + } + + writeLatencies.sort((a, b) => a - b); + const p50 = writeLatencies[14]; + const p99 = writeLatencies[29]; + console.log(`Write P50: ${p50}ms, P99: ${p99}ms`); + + // Verify all files persisted + const files = await client.fs.readdir("/sdk-perf-workspace/src"); + expect(files.filter((f) => f.type === "file").length).toBe(30); + + // Cleanup + await client.fs.remove("/sdk-perf-workspace", true); + }, 60000); + }); + + describe("Command burst performance", () => { + it("should run 50 sequential commands and report P50/P99 latency", async () => { + if (!client) throw new Error("Client not initialized"); + + const latencies: number[] = []; + + for (let i = 0; i < 50; i++) { + const start = Date.now(); + const output = await client.commands.run(`echo "step ${i}"`); + latencies.push(Date.now() - start); + expect(output).toContain(`step ${i}`); + } + + latencies.sort((a, b) => a - b); + const p50 = latencies[24]; + const p99 = latencies[49]; + console.log(`Command P50: ${p50}ms, P99: ${p99}ms`); + + // P99 should be under 10 seconds (generous bound for E2E over network) + expect(p99).toBeLessThan(10000); + }, 180000); + }); + + describe("Package installation", () => { + it("should install a package and verify it is usable", async () => { + if (!client) throw new Error("Client not initialized"); + + await client.commands.run( + "mkdir -p /npm-test && cd /npm-test && npm init -y" + ); + + const installStart = Date.now(); + await client.commands.run("cd /npm-test && npm install express"); + const installDuration = Date.now() - installStart; + console.log(`npm install express: ${installDuration}ms`); + + expect(installDuration).toBeLessThan(60000); + + // Verify the package is usable + const verify = await client.commands.run( + "node -e \"require('/npm-test/node_modules/express'); console.log('express loaded')\"" + ); + expect(verify).toContain("express loaded"); + + // Cleanup + await client.fs.remove("/npm-test", true); + }, 120000); + + it("should complete a heavy package install within 120 seconds", async () => { + if (!client) throw new Error("Client not initialized"); + + await client.commands.run( + "mkdir -p /heavy-install && cd /heavy-install && npm init -y" + ); + + const heavyStart = Date.now(); + await client.commands.run( + "cd /heavy-install && npm install next react react-dom typescript @types/react" + ); + const heavyDuration = Date.now() - heavyStart; + console.log(`Heavy npm install: ${heavyDuration}ms`); + + expect(heavyDuration).toBeLessThan(120000); + + // Cleanup + await client.fs.remove("/heavy-install", true); + }, 180000); + }); + + describe("Git operations", () => { + it("should perform git init, add, commit and log successfully", async () => { + if (!client) throw new Error("Client not initialized"); + + await client.fs.mkdir("/git-test/src", true); + + // Configure git + await client.commands.run([ + "cd /git-test", + "git init", + "git config user.email 'test@test.com'", + "git config user.name 'Test User'", + ]); + + // Write some source files + for (let i = 0; i < 5; i++) { + await client.fs.writeTextFile( + `/git-test/src/component_${i}.ts`, + `export const Component${i} = () => "component ${i}";` + ); + } + + // Stage and commit + await client.commands.run("cd /git-test && git add ."); + await client.commands.run( + "cd /git-test && git commit -m 'initial commit'" + ); + + // Verify commit is in log + const log = await client.commands.run( + "cd /git-test && git log --oneline" + ); + expect(log).toContain("initial commit"); + + // Verify all files were committed + const trackedFiles = await client.commands.run( + "cd /git-test && git ls-files | wc -l" + ); + expect(parseInt(trackedFiles.trim())).toBe(5); + + // Cleanup + await client.fs.remove("/git-test", true); + }, 60000); + }); +}); diff --git a/tests/e2e/sandbox-state-consistency.test.ts b/tests/e2e/sandbox-state-consistency.test.ts new file mode 100644 index 0000000..806cc32 --- /dev/null +++ b/tests/e2e/sandbox-state-consistency.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "vitest"; +import { createTest } from "./helpers.js"; + +/** + * Scenario 8: State consistency edge cases + * + * Tests rapid hibernate/resume cycles, concurrent operations, and + * state integrity when interrupting long-running operations. + */ +describe("Sandbox State Consistency", () => { + const test = createTest(); + + it("should handle 5 rapid hibernate/resume cycles with state intact", async () => { + // Write anchor file before cycling + const setupClient = await test.sandbox.connect(); + try { + await setupClient.fs.writeTextFile( + "/anchor.txt", + "do not lose me" + ); + } finally { + await setupClient.disconnect(); + setupClient.dispose(); + } + + let currentSandbox = test.sandbox; + + for (let i = 0; i < 5; i++) { + await test.sdk.sandboxes.hibernate(currentSandbox.id); + currentSandbox = await test.sdk.sandboxes.resume(currentSandbox.id); + + const cycleClient = await currentSandbox.connect(); + try { + const content = await cycleClient.fs.readTextFile("/anchor.txt"); + expect(content).toBe("do not lose me"); + console.log(`Cycle ${i + 1}: OK`); + } finally { + await cycleClient.disconnect(); + cycleClient.dispose(); + } + } + }, 300000); + + it("should complete multiple concurrent commands without error", async () => { + const client = await test.sandbox.connect(); + + try { + const results = await Promise.all([ + client.commands.run("echo cmd-1"), + client.commands.run("echo cmd-2"), + client.commands.run("echo cmd-3"), + client.commands.run("echo cmd-4"), + client.commands.run("echo cmd-5"), + ]); + + expect(results.length).toBe(5); + expect(results[0]).toContain("cmd-1"); + expect(results[1]).toContain("cmd-2"); + expect(results[2]).toContain("cmd-3"); + expect(results[3]).toContain("cmd-4"); + expect(results[4]).toContain("cmd-5"); + + console.log("All concurrent commands completed:", results.length); + } finally { + await client.disconnect(); + client.dispose(); + } + }, 30000); + + it("should recover gracefully after hibernating during a running npm install", async () => { + const client = await test.sandbox.connect(); + + try { + await client.commands.run( + "mkdir -p /mid-install-test && cd /mid-install-test && npm init -y" + ); + + // Start npm install and do NOT await (fire and forget) + const installPromise = client.commands + .run( + "cd /mid-install-test && npm install next react react-dom typescript" + ) + .catch(() => { + // Expected: install may be interrupted by hibernate + }); + + // Hibernate after a short delay (mid-install) + await new Promise((r) => setTimeout(r, 5000)); + } finally { + await client.disconnect(); + client.dispose(); + } + + await test.sdk.sandboxes.hibernate(test.sandbox.id); + const resumed = await test.sdk.sandboxes.resume(test.sandbox.id); + + const afterClient = await resumed.connect(); + try { + // State should be recoverable: either install completed or can be re-run + const checkResult = await afterClient.commands.run( + "ls /mid-install-test/node_modules 2>/dev/null | head -5 || echo 'no node_modules'" + ); + console.log("After resume mid-install state:", checkResult.trim()); + + // The sandbox should be responsive + const echo = await afterClient.commands.run("echo still alive"); + expect(echo).toContain("still alive"); + + // Cleanup + await afterClient.fs.remove("/mid-install-test", true); + } finally { + await afterClient.disconnect(); + afterClient.dispose(); + } + }, 120000); +}); diff --git a/tests/e2e/sandbox-vibe-coder.test.ts b/tests/e2e/sandbox-vibe-coder.test.ts new file mode 100644 index 0000000..e5082b9 --- /dev/null +++ b/tests/e2e/sandbox-vibe-coder.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { createTest } from "./helpers.js"; + +/** + * Scenario 4: Full vibe coder workflow + * + * Simulates a realistic user journey: create sandbox, write code, + * install deps, start dev server, verify preview URL, update code. + */ +describe("Sandbox Vibe Coder Workflow", () => { + const test = createTest(); + let client: SandboxClient | undefined; + + beforeAll(async () => { + client = await test.sandbox.connect(); + + // Write application files + await client.fs.mkdir("/app"); + await client.fs.writeTextFile( + "/app/package.json", + JSON.stringify( + { + name: "testing-party", + scripts: { start: "node server.js" }, + dependencies: { express: "^4.18.0" }, + }, + null, + 2 + ) + ); + await client.fs.writeTextFile( + "/app/server.js", + [ + "const express = require('express');", + "const app = express();", + "app.get('/', (req, res) => res.send('

Testing Party 2026

'));", + "app.get('/health', (req, res) => res.json({ status: 'ok' }));", + "app.listen(3000, () => console.log('Server running on port 3000'));", + ].join("\n") + ); + + // Write additional files simulating a typical AI coding session + await client.fs.mkdir("/app/routes"); + await client.fs.mkdir("/app/middleware"); + await client.fs.mkdir("/app/utils"); + + const additionalFiles = [ + { + path: "/app/routes/index.js", + content: "module.exports = require('./home');", + }, + { + path: "/app/routes/home.js", + content: + "const router = require('express').Router();\nrouter.get('/', (req, res) => res.send('home'));\nmodule.exports = router;", + }, + { + path: "/app/middleware/logger.js", + content: + "module.exports = (req, res, next) => { console.log(req.method, req.url); next(); };", + }, + { + path: "/app/utils/helpers.js", + content: "exports.formatDate = (d) => d.toISOString();", + }, + { + path: "/app/config.js", + content: + "module.exports = { port: 3000, env: process.env.NODE_ENV || 'development' };", + }, + { path: "/app/.env.example", content: "NODE_ENV=development\nPORT=3000" }, + { + path: "/app/README.md", + content: "# Testing Party 2026\n\nA simple Express server.", + }, + ]; + + for (const file of additionalFiles) { + await client.fs.writeTextFile(file.path, file.content); + } + + // Install dependencies — shared prerequisite for all tests below + const installStart = Date.now(); + await client.commands.run("cd /app && npm install"); + console.log(`npm install: ${Date.now() - installStart}ms`); + }, 180000); + + afterAll(async () => { + try { + if (client) { + await client.disconnect(); + client.dispose(); + client = undefined; + } + } catch (error) { + console.error("Failed to dispose client:", error); + } + }); + + it("should have all application files written and dependencies installed", async () => { + if (!client) throw new Error("Client not initialized"); + + const appFiles = await client.fs.readdir("/app"); + expect(appFiles.find((f) => f.name === "package.json")).toBeDefined(); + expect(appFiles.find((f) => f.name === "server.js")).toBeDefined(); + + const expressExists = await client.fs.stat("/app/node_modules/express"); + expect(expressExists).toBeDefined(); + expect(expressExists.type).toBe("directory"); + }, 30000); + + it("should start a dev server and respond to HTTP requests", async () => { + if (!client) throw new Error("Client not initialized"); + + const serverCmd = await client.commands.runBackground( + "cd /app && node server.js" + ); + + try { + await client.ports.waitForPort(3000, { timeoutMs: 20000 }); + + const response = await client.commands.run( + "curl -s http://localhost:3000" + ); + expect(response).toContain("Testing Party 2026"); + + const healthResponse = await client.commands.run( + "curl -s http://localhost:3000/health" + ); + expect(healthResponse).toContain("ok"); + + const previewUrl = client.hosts.getUrl(3000); + expect(previewUrl).toBeTruthy(); + expect(previewUrl).toContain("3000"); + expect(previewUrl).toContain(test.sandbox.id); + } finally { + await serverCmd.kill(); + } + }, 60000); + + it("should reflect code changes after server restart", async () => { + if (!client) throw new Error("Client not initialized"); + + // Use port 3001 to avoid conflicts with port 3000 from the previous test + await client.fs.writeTextFile( + "/app/server.js", + [ + "const express = require('express');", + "const app = express();", + "app.get('/', (req, res) => res.send('

Updated: Testing Party 2026

'));", + "app.listen(3001, () => console.log('Server running on port 3001'));", + ].join("\n") + ); + + const serverCmd = await client.commands.runBackground( + "cd /app && node server.js" + ); + + try { + await client.ports.waitForPort(3001, { timeoutMs: 20000 }); + + const response = await client.commands.run( + "curl -s http://localhost:3001" + ); + expect(response).toContain("Updated: Testing Party 2026"); + } finally { + await serverCmd.kill(); + } + }, 60000); +}); From f53720b953967964bb077d37ccd1ebdd94debba9 Mon Sep 17 00:00:00 2001 From: Christian Alfoni Date: Tue, 3 Mar 2026 13:19:12 +0100 Subject: [PATCH 35/46] remove non deterministic test. Will add deterministic when new option arrives --- tests/e2e/sandbox-hibernate-state.test.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/tests/e2e/sandbox-hibernate-state.test.ts b/tests/e2e/sandbox-hibernate-state.test.ts index bd244b6..b0c86fc 100644 --- a/tests/e2e/sandbox-hibernate-state.test.ts +++ b/tests/e2e/sandbox-hibernate-state.test.ts @@ -78,19 +78,4 @@ describe("Sandbox Hibernate State", () => { } } }, 300000); - - it("should report bootupType as RESUME after hibernation", async () => { - const client = await test.sandbox.connect(); - await client.disconnect(); - client.dispose(); - - await test.sdk.sandboxes.hibernate(test.sandbox.id); - const resumed = await test.sdk.sandboxes.resume(test.sandbox.id); - - expect(resumed.bootupType).toBe("RESUME"); - - const afterClient = await resumed.connect(); - await afterClient.disconnect(); - afterClient.dispose(); - }, 120000); }); From 60e2a71fec6c1360f5fa38f51a3cc79ab8701b3c Mon Sep 17 00:00:00 2001 From: Christian Alfoni Date: Fri, 6 Mar 2026 17:09:09 +0100 Subject: [PATCH 36/46] fix: use eager fetch for watcher and strip leading slash from path Two fixes for the filesystem watcher: 1. Replace lazy SSE generator with an eager fetch() call so watch() only resolves after the server confirms the watcher is active (200 OK). The previous approach used a lazy AsyncGenerator that never connected until iterated, requiring an unreliable sleep() workaround. 2. Strip leading slash from path before URL template substitution. buildUrl calls encodeURIComponent on path values, so "/sandbox/project" became "%2Fsandbox%2Fproject" in the URL. Go decodes this to "//sandbox/project", causing the watcher's prefix filter to drop all events silently. Removing the leading slash avoids the double-slash after decoding; pint's getCorrectPath re-adds it server-side. Also rewrites pint-fs-watcher.test.ts to use a real Node.js HTTP server instead of mocking generated API functions, matching the approach used in pint's own Go tests. Co-Authored-By: Claude Sonnet 4.6 --- src/PintClient/fs.ts | 80 +++++--- tests/pint-fs-watcher.test.ts | 357 +++++++++++++--------------------- 2 files changed, 186 insertions(+), 251 deletions(-) diff --git a/src/PintClient/fs.ts b/src/PintClient/fs.ts index 28b931c..c920f51 100644 --- a/src/PintClient/fs.ts +++ b/src/PintClient/fs.ts @@ -11,9 +11,8 @@ import { createDirectory, deleteDirectory, getFileStat, - createWatcher, } from "../api-clients/pint"; -import { Barrier } from "../utils/barrier"; + export class PintFsClient implements IAgentClientFS { constructor(private apiClient: Client) {} @@ -334,48 +333,81 @@ export class PintFsClient implements IAgentClientFS { > { try { const abortController = new AbortController(); + const config = this.apiClient.getConfig(); - const response = await createWatcher({ - client: this.apiClient, - path: { - path: path, - }, + const url = this.apiClient.buildUrl({ + baseUrl: config.baseUrl as string, + url: "/api/v1/stream/directories/watcher/{path}", + path: { path: path.startsWith("/") ? path.slice(1) : path }, query: { recursive: options.recursive, ignorePatterns: options.excludes ? [...options.excludes] : undefined, }, - signal: abortController.signal, + querySerializer: + typeof config.querySerializer === "function" + ? config.querySerializer + : undefined, }); - const barrier = new Barrier(); + // Make the fetch eagerly so watch() only resolves once the server + // has confirmed the watcher is active (200 OK means ready channel fired). + const _fetch = config.fetch ?? globalThis.fetch; + const response = await _fetch( + new Request(url, { + method: "GET", + headers: config.headers as Headers, + signal: abortController.signal, + }) + ); + + if (!response.ok) { + return { + type: "error", + error: `Failed to establish watcher: ${response.status} ${response.statusText}`, + errno: null, + }; + } - // Start listening to the stream in the background + // SSE connection established — server watcher is now active. + // Process the stream in the background. + let reader: ReadableStreamDefaultReader | null = null; (async () => { try { - for await (const evt of response.stream) { - try { - const watchEvent = parseStreamEvent(evt); - - // @ts-ignore - if (watchEvent.type === "connected") { - barrier.open(); - } else { - onEvent(watchEvent); + if (!response.body) return; + reader = response.body + .pipeThrough(new TextDecoderStream()) + .getReader(); + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + const chunks = buffer.split("\n\n"); + buffer = chunks.pop() ?? ""; + for (const chunk of chunks) { + const dataLine = chunk + .split("\n") + .find((l) => l.startsWith("data:")); + if (!dataLine) continue; + try { + const data = JSON.parse(dataLine.replace(/^data:\s*/, "")); + onEvent(parseStreamEvent(data)); + } catch (e) { + console.warn("Failed to parse filesystem watch event:", e); } - } catch (error) { - console.warn("Failed to parse filesystem watch event:", error); } } } catch (error) { - console.error("Filesystem watch stream error:", error); + if ((error as Error)?.name !== "AbortError") { + console.error("Filesystem watch stream error:", error); + } } })(); - await barrier.wait(); - return { type: "success", dispose(): void { + reader?.cancel(); abortController.abort(); }, }; diff --git a/tests/pint-fs-watcher.test.ts b/tests/pint-fs-watcher.test.ts index 5acf273..542a696 100644 --- a/tests/pint-fs-watcher.test.ts +++ b/tests/pint-fs-watcher.test.ts @@ -1,268 +1,171 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import * as http from 'node:http' import { PintFsClient } from '../src/PintClient/fs' -import { Client } from '../src/api-clients/pint/client' -import * as pintApi from '../src/api-clients/pint' +import { createClient, createConfig } from '../src/api-clients/pint/client' + +/** + * Creates a minimal mock server that mimics pint's SSE watcher endpoint. + * Mirrors the Go test helper `setupV1TestServer` in the pint project. + * + * The server guarantees the watcher is active before sending 200 OK, + * just like pint's `CreateWatcher` uses the `ready` channel. + */ +function createMockPintServer() { + let activeSseResponse: http.ServerResponse | null = null + + const server = http.createServer((req, res) => { + if (req.url?.includes('/api/v1/stream/directories/watcher/')) { + // Simulate pint: watcher is set up synchronously before headers are written. + // The 200 OK signals to the client that the watcher is fully active. + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + res.flushHeaders() + activeSseResponse = res + req.on('close', () => { + activeSseResponse = null + }) + } else { + res.writeHead(404) + res.end() + } + }) -// Mock the pint API functions -vi.mock('../src/api-clients/pint', () => ({ - createWatcher: vi.fn(), - createFile: vi.fn(), - readFile: vi.fn(), - listDirectory: vi.fn(), - deleteDirectory: vi.fn(), - createDirectory: vi.fn(), - getFileStat: vi.fn(), - performFileAction: vi.fn(), -})) + return { + server, + /** Send a filesystem event over the active SSE connection. */ + sendEvent(event: { paths: string[]; type: string }) { + activeSseResponse?.write(`data: ${JSON.stringify(event)}\n\n`) + }, + isConnected() { + return activeSseResponse !== null + }, + } +} describe('PintFsClient filesystem watcher', () => { + let server: http.Server + let sendEvent: (event: { paths: string[]; type: string }) => void + let isConnected: () => boolean let fsClient: PintFsClient - let mockApiClient: Client - let mockCreateWatcher: any - - beforeEach(() => { - // Create a mock API client - mockApiClient = {} as Client - - // Create instance of PintFsClient - fsClient = new PintFsClient(mockApiClient) - - // Get reference to mocked functions - mockCreateWatcher = vi.mocked(pintApi.createWatcher) + let port: number + let activeWatcher: { dispose(): void } | null = null + + beforeEach(async () => { + activeWatcher = null + const mock = createMockPintServer() + server = mock.server + sendEvent = mock.sendEvent + isConnected = mock.isConnected + + await new Promise((resolve) => server.listen(0, resolve)) + port = (server.address() as http.AddressInfo).port + + const apiClient = createClient( + createConfig({ + baseUrl: `http://localhost:${port}`, + headers: { Authorization: 'Bearer test-token' }, + }) + ) + fsClient = new PintFsClient(apiClient) }) - afterEach(() => { - vi.clearAllMocks() + afterEach(async () => { + // Dispose any active watcher to close the SSE connection so server.close() can complete. + activeWatcher?.dispose() + activeWatcher = null + await new Promise((resolve) => server.close(() => resolve())) }) - it('should successfully start watching a directory', async () => { - const path = '/test/directory' - const options = { recursive: true, excludes: ['*.log', 'node_modules/*'] } - const onEvent = vi.fn() - - // Mock the stream generator - async function* mockStream() { - yield 'data: {"paths": ["/test/directory/file1.txt"], "type": "add"}' - yield 'data: {"paths": ["/test/directory/file2.txt"], "type": "change"}' - } - - // Mock createWatcher to return a stream - mockCreateWatcher.mockResolvedValue({ - stream: mockStream() - }) - - // Call watch method - const result = await fsClient.watch(path, options, onEvent) + it('watch() resolves only after the server has confirmed the watcher is active (200 OK)', async () => { + // Mirrors TestFileWatcherIsReadyWhenConnectionEstablished: + // The watcher must be active the moment watch() resolves — no sleep needed. + const result = await fsClient.watch('/sandbox/project', { recursive: true }, () => {}) - // Verify the result expect(result.type).toBe('success') - expect(result).toHaveProperty('dispose') - - // Verify createWatcher was called with correct parameters - expect(mockCreateWatcher).toHaveBeenCalledWith({ - client: mockApiClient, - path: { path }, - query: { - recursive: true, - ignorePatterns: ['*.log', 'node_modules/*'] - }, - signal: expect.any(AbortSignal) - }) - - // Wait a bit for the async stream processing - await new Promise(resolve => setTimeout(resolve, 100)) + expect(isConnected()).toBe(true) - // Verify events were parsed and fired - expect(onEvent).toHaveBeenCalledTimes(2) - expect(onEvent).toHaveBeenCalledWith({ - paths: ['/test/directory/file1.txt'], - type: 'add' - }) - expect(onEvent).toHaveBeenCalledWith({ - paths: ['/test/directory/file2.txt'], - type: 'change' - }) + if (result.type === 'success') activeWatcher = result }) - it('should handle watcher with minimal options', async () => { - const path = '/simple/path' - const options = {} - const onEvent = vi.fn() - - // Mock empty stream - async function* mockStream() { - // Empty stream - } + it('delivers SSE events to onEvent immediately after watch() resolves', async () => { + // Mirrors the core of TestFileWatcherIsReadyWhenConnectionEstablished: + // send an event right after watch() resolves, no sleep. + const events: Array<{ paths: string[]; type: string }> = [] - mockCreateWatcher.mockResolvedValue({ - stream: mockStream() + const result = await fsClient.watch('/sandbox/project', { recursive: true }, (event) => { + events.push(event as any) }) - const result = await fsClient.watch(path, options, onEvent) - expect(result.type).toBe('success') - expect(mockCreateWatcher).toHaveBeenCalledWith({ - client: mockApiClient, - path: { path }, - query: { - recursive: undefined, - ignorePatterns: undefined - }, - signal: expect.any(AbortSignal) - }) - }) + if (result.type === 'success') activeWatcher = result - it('should handle filesystem events correctly', async () => { - const path = '/test/path' - const options = { recursive: false } - const onEvent = vi.fn() + // Send event immediately — watcher is already active, no sleep needed. + sendEvent({ paths: ['/sandbox/project/new-file.txt'], type: 'ADD' }) - // Mock stream with different event types - async function* mockStream() { - yield 'data: {"paths": ["/test/path/new-file.txt"], "type": "add"}' - yield 'data: {"paths": ["/test/path/modified-file.txt"], "type": "change"}' - yield 'data: {"paths": ["/test/path/deleted-file.txt"], "type": "remove"}' - } - - mockCreateWatcher.mockResolvedValue({ - stream: mockStream() - }) + // Wait for the event loop to process the SSE data. + await new Promise((resolve) => setTimeout(resolve, 200)) - const result = await fsClient.watch(path, options, onEvent) - expect(result.type).toBe('success') - - // Wait for stream processing - await new Promise(resolve => setTimeout(resolve, 100)) - - // Verify all event types were handled - expect(onEvent).toHaveBeenCalledTimes(3) - expect(onEvent).toHaveBeenNthCalledWith(1, { - paths: ['/test/path/new-file.txt'], - type: 'add' - }) - expect(onEvent).toHaveBeenNthCalledWith(2, { - paths: ['/test/path/modified-file.txt'], - type: 'change' - }) - expect(onEvent).toHaveBeenNthCalledWith(3, { - paths: ['/test/path/deleted-file.txt'], - type: 'remove' - }) + expect(events).toHaveLength(1) + expect(events[0]).toEqual({ paths: ['/sandbox/project/new-file.txt'], type: 'ADD' }) }) - it('should handle malformed stream events gracefully', async () => { - const path = '/test/path' - const options = {} - const onEvent = vi.fn() - - // Mock stream with malformed data - async function* mockStream() { - yield 'data: {"paths": ["/test/path/good-file.txt"], "type": "add"}' - yield 'data: invalid json' - yield 'data: {"paths": ["/test/path/another-good-file.txt"], "type": "change"}' - } + it('delivers multiple event types (ADD, CHANGE, REMOVE)', async () => { + const events: Array<{ paths: string[]; type: string }> = [] - mockCreateWatcher.mockResolvedValue({ - stream: mockStream() + const result = await fsClient.watch('/sandbox/project', {}, (event) => { + events.push(event as any) }) - - // Spy on console.warn to verify error handling - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const result = await fsClient.watch(path, options, onEvent) expect(result.type).toBe('success') + if (result.type === 'success') activeWatcher = result - // Wait for stream processing - await new Promise(resolve => setTimeout(resolve, 100)) - - // Verify only valid events were processed - expect(onEvent).toHaveBeenCalledTimes(2) - expect(onEvent).toHaveBeenNthCalledWith(1, { - paths: ['/test/path/good-file.txt'], - type: 'add' - }) - expect(onEvent).toHaveBeenNthCalledWith(2, { - paths: ['/test/path/another-good-file.txt'], - type: 'change' - }) + sendEvent({ paths: ['/sandbox/project/a.txt'], type: 'ADD' }) + sendEvent({ paths: ['/sandbox/project/b.txt'], type: 'CHANGE' }) + sendEvent({ paths: ['/sandbox/project/c.txt'], type: 'REMOVE' }) - // Verify warning was logged for malformed data - expect(consoleSpy).toHaveBeenCalledWith( - 'Failed to parse filesystem watch event:', - expect.any(Error) - ) + await new Promise((resolve) => setTimeout(resolve, 200)) - consoleSpy.mockRestore() + expect(events).toHaveLength(3) + expect(events[0].type).toBe('ADD') + expect(events[1].type).toBe('CHANGE') + expect(events[2].type).toBe('REMOVE') }) - it('should allow disposal of watcher', async () => { - const path = '/test/path' - const options = {} - const onEvent = vi.fn() - - // Mock stream that would run indefinitely - async function* mockStream() { - let count = 0 - while (true) { - yield `data: {"paths": ["/test/path/file${count}.txt"], "type": "add"}` - count++ - // Add a small delay to prevent tight loop - await new Promise(resolve => setTimeout(resolve, 10)) - } - } - - mockCreateWatcher.mockResolvedValue({ - stream: mockStream() + it('returns error when server returns non-200', async () => { + // Close the default server and replace with one that returns 400. + await new Promise((resolve) => server.close(() => resolve())) + server = http.createServer((_req, res) => { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ message: 'Directory not found', code: 400 })) }) + await new Promise((resolve) => server.listen(port, resolve)) - const result = await fsClient.watch(path, options, onEvent) - expect(result.type).toBe('success') - - if (result.type === 'success') { - expect(typeof result.dispose).toBe('function') - - // Let it run for a bit - await new Promise(resolve => setTimeout(resolve, 50)) - - // Dispose the watcher - result.dispose() - - // The dispose function should abort the controller - expect(() => result.dispose()).not.toThrow() - } - }) - - it('should handle createWatcher promise rejection', async () => { - const path = '/test/path' - const options = {} - const onEvent = vi.fn() - - // Mock createWatcher to reject - mockCreateWatcher.mockRejectedValue(new Error('Network error')) - - const result = await fsClient.watch(path, options, onEvent) + const result = await fsClient.watch('/nonexistent/path', {}, () => {}) expect(result.type).toBe('error') - if (result.type === 'error') { - expect(result.error).toBe('Network error') - expect(result.errno).toBe(null) - } }) - it('should handle unknown errors', async () => { - const path = '/test/path' - const options = {} - const onEvent = vi.fn() + it('stops receiving events after dispose()', async () => { + const events: Array<{ paths: string[]; type: string }> = [] - // Mock createWatcher to reject with non-Error - mockCreateWatcher.mockRejectedValue('String error') + const result = await fsClient.watch('/sandbox/project', {}, (event) => { + events.push(event as any) + }) + expect(result.type).toBe('success') + if (result.type !== 'success') return - const result = await fsClient.watch(path, options, onEvent) + sendEvent({ paths: ['/sandbox/project/before.txt'], type: 'ADD' }) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(events).toHaveLength(1) - expect(result.type).toBe('error') - if (result.type === 'error') { - expect(result.error).toBe('Unknown error') - expect(result.errno).toBe(null) - } + result.dispose() + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Any events sent after dispose should not arrive. + sendEvent({ paths: ['/sandbox/project/after.txt'], type: 'ADD' }) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(events).toHaveLength(1) }) -}) \ No newline at end of file +}) From 86fd630ef4f5672eadf50ec559e9062efc392034 Mon Sep 17 00:00:00 2001 From: Christian Alfoni Date: Mon, 9 Mar 2026 13:06:58 +0100 Subject: [PATCH 37/46] include all filesystem tests again --- package-lock.json | 1020 ++++++++++++++++++++++++-- tests/e2e/sandbox-filesystem.test.ts | 2 +- 2 files changed, 974 insertions(+), 48 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9ad8d83..562704a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -111,6 +111,74 @@ "node": ">=0.1.90" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/darwin-arm64": { "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", @@ -128,6 +196,346 @@ "node": ">=18" } }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", + "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@hey-api/client-fetch": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.13.1.tgz", @@ -926,78 +1334,330 @@ "node": ">=14" } }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.34.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.34.0.tgz", - "integrity": "sha512-aKcOkyrorBGlajjRdVoJWHTxfxO1vCNHLJVlSDaRHDIdjU+pX8IYQPvPDkYiujKLbRnWU+1TBwEt0QRgSm4SGA==", - "license": "Apache-2.0", + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.34.0.tgz", + "integrity": "sha512-aKcOkyrorBGlajjRdVoJWHTxfxO1vCNHLJVlSDaRHDIdjU+pX8IYQPvPDkYiujKLbRnWU+1TBwEt0QRgSm4SGA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sql-common": { + "version": "0.40.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.40.1.tgz", + "integrity": "sha512-nSDlnHSqzC3pXn/wZEZVLuAuJ1MYMXPBwtv2qAbCa3847SaHItdE7SzUq/Jtb0KZmh1zfAbNi3AAMjztTT4Ugg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@opentelemetry/core": "^1.1.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@opentelemetry/sql-common": { - "version": "0.40.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.40.1.tgz", - "integrity": "sha512-nSDlnHSqzC3pXn/wZEZVLuAuJ1MYMXPBwtv2qAbCa3847SaHItdE7SzUq/Jtb0KZmh1zfAbNi3AAMjztTT4Ugg==", - "license": "Apache-2.0", + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", "optional": true, - "dependencies": { - "@opentelemetry/core": "^1.1.0" - }, + "os": [ + "win32" + ], "engines": { - "node": ">=14" + "node": ">= 10.0.0" }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@parcel/watcher": { + "node_modules/@parcel/watcher-win32-ia32": { "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", - "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], "dev": true, - "hasInstallScript": true, - "dependencies": { - "detect-libc": "^1.0.3", - "is-glob": "^4.0.3", - "micromatch": "^4.0.5", - "node-addon-api": "^7.0.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { "node": ">= 10.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.1", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-freebsd-x64": "2.5.1", - "@parcel/watcher-linux-arm-glibc": "2.5.1", - "@parcel/watcher-linux-arm-musl": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-ia32": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1" } }, - "node_modules/@parcel/watcher-darwin-arm64": { + "node_modules/@parcel/watcher-win32-x64": { "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", - "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", "cpu": [ - "arm64" + "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "win32" ], "engines": { "node": ">= 10.0.0" @@ -1020,6 +1680,34 @@ "@opentelemetry/api": "^1.8" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.47.0.tgz", + "integrity": "sha512-Weap5hVbZs/yIvUZcFpAmIso8rLmwkO1LesddNjeX28tIhQkAKjRuVgAJ2xpj8wXTny7IZro9aBIgGov0qsL4A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.47.0.tgz", + "integrity": "sha512-XcnlqvG5riTJByKX7bZ1ehe48GiF+eNkdnzV0ziLp85XyJ6tLPfhkXHv3e0h3cpZESTQa8IB+ZHhV/r02+8qKw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.47.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.47.0.tgz", @@ -1034,6 +1722,244 @@ "darwin" ] }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.47.0.tgz", + "integrity": "sha512-WaMrgHRbFspYjvycbsbqheBmlsQBLwfZVWv/KFsT212Yz/RjEQ/9KEp1/p0Ef3ZNwbWsylmgf69St66D9NQNHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.47.0.tgz", + "integrity": "sha512-umfYslurvSmAK5MEyOcOGooQ6EBB2pYePQaTVlrOkIfG6uuwu9egYOlxr35lwsp6XG0NzmXW0/5o150LUioMkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.47.0.tgz", + "integrity": "sha512-EFXhIykAl8//4ihOjGNirF89HEUbOB8ev2aiw8ST8wFGwDdIPARh3enDlbp8aFnScl4CDK4DZLQYXaM6qpxzZw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.47.0.tgz", + "integrity": "sha512-EwkC5N61ptruQ9wNkYfLgUWEGh+F3JZSGHkUWhaK2ISAK0d0xmiMKF0trFhRqPQFov5d9DmFiFIhWB5IC79OUA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.47.0.tgz", + "integrity": "sha512-Iz/g1X94vIjppA4H9hN3VEedw4ObC+u+aua2J/VPJnENEJ0GeCAPBN15nJc5pS5M8JPlUhOd3oqhOWX6Un4RHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.47.0.tgz", + "integrity": "sha512-eYEYHYjFo/vb6k1l5uq5+Af9yuo9WaST/z+/8T5gkee+A0Sfx1NIPZtKMEQOLjm/oaeHFGpWaAO97gTPhouIfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.47.0.tgz", + "integrity": "sha512-LX2x0/RszFEmDfjzL6kG/vihD5CkpJ+0K6lcbqX0jAopkkXeY2ZjStngdFMFW+BK7pyrqryJgy6Jt3+oyDxrSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.47.0.tgz", + "integrity": "sha512-0U+56rJmJvqBCwlPFz/BcxkvdiRdNPamBfuFHrOGQtGajSMJ2OqzlvOgwj5vReRQnSA6XMKw/JL1DaBhceil+g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.47.0.tgz", + "integrity": "sha512-2VKOsnNyvS05HFPKtmAWtef+nZyKCot/V3Jh/A5sYMhUvtthNjp6CjakYTtc5xZ8J8Fp5FKrUWGxptVtZ2OzEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.47.0.tgz", + "integrity": "sha512-uY5UP7YZM4DMQiiP9Fl4/7O3UbT2p3uI0qvqLXZSGWBfyYuqi2DYQ48ExylgBN3T8AJork+b+mLGq6VXsxBfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.47.0.tgz", + "integrity": "sha512-qpcN2+/ivq3TcrXtZoHrS9WZplV3Nieh0gvnGb+SFZg7h/YkWsOXINJnjJRWHp9tEur7T8lMnMeQMPS7s9MjUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.47.0.tgz", + "integrity": "sha512-XfuI+o7a2/KA2tBeP+J1CT3siyIQyjpGEL6fFvtUdoHJK1k5iVI3qeGT2i5y6Bb+xQu08AHKBsUGJ2GsOZzXbQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.47.0.tgz", + "integrity": "sha512-ylkLO6G7oUiN28mork3caDmgXHqRuopAxjYDaOqs4CoU9pkfR0R/pGQb2V1x2Zg3tlFj4b/DvxZroxC3xALX6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.47.0.tgz", + "integrity": "sha512-1L72a+ice8xKqJ2afsAVW9EfECOhNMAOC1jH65TgghLaHSFwNzyEdeye+1vRFDNy52OGKip/vajj0ONtX7VpAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.47.0.tgz", + "integrity": "sha512-wluhdd1uNLk/S+ex2Yj62WFw3un2cZo2ZKXy9cOuoti5IhaPXSDSvxT3os+SJ1cjNorE1PwAOfiJU7QUH6n3Zw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.47.0.tgz", + "integrity": "sha512-0SMTA6AeG7u2rfwdkKSo6aZD/obmA7oyhR+4ePwLzlwxNE8sfSI9zmjZXtchvBAZmtkVQNt/lZ6RxSl9wBj4pw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.47.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.47.0.tgz", + "integrity": "sha512-mw1/7kAGxLcfzoG7DIKFHvKr2ZUQasKOPCgT2ubkNZPgIDZOJPymqThtRWEeAlXBoipehP4BUFpBAZIrPhFg8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@sentry/core": { "version": "9.29.0", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.29.0.tgz", diff --git a/tests/e2e/sandbox-filesystem.test.ts b/tests/e2e/sandbox-filesystem.test.ts index 8de4f24..6b3dfd9 100644 --- a/tests/e2e/sandbox-filesystem.test.ts +++ b/tests/e2e/sandbox-filesystem.test.ts @@ -275,7 +275,7 @@ describe("Sandbox Filesystem", () => { }); describe("File watching", () => { - it.only("should detect file system changes", async () => { + it("should detect file system changes", async () => { if (!client) throw new Error("Client not initialized"); try { From 3b9296a87dbf54f3afb48675a2f4cc13da1d51fb Mon Sep 17 00:00:00 2001 From: Christian Alfoni Date: Tue, 10 Mar 2026 12:36:02 +0100 Subject: [PATCH 38/46] bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d6fc635..548cc07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codesandbox/sdk", - "version": "2.4.1", + "version": "2.5.0", "description": "The CodeSandbox SDK", "author": "CodeSandbox", "license": "MIT", From d67eece6e18826b4a49828761483c83cbd1019dd Mon Sep 17 00:00:00 2001 From: Joji Augustine Date: Thu, 12 Mar 2026 18:02:06 +0100 Subject: [PATCH 39/46] add benchmarks for comparing pitcher vs pint --- package-lock.json | 4 +- package.json | 1 + tests/benchmark/lifecycle.test.ts | 331 ++++++++++++++++++++++++++++++ vitest.benchmark.config.ts | 12 ++ 4 files changed, 346 insertions(+), 2 deletions(-) create mode 100644 tests/benchmark/lifecycle.test.ts create mode 100644 vitest.benchmark.config.ts diff --git a/package-lock.json b/package-lock.json index 562704a..62a0277 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@codesandbox/sdk", - "version": "2.4.1", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@codesandbox/sdk", - "version": "2.4.1", + "version": "2.5.0", "license": "MIT", "dependencies": { "@hey-api/client-fetch": "^0.13.1", diff --git a/package.json b/package.json index 548cc07..68f3139 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "postbuild": "rimraf {lib,es}/**/__tests__ {lib,es}/**/*.{spec,test}.{js,d.ts,js.map}", "postversion": "git push && git push --tags", "prepublish": "npm run build", + "benchmark": "vitest run --config vitest.benchmark.config.ts", "demo:install": "cd demo && npm install", "demo:dev": "cd demo && npm run dev", "demo:build": "cd demo && npm run build" diff --git a/tests/benchmark/lifecycle.test.ts b/tests/benchmark/lifecycle.test.ts new file mode 100644 index 0000000..a2a14d2 --- /dev/null +++ b/tests/benchmark/lifecycle.test.ts @@ -0,0 +1,331 @@ +/** + * Sandbox Operation Benchmark + * + * Measures timing for: create, hibernate, resume, fork, shutdown + * Runs N iterations and reports avg, median, p50, p90, p95, p99 per operation. + * + * Usage: + * CSB_API_KEY= CSB_TEMPLATE_ID= npm run benchmark + * CSB_API_KEY= CSB_TEMPLATE_ID= CSB_ITERATIONS=10 npm run benchmark + * + * Environment Variables: + * CSB_API_KEY CodeSandbox API key (required) + * CSB_TEMPLATE_ID Template ID to fork from (required) + * CSB_BASE_URL API base URL (default: https://api.codesandbox.io) + * CSB_ITERATIONS Number of benchmark iterations (default: 5) + */ + +import { test } from "vitest"; +import { CodeSandbox, Sandbox } from "../../src/index.js"; + +// --------------------------------------------------------------------------- +// CLI / env argument parsing +// --------------------------------------------------------------------------- + +function parseArgs() { + const templateId = process.env.CSB_TEMPLATE_ID; + const iterations = process.env.CSB_ITERATIONS + ? parseInt(process.env.CSB_ITERATIONS, 10) + : 5; + + if (!templateId) { + throw new Error("CSB_TEMPLATE_ID environment variable is required."); + } + + if (!process.env.CSB_API_KEY) { + throw new Error("CSB_API_KEY environment variable is required."); + } + + return { templateId, iterations }; +} + +// --------------------------------------------------------------------------- +// SDK initialisation +// --------------------------------------------------------------------------- + +function initSDK(): CodeSandbox { + const baseUrl = process.env.CSB_BASE_URL ?? "https://api.codesandbox.io"; + return new CodeSandbox(process.env.CSB_API_KEY, { baseUrl }); +} + +// --------------------------------------------------------------------------- +// Timing helpers +// --------------------------------------------------------------------------- + +async function timeMs(fn: () => Promise): Promise<[T, number]> { + const start = performance.now(); + const result = await fn(); + return [result, performance.now() - start]; +} + +// --------------------------------------------------------------------------- +// Statistics +// --------------------------------------------------------------------------- + +interface Stats { + samples: number; + avg: number; + min: number; + max: number; + median: number; + p50: number; + p90: number; + p95: number; + p99: number; +} + +function computeStats(values: number[]): Stats { + const sorted = [...values].sort((a, b) => a - b); + const n = sorted.length; + + const pct = (p: number) => { + const rank = Math.ceil((p / 100) * n); + return sorted[Math.min(rank, n) - 1]; + }; + + const avg = values.reduce((sum, v) => sum + v, 0) / n; + + return { + samples: n, + avg, + min: sorted[0], + max: sorted[n - 1], + median: pct(50), + p50: pct(50), + p90: pct(90), + p95: pct(95), + p99: pct(99), + }; +} + +// --------------------------------------------------------------------------- +// Result storage +// --------------------------------------------------------------------------- + +type OperationName = "create" | "hibernate" | "resume" | "fork" | "shutdown"; + +const timings: Record = { + create: [], + hibernate: [], + resume: [], + fork: [], + shutdown: [], +}; + +const errors: Record = { + create: 0, + hibernate: 0, + resume: 0, + fork: 0, + shutdown: 0, +}; + +function record(op: OperationName, ms: number) { + timings[op].push(ms); +} + +function recordError(op: OperationName) { + errors[op]++; +} + +// --------------------------------------------------------------------------- +// Cleanup helper +// --------------------------------------------------------------------------- + +async function tryCleanup(sdk: CodeSandbox, sandboxId: string) { + try { + await sdk.sandboxes.shutdown(sandboxId); + } catch { + /* best effort */ + } + try { + await sdk.sandboxes.delete(sandboxId); + } catch { + /* best effort */ + } +} + +// --------------------------------------------------------------------------- +// Single benchmark iteration +// --------------------------------------------------------------------------- + +async function runIteration( + sdk: CodeSandbox, + templateId: string, + index: number +): Promise { + console.log(`\n── Iteration ${index + 1} ──────────────────────────────`); + let sandbox: Sandbox | undefined; + let forkedSandbox: Sandbox | undefined; + + try { + // ── create ──────────────────────────────────────────────────────────────── + process.stdout.write(" create "); + let ms: number; + try { + [sandbox, ms] = await timeMs(() => + sdk.sandboxes.create({ id: templateId, tags: ["benchmark"] }) + ); + record("create", ms); + console.log(`${ms.toFixed(0)} ms ✓ (id: ${sandbox.id})`); + } catch (err) { + console.log(`FAILED ✗ ${String(err)}`); + recordError("create"); + return; // Cannot continue without a sandbox + } + + const sandboxId = sandbox.id; + + // ── hibernate ───────────────────────────────────────────────────────────── + process.stdout.write(" hibernate "); + try { + [, ms] = await timeMs(() => sdk.sandboxes.hibernate(sandboxId)); + record("hibernate", ms); + console.log(`${ms.toFixed(0)} ms ✓`); + } catch (err) { + console.log(`FAILED ✗ ${String(err)}`); + recordError("hibernate"); + await tryCleanup(sdk, sandboxId); + return; + } + + // ── resume ──────────────────────────────────────────────────────────────── + process.stdout.write(" resume "); + try { + [sandbox, ms] = await timeMs(() => sdk.sandboxes.resume(sandboxId)); + record("resume", ms); + console.log(`${ms.toFixed(0)} ms ✓`); + } catch (err) { + console.log(`FAILED ✗ ${String(err)}`); + recordError("resume"); + await tryCleanup(sdk, sandboxId); + return; + } + + // ── fork ────────────────────────────────────────────────────────────────── + process.stdout.write(" fork "); + try { + [forkedSandbox, ms] = await timeMs(() => + sdk.sandboxes.create({ id: sandboxId, tags: ["benchmark-fork"] }) + ); + record("fork", ms); + console.log(`${ms.toFixed(0)} ms ✓ (fork id: ${forkedSandbox.id})`); + } catch (err) { + console.log(`FAILED ✗ ${String(err)}`); + recordError("fork"); + } + + if (forkedSandbox) { + await tryCleanup(sdk, forkedSandbox.id); + } + + // ── shutdown ────────────────────────────────────────────────────────────── + process.stdout.write(" shutdown "); + try { + [, ms] = await timeMs(() => sdk.sandboxes.shutdown(sandboxId)); + record("shutdown", ms); + console.log(`${ms.toFixed(0)} ms ✓`); + } catch (err) { + console.log(`FAILED ✗ ${String(err)}`); + recordError("shutdown"); + } + } finally { + if (sandbox) { + await tryCleanup(sdk, sandbox.id); + } + } +} + +// --------------------------------------------------------------------------- +// Report +// --------------------------------------------------------------------------- + +const METRIC_NAMES: Record = { + create: "sandbox_create_duration", + hibernate: "sandbox_hibernate_duration", + resume: "sandbox_resume_duration", + fork: "sandbox_fork_duration", + shutdown: "sandbox_shutdown_duration", +}; + +const LABEL_WIDTH = Math.max(...Object.values(METRIC_NAMES).map((n) => n.length)) + 2; + +function metricLabel(op: OperationName): string { + const name = METRIC_NAMES[op]; + const dots = ".".repeat(LABEL_WIDTH - name.length); + return `${name}${dots}`; +} + +const CYAN = "\x1b[96m"; +const RESET = "\x1b[0m"; + +function fmtField(key: string, ms: number, valueWidth: number): string { + const raw = `${(ms / 1000).toFixed(2)}s`; + const padded = raw.padEnd(valueWidth); + return `${key}=${CYAN}${padded}${RESET}`; +} + +function printReport() { + const ops: OperationName[] = [ + "create", + "hibernate", + "resume", + "fork", + "shutdown", + ]; + + console.log("\n"); + console.log("benchmark results"); + + for (const op of ops) { + const label = metricLabel(op); + const samples = timings[op]; + const errCount = errors[op]; + + if (samples.length === 0) { + console.log(`${label}: no data errors=${errCount}`); + continue; + } + + const s = computeStats(samples); + + const row = [ + fmtField("avg", s.avg, 8), + fmtField("min", s.min, 8), + fmtField("med", s.median, 8), + fmtField("max", s.max, 8), + fmtField("p(90)", s.p90, 8), + fmtField("p(95)", s.p95, 8), + fmtField("p(99)", s.p99, 8), + `n=${s.samples}`, + ...(errCount > 0 ? [`errors=${errCount}`] : []), + ].join(" "); + + console.log(`${label}: ${row}`); + } +} + +// --------------------------------------------------------------------------- +// Vitest test entry point +// --------------------------------------------------------------------------- + +const { templateId, iterations } = parseArgs(); + +// Allow up to 5 minutes per iteration plus overhead +const TIMEOUT_MS = (iterations + 1) * 5 * 60 * 1000; + +test("sandbox benchmark", { timeout: TIMEOUT_MS }, async () => { + const sdk = initSDK(); + + const baseUrl = process.env.CSB_BASE_URL ?? "https://api.codesandbox.io"; + console.log("Sandbox Benchmark"); + console.log(` Template: ${templateId}`); + console.log(` Iterations: ${iterations}`); + console.log(` API URL: ${baseUrl}`); + + for (let i = 0; i < iterations; i++) { + await runIteration(sdk, templateId, i); + } + + printReport(); +}); diff --git a/vitest.benchmark.config.ts b/vitest.benchmark.config.ts new file mode 100644 index 0000000..bcf0eab --- /dev/null +++ b/vitest.benchmark.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/benchmark/**/*.test.ts"], + reporters: ["verbose"], + }, + define: { + CSB_SDK_VERSION: JSON.stringify("2.5.0"), + }, +}); From e96c3503fc948e755dbf00d12d522e5bf3a50cc7 Mon Sep 17 00:00:00 2001 From: Joji Augustine Date: Thu, 12 Mar 2026 22:49:27 +0100 Subject: [PATCH 40/46] add working benchmark --- tests/benchmark/lifecycle.test.ts | 188 +++++++++++++++++++----------- 1 file changed, 122 insertions(+), 66 deletions(-) diff --git a/tests/benchmark/lifecycle.test.ts b/tests/benchmark/lifecycle.test.ts index a2a14d2..fdeba8e 100644 --- a/tests/benchmark/lifecycle.test.ts +++ b/tests/benchmark/lifecycle.test.ts @@ -1,22 +1,25 @@ /** * Sandbox Operation Benchmark * - * Measures timing for: create, hibernate, resume, fork, shutdown + * Measures timing for: create, hibernate, resume, shutdown, start (after shutdown) + * Optionally measures time-to-port-ready for: create, resume, start (set CSB_PORT) * Runs N iterations and reports avg, median, p50, p90, p95, p99 per operation. * * Usage: * CSB_API_KEY= CSB_TEMPLATE_ID= npm run benchmark - * CSB_API_KEY= CSB_TEMPLATE_ID= CSB_ITERATIONS=10 npm run benchmark + * CSB_API_KEY= CSB_TEMPLATE_ID= CSB_ITERATIONS=10 CSB_PORT=3000 npm run benchmark * * Environment Variables: * CSB_API_KEY CodeSandbox API key (required) * CSB_TEMPLATE_ID Template ID to fork from (required) * CSB_BASE_URL API base URL (default: https://api.codesandbox.io) * CSB_ITERATIONS Number of benchmark iterations (default: 5) + * CSB_PORT Port to wait for after create/resume/start (optional) */ import { test } from "vitest"; import { CodeSandbox, Sandbox } from "../../src/index.js"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; // --------------------------------------------------------------------------- // CLI / env argument parsing @@ -27,6 +30,9 @@ function parseArgs() { const iterations = process.env.CSB_ITERATIONS ? parseInt(process.env.CSB_ITERATIONS, 10) : 5; + const port = process.env.CSB_PORT + ? parseInt(process.env.CSB_PORT, 10) + : undefined; if (!templateId) { throw new Error("CSB_TEMPLATE_ID environment variable is required."); @@ -36,7 +42,7 @@ function parseArgs() { throw new Error("CSB_API_KEY environment variable is required."); } - return { templateId, iterations }; + return { templateId, iterations, port }; } // --------------------------------------------------------------------------- @@ -102,22 +108,36 @@ function computeStats(values: number[]): Stats { // Result storage // --------------------------------------------------------------------------- -type OperationName = "create" | "hibernate" | "resume" | "fork" | "shutdown"; +type OperationName = + | "create" + | "hibernate" + | "resume" + | "shutdown" + | "start_after_shutdown" + | "create_to_port_ready" + | "resume_to_port_ready" + | "start_after_shutdown_to_port_ready"; const timings: Record = { create: [], hibernate: [], resume: [], - fork: [], shutdown: [], + start_after_shutdown: [], + create_to_port_ready: [], + resume_to_port_ready: [], + start_after_shutdown_to_port_ready: [], }; const errors: Record = { create: 0, hibernate: 0, resume: 0, - fork: 0, shutdown: 0, + start_after_shutdown: 0, + create_to_port_ready: 0, + resume_to_port_ready: 0, + start_after_shutdown_to_port_ready: 0, }; function record(op: OperationName, ms: number) { @@ -145,6 +165,38 @@ async function tryCleanup(sdk: CodeSandbox, sandboxId: string) { } } +// --------------------------------------------------------------------------- +// Port readiness helper +// Measures time from `opStart` until the given port is ready on the sandbox. +// --------------------------------------------------------------------------- + +async function measurePortReady( + sandbox: Sandbox, + port: number, + opName: OperationName, + opStart: number +): Promise { + let client: SandboxClient | undefined; + try { + client = await sandbox.connect(); + await client.ports.waitForPort(port, { timeoutMs: 120_000 }); + record(opName, performance.now() - opStart); + console.log( + ` Port ${port} ready ${((performance.now() - opStart) / 1000).toFixed(2)}s ✓` + ); + } catch (err) { + console.log(` Port ${port} not ready ✗ ${String(err)}`); + recordError(opName); + } finally { + try { + await client?.disconnect(); + client?.dispose(); + } catch { + /* best effort */ + } + } +} + // --------------------------------------------------------------------------- // Single benchmark iteration // --------------------------------------------------------------------------- @@ -152,83 +204,103 @@ async function tryCleanup(sdk: CodeSandbox, sandboxId: string) { async function runIteration( sdk: CodeSandbox, templateId: string, + port: number | undefined, index: number ): Promise { console.log(`\n── Iteration ${index + 1} ──────────────────────────────`); let sandbox: Sandbox | undefined; - let forkedSandbox: Sandbox | undefined; try { // ── create ──────────────────────────────────────────────────────────────── - process.stdout.write(" create "); + console.log(" Creating..."); let ms: number; + let opStart: number; try { + opStart = performance.now(); [sandbox, ms] = await timeMs(() => sdk.sandboxes.create({ id: templateId, tags: ["benchmark"] }) ); record("create", ms); - console.log(`${ms.toFixed(0)} ms ✓ (id: ${sandbox.id})`); + console.log(` Created ${(ms / 1000).toFixed(2)}s ✓ (id: ${sandbox.id})`); } catch (err) { - console.log(`FAILED ✗ ${String(err)}`); + console.log(` Failed creating ✗ ${String(err)}`); recordError("create"); - return; // Cannot continue without a sandbox + return; + } + + if (port) { + await measurePortReady(sandbox, port, "create_to_port_ready", opStart!); } const sandboxId = sandbox.id; // ── hibernate ───────────────────────────────────────────────────────────── - process.stdout.write(" hibernate "); + console.log(" Hibernating..."); try { [, ms] = await timeMs(() => sdk.sandboxes.hibernate(sandboxId)); record("hibernate", ms); - console.log(`${ms.toFixed(0)} ms ✓`); + console.log(` Hibernated ${(ms / 1000).toFixed(2)}s ✓`); } catch (err) { - console.log(`FAILED ✗ ${String(err)}`); + console.log(` Failed hibernating ✗ ${String(err)}`); recordError("hibernate"); await tryCleanup(sdk, sandboxId); return; } // ── resume ──────────────────────────────────────────────────────────────── - process.stdout.write(" resume "); + console.log(" Resuming..."); try { + opStart = performance.now(); [sandbox, ms] = await timeMs(() => sdk.sandboxes.resume(sandboxId)); record("resume", ms); - console.log(`${ms.toFixed(0)} ms ✓`); + console.log(` Resumed ${(ms / 1000).toFixed(2)}s ✓`); } catch (err) { - console.log(`FAILED ✗ ${String(err)}`); + console.log(` Failed resuming ✗ ${String(err)}`); recordError("resume"); await tryCleanup(sdk, sandboxId); return; } - // ── fork ────────────────────────────────────────────────────────────────── - process.stdout.write(" fork "); - try { - [forkedSandbox, ms] = await timeMs(() => - sdk.sandboxes.create({ id: sandboxId, tags: ["benchmark-fork"] }) - ); - record("fork", ms); - console.log(`${ms.toFixed(0)} ms ✓ (fork id: ${forkedSandbox.id})`); - } catch (err) { - console.log(`FAILED ✗ ${String(err)}`); - recordError("fork"); - } - - if (forkedSandbox) { - await tryCleanup(sdk, forkedSandbox.id); + if (port) { + await measurePortReady(sandbox, port, "resume_to_port_ready", opStart!); } // ── shutdown ────────────────────────────────────────────────────────────── - process.stdout.write(" shutdown "); + console.log(" Shutting down..."); try { [, ms] = await timeMs(() => sdk.sandboxes.shutdown(sandboxId)); record("shutdown", ms); - console.log(`${ms.toFixed(0)} ms ✓`); + console.log(` Shut down ${(ms / 1000).toFixed(2)}s ✓`); } catch (err) { - console.log(`FAILED ✗ ${String(err)}`); + console.log(` Failed shutting down ✗ ${String(err)}`); recordError("shutdown"); + await tryCleanup(sdk, sandboxId); + return; + } + + // ── start (after shutdown) ──────────────────────────────────────────────── + console.log(" Starting..."); + try { + opStart = performance.now(); + [sandbox, ms] = await timeMs(() => sdk.sandboxes.resume(sandboxId)); + record("start_after_shutdown", ms); + console.log(` Started (after shutdown) ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` Failed starting (after shutdown) ✗ ${String(err)}`); + recordError("start_after_shutdown"); + await tryCleanup(sdk, sandboxId); + return; } + + if (port) { + await measurePortReady(sandbox, port, "start_after_shutdown_to_port_ready", opStart!); + } + + // ── final shutdown (unmeasured cleanup) ─────────────────────────────────── + console.log(" Shutting down (cleanup)..."); + await tryCleanup(sdk, sandboxId); + sandbox = undefined; + console.log(" Done"); } finally { if (sandbox) { await tryCleanup(sdk, sandbox.id); @@ -240,22 +312,6 @@ async function runIteration( // Report // --------------------------------------------------------------------------- -const METRIC_NAMES: Record = { - create: "sandbox_create_duration", - hibernate: "sandbox_hibernate_duration", - resume: "sandbox_resume_duration", - fork: "sandbox_fork_duration", - shutdown: "sandbox_shutdown_duration", -}; - -const LABEL_WIDTH = Math.max(...Object.values(METRIC_NAMES).map((n) => n.length)) + 2; - -function metricLabel(op: OperationName): string { - const name = METRIC_NAMES[op]; - const dots = ".".repeat(LABEL_WIDTH - name.length); - return `${name}${dots}`; -} - const CYAN = "\x1b[96m"; const RESET = "\x1b[0m"; @@ -265,25 +321,24 @@ function fmtField(key: string, ms: number, valueWidth: number): string { return `${key}=${CYAN}${padded}${RESET}`; } -function printReport() { - const ops: OperationName[] = [ - "create", - "hibernate", - "resume", - "fork", - "shutdown", - ]; +function printReport(port: number | undefined) { + const coreOps: OperationName[] = ["create", "hibernate", "resume", "shutdown", "start_after_shutdown"]; + const portOps: OperationName[] = ["create_to_port_ready", "resume_to_port_ready", "start_after_shutdown_to_port_ready"]; + const ops = port ? [...coreOps, ...portOps] : coreOps; + + const labelWidth = Math.max(...ops.map((o) => o.length)) + 2; + const label = (op: OperationName) => op + ".".repeat(labelWidth - op.length); console.log("\n"); - console.log("benchmark results"); + console.log("BENCHMARK RESULTS"); + console.log("─────────────────\n"); for (const op of ops) { - const label = metricLabel(op); const samples = timings[op]; const errCount = errors[op]; if (samples.length === 0) { - console.log(`${label}: no data errors=${errCount}`); + if (errCount > 0) console.log(`${label(op)}: no data errors=${errCount}`); continue; } @@ -301,7 +356,7 @@ function printReport() { ...(errCount > 0 ? [`errors=${errCount}`] : []), ].join(" "); - console.log(`${label}: ${row}`); + console.log(`${label(op)}: ${row}`); } } @@ -309,7 +364,7 @@ function printReport() { // Vitest test entry point // --------------------------------------------------------------------------- -const { templateId, iterations } = parseArgs(); +const { templateId, iterations, port } = parseArgs(); // Allow up to 5 minutes per iteration plus overhead const TIMEOUT_MS = (iterations + 1) * 5 * 60 * 1000; @@ -322,10 +377,11 @@ test("sandbox benchmark", { timeout: TIMEOUT_MS }, async () => { console.log(` Template: ${templateId}`); console.log(` Iterations: ${iterations}`); console.log(` API URL: ${baseUrl}`); + if (port) console.log(` Port: ${port}`); for (let i = 0; i < iterations; i++) { - await runIteration(sdk, templateId, i); + await runIteration(sdk, templateId, port, i); } - printReport(); + printReport(port); }); From 8aebc2e19c7b147668fce63604d6e873db90a2b8 Mon Sep 17 00:00:00 2001 From: Joji Augustine Date: Thu, 12 Mar 2026 23:33:57 +0100 Subject: [PATCH 41/46] add archive start test --- tests/benchmark/lifecycle.test.ts | 253 ++++----------------- tests/benchmark/start-from-archive.test.ts | 140 ++++++++++++ tests/benchmark/utils.ts | 183 +++++++++++++++ 3 files changed, 368 insertions(+), 208 deletions(-) create mode 100644 tests/benchmark/start-from-archive.test.ts create mode 100644 tests/benchmark/utils.ts diff --git a/tests/benchmark/lifecycle.test.ts b/tests/benchmark/lifecycle.test.ts index fdeba8e..1a60091 100644 --- a/tests/benchmark/lifecycle.test.ts +++ b/tests/benchmark/lifecycle.test.ts @@ -1,5 +1,5 @@ /** - * Sandbox Operation Benchmark + * Sandbox Lifecycle Benchmark * * Measures timing for: create, hibernate, resume, shutdown, start (after shutdown) * Optionally measures time-to-port-ready for: create, resume, start (set CSB_PORT) @@ -19,7 +19,17 @@ import { test } from "vitest"; import { CodeSandbox, Sandbox } from "../../src/index.js"; -import { SandboxClient } from "../../src/SandboxClient/index.js"; +import { + BenchmarkState, + createState, + initSDK, + measurePortReady, + printReport, + record, + recordError, + timeMs, + tryCleanup, +} from "./utils.js"; // --------------------------------------------------------------------------- // CLI / env argument parsing @@ -46,66 +56,7 @@ function parseArgs() { } // --------------------------------------------------------------------------- -// SDK initialisation -// --------------------------------------------------------------------------- - -function initSDK(): CodeSandbox { - const baseUrl = process.env.CSB_BASE_URL ?? "https://api.codesandbox.io"; - return new CodeSandbox(process.env.CSB_API_KEY, { baseUrl }); -} - -// --------------------------------------------------------------------------- -// Timing helpers -// --------------------------------------------------------------------------- - -async function timeMs(fn: () => Promise): Promise<[T, number]> { - const start = performance.now(); - const result = await fn(); - return [result, performance.now() - start]; -} - -// --------------------------------------------------------------------------- -// Statistics -// --------------------------------------------------------------------------- - -interface Stats { - samples: number; - avg: number; - min: number; - max: number; - median: number; - p50: number; - p90: number; - p95: number; - p99: number; -} - -function computeStats(values: number[]): Stats { - const sorted = [...values].sort((a, b) => a - b); - const n = sorted.length; - - const pct = (p: number) => { - const rank = Math.ceil((p / 100) * n); - return sorted[Math.min(rank, n) - 1]; - }; - - const avg = values.reduce((sum, v) => sum + v, 0) / n; - - return { - samples: n, - avg, - min: sorted[0], - max: sorted[n - 1], - median: pct(50), - p50: pct(50), - p90: pct(90), - p95: pct(95), - p99: pct(99), - }; -} - -// --------------------------------------------------------------------------- -// Result storage +// Operation names // --------------------------------------------------------------------------- type OperationName = @@ -118,84 +69,19 @@ type OperationName = | "resume_to_port_ready" | "start_after_shutdown_to_port_ready"; -const timings: Record = { - create: [], - hibernate: [], - resume: [], - shutdown: [], - start_after_shutdown: [], - create_to_port_ready: [], - resume_to_port_ready: [], - start_after_shutdown_to_port_ready: [], -}; - -const errors: Record = { - create: 0, - hibernate: 0, - resume: 0, - shutdown: 0, - start_after_shutdown: 0, - create_to_port_ready: 0, - resume_to_port_ready: 0, - start_after_shutdown_to_port_ready: 0, -}; - -function record(op: OperationName, ms: number) { - timings[op].push(ms); -} - -function recordError(op: OperationName) { - errors[op]++; -} - -// --------------------------------------------------------------------------- -// Cleanup helper -// --------------------------------------------------------------------------- - -async function tryCleanup(sdk: CodeSandbox, sandboxId: string) { - try { - await sdk.sandboxes.shutdown(sandboxId); - } catch { - /* best effort */ - } - try { - await sdk.sandboxes.delete(sandboxId); - } catch { - /* best effort */ - } -} - -// --------------------------------------------------------------------------- -// Port readiness helper -// Measures time from `opStart` until the given port is ready on the sandbox. -// --------------------------------------------------------------------------- +const CORE_OPS: OperationName[] = [ + "create", + "hibernate", + "resume", + "shutdown", + "start_after_shutdown", +]; -async function measurePortReady( - sandbox: Sandbox, - port: number, - opName: OperationName, - opStart: number -): Promise { - let client: SandboxClient | undefined; - try { - client = await sandbox.connect(); - await client.ports.waitForPort(port, { timeoutMs: 120_000 }); - record(opName, performance.now() - opStart); - console.log( - ` Port ${port} ready ${((performance.now() - opStart) / 1000).toFixed(2)}s ✓` - ); - } catch (err) { - console.log(` Port ${port} not ready ✗ ${String(err)}`); - recordError(opName); - } finally { - try { - await client?.disconnect(); - client?.dispose(); - } catch { - /* best effort */ - } - } -} +const PORT_OPS: OperationName[] = [ + "create_to_port_ready", + "resume_to_port_ready", + "start_after_shutdown_to_port_ready", +]; // --------------------------------------------------------------------------- // Single benchmark iteration @@ -203,6 +89,7 @@ async function measurePortReady( async function runIteration( sdk: CodeSandbox, + state: BenchmarkState, templateId: string, port: number | undefined, index: number @@ -220,16 +107,16 @@ async function runIteration( [sandbox, ms] = await timeMs(() => sdk.sandboxes.create({ id: templateId, tags: ["benchmark"] }) ); - record("create", ms); + record(state, "create", ms); console.log(` Created ${(ms / 1000).toFixed(2)}s ✓ (id: ${sandbox.id})`); } catch (err) { console.log(` Failed creating ✗ ${String(err)}`); - recordError("create"); + recordError(state, "create"); return; } if (port) { - await measurePortReady(sandbox, port, "create_to_port_ready", opStart!); + await measurePortReady(sandbox, port, "create_to_port_ready", opStart!, state); } const sandboxId = sandbox.id; @@ -238,11 +125,11 @@ async function runIteration( console.log(" Hibernating..."); try { [, ms] = await timeMs(() => sdk.sandboxes.hibernate(sandboxId)); - record("hibernate", ms); + record(state, "hibernate", ms); console.log(` Hibernated ${(ms / 1000).toFixed(2)}s ✓`); } catch (err) { console.log(` Failed hibernating ✗ ${String(err)}`); - recordError("hibernate"); + recordError(state, "hibernate"); await tryCleanup(sdk, sandboxId); return; } @@ -252,28 +139,28 @@ async function runIteration( try { opStart = performance.now(); [sandbox, ms] = await timeMs(() => sdk.sandboxes.resume(sandboxId)); - record("resume", ms); + record(state, "resume", ms); console.log(` Resumed ${(ms / 1000).toFixed(2)}s ✓`); } catch (err) { console.log(` Failed resuming ✗ ${String(err)}`); - recordError("resume"); + recordError(state, "resume"); await tryCleanup(sdk, sandboxId); return; } if (port) { - await measurePortReady(sandbox, port, "resume_to_port_ready", opStart!); + await measurePortReady(sandbox, port, "resume_to_port_ready", opStart!, state); } // ── shutdown ────────────────────────────────────────────────────────────── console.log(" Shutting down..."); try { [, ms] = await timeMs(() => sdk.sandboxes.shutdown(sandboxId)); - record("shutdown", ms); + record(state, "shutdown", ms); console.log(` Shut down ${(ms / 1000).toFixed(2)}s ✓`); } catch (err) { console.log(` Failed shutting down ✗ ${String(err)}`); - recordError("shutdown"); + recordError(state, "shutdown"); await tryCleanup(sdk, sandboxId); return; } @@ -283,17 +170,17 @@ async function runIteration( try { opStart = performance.now(); [sandbox, ms] = await timeMs(() => sdk.sandboxes.resume(sandboxId)); - record("start_after_shutdown", ms); + record(state, "start_after_shutdown", ms); console.log(` Started (after shutdown) ${(ms / 1000).toFixed(2)}s ✓`); } catch (err) { console.log(` Failed starting (after shutdown) ✗ ${String(err)}`); - recordError("start_after_shutdown"); + recordError(state, "start_after_shutdown"); await tryCleanup(sdk, sandboxId); return; } if (port) { - await measurePortReady(sandbox, port, "start_after_shutdown_to_port_ready", opStart!); + await measurePortReady(sandbox, port, "start_after_shutdown_to_port_ready", opStart!, state); } // ── final shutdown (unmeasured cleanup) ─────────────────────────────────── @@ -308,58 +195,6 @@ async function runIteration( } } -// --------------------------------------------------------------------------- -// Report -// --------------------------------------------------------------------------- - -const CYAN = "\x1b[96m"; -const RESET = "\x1b[0m"; - -function fmtField(key: string, ms: number, valueWidth: number): string { - const raw = `${(ms / 1000).toFixed(2)}s`; - const padded = raw.padEnd(valueWidth); - return `${key}=${CYAN}${padded}${RESET}`; -} - -function printReport(port: number | undefined) { - const coreOps: OperationName[] = ["create", "hibernate", "resume", "shutdown", "start_after_shutdown"]; - const portOps: OperationName[] = ["create_to_port_ready", "resume_to_port_ready", "start_after_shutdown_to_port_ready"]; - const ops = port ? [...coreOps, ...portOps] : coreOps; - - const labelWidth = Math.max(...ops.map((o) => o.length)) + 2; - const label = (op: OperationName) => op + ".".repeat(labelWidth - op.length); - - console.log("\n"); - console.log("BENCHMARK RESULTS"); - console.log("─────────────────\n"); - - for (const op of ops) { - const samples = timings[op]; - const errCount = errors[op]; - - if (samples.length === 0) { - if (errCount > 0) console.log(`${label(op)}: no data errors=${errCount}`); - continue; - } - - const s = computeStats(samples); - - const row = [ - fmtField("avg", s.avg, 8), - fmtField("min", s.min, 8), - fmtField("med", s.median, 8), - fmtField("max", s.max, 8), - fmtField("p(90)", s.p90, 8), - fmtField("p(95)", s.p95, 8), - fmtField("p(99)", s.p99, 8), - `n=${s.samples}`, - ...(errCount > 0 ? [`errors=${errCount}`] : []), - ].join(" "); - - console.log(`${label(op)}: ${row}`); - } -} - // --------------------------------------------------------------------------- // Vitest test entry point // --------------------------------------------------------------------------- @@ -369,19 +204,21 @@ const { templateId, iterations, port } = parseArgs(); // Allow up to 5 minutes per iteration plus overhead const TIMEOUT_MS = (iterations + 1) * 5 * 60 * 1000; -test("sandbox benchmark", { timeout: TIMEOUT_MS }, async () => { +test("sandbox lifecycle benchmark", { timeout: TIMEOUT_MS }, async () => { const sdk = initSDK(); + const state = createState([...CORE_OPS, ...PORT_OPS]); const baseUrl = process.env.CSB_BASE_URL ?? "https://api.codesandbox.io"; - console.log("Sandbox Benchmark"); + console.log("Sandbox Lifecycle Benchmark"); console.log(` Template: ${templateId}`); console.log(` Iterations: ${iterations}`); console.log(` API URL: ${baseUrl}`); if (port) console.log(` Port: ${port}`); for (let i = 0; i < iterations; i++) { - await runIteration(sdk, templateId, port, i); + await runIteration(sdk, state, templateId, port, i); } - printReport(port); + const ops = port ? [...CORE_OPS, ...PORT_OPS] : CORE_OPS; + printReport(ops, state); }); diff --git a/tests/benchmark/start-from-archive.test.ts b/tests/benchmark/start-from-archive.test.ts new file mode 100644 index 0000000..ccc3b00 --- /dev/null +++ b/tests/benchmark/start-from-archive.test.ts @@ -0,0 +1,140 @@ +/** + * Sandbox Start-from-Archive Benchmark + * + * Measures how long it takes to resume a sandbox from archived state and + * optionally wait for a port to be ready. + * + * The sandbox must already be in an archived state before running. Each + * iteration resumes the sandbox, records timings, then re-archives it for + * the next iteration. + * + * Usage: + * CSB_API_KEY= CSB_SANDBOX_ID= CSB_PORT=3000 npm run benchmark -- --project start-from-archive + * + * Environment Variables: + * CSB_API_KEY CodeSandbox API key (required) + * CSB_SANDBOX_ID ID of the archived sandbox (required) + * CSB_PORT Port to wait for after resume (optional) + * CSB_BASE_URL API base URL (default: https://api.codesandbox.io) + * CSB_ITERATIONS Number of benchmark iterations (default: 5) + */ + +import { test } from "vitest"; +import { CodeSandbox, Sandbox } from "../../src/index.js"; +import { + BenchmarkState, + createState, + initSDK, + measurePortReady, + printReport, + record, + recordError, + timeMs, +} from "./utils.js"; + +// --------------------------------------------------------------------------- +// CLI / env argument parsing +// --------------------------------------------------------------------------- + +function parseArgs() { + const sandboxId = process.env.CSB_SANDBOX_ID; + const iterations = process.env.CSB_ITERATIONS + ? parseInt(process.env.CSB_ITERATIONS, 10) + : 5; + const port = process.env.CSB_PORT + ? parseInt(process.env.CSB_PORT, 10) + : undefined; + + if (!sandboxId) { + throw new Error("CSB_SANDBOX_ID environment variable is required."); + } + + if (!process.env.CSB_API_KEY) { + throw new Error("CSB_API_KEY environment variable is required."); + } + + return { sandboxId, iterations, port }; +} + +// --------------------------------------------------------------------------- +// Operation names +// --------------------------------------------------------------------------- + +type OperationName = "start_from_archive" | "start_from_archive_to_port_ready"; + +const CORE_OPS: OperationName[] = ["start_from_archive"]; +const PORT_OPS: OperationName[] = ["start_from_archive_to_port_ready"]; + +// --------------------------------------------------------------------------- +// Single benchmark iteration +// --------------------------------------------------------------------------- + +async function runIteration( + sdk: CodeSandbox, + state: BenchmarkState, + sandboxId: string, + port: number | undefined, + index: number +): Promise { + console.log(`\n── Iteration ${index + 1} ──────────────────────────────`); + + // ── resume from archive ─────────────────────────────────────────────────── + console.log(" Resuming from archive..."); + let sandbox: Sandbox; + let opStart: number; + let ms: number; + try { + opStart = performance.now(); + [sandbox, ms] = await timeMs(() => sdk.sandboxes.resume(sandboxId)); + record(state, "start_from_archive", ms); + console.log(` Started from archive ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` Failed to start from archive ✗ ${String(err)}`); + recordError(state, "start_from_archive"); + return; + } + + // ── port readiness ──────────────────────────────────────────────────────── + if (port) { + await measurePortReady(sandbox, port, "start_from_archive_to_port_ready", opStart, state); + } + + // ── re-archive for next iteration ───────────────────────────────────────── + if (index < iterations - 1) { + console.log(" Archiving..."); + try { + await sdk.sandboxes.hibernate(sandboxId); + console.log(" Archived ✓"); + } catch (err) { + console.log(` Failed to archive ✗ ${String(err)}`); + } + } +} + +// --------------------------------------------------------------------------- +// Vitest test entry point +// --------------------------------------------------------------------------- + +const { sandboxId, iterations, port } = parseArgs(); + +// Allow up to 5 minutes per iteration plus overhead +const TIMEOUT_MS = (iterations + 1) * 5 * 60 * 1000; + +test("sandbox start-from-archive benchmark", { timeout: TIMEOUT_MS }, async () => { + const sdk = initSDK(); + const state = createState([...CORE_OPS, ...PORT_OPS]); + + const baseUrl = process.env.CSB_BASE_URL ?? "https://api.codesandbox.io"; + console.log("Sandbox Start-from-Archive Benchmark"); + console.log(` Sandbox ID: ${sandboxId}`); + console.log(` Iterations: ${iterations}`); + console.log(` API URL: ${baseUrl}`); + if (port) console.log(` Port: ${port}`); + + for (let i = 0; i < iterations; i++) { + await runIteration(sdk, state, sandboxId, port, i); + } + + const ops = port ? [...CORE_OPS, ...PORT_OPS] : CORE_OPS; + printReport(ops, state); +}); diff --git a/tests/benchmark/utils.ts b/tests/benchmark/utils.ts new file mode 100644 index 0000000..1e00867 --- /dev/null +++ b/tests/benchmark/utils.ts @@ -0,0 +1,183 @@ +import { CodeSandbox, Sandbox } from "../../src/index.js"; +import { SandboxClient } from "../../src/SandboxClient/index.js"; + +// --------------------------------------------------------------------------- +// SDK initialisation +// --------------------------------------------------------------------------- + +export function initSDK(): CodeSandbox { + const baseUrl = process.env.CSB_BASE_URL ?? "https://api.codesandbox.io"; + return new CodeSandbox(process.env.CSB_API_KEY, { baseUrl }); +} + +// --------------------------------------------------------------------------- +// Timing helpers +// --------------------------------------------------------------------------- + +export async function timeMs(fn: () => Promise): Promise<[T, number]> { + const start = performance.now(); + const result = await fn(); + return [result, performance.now() - start]; +} + +// --------------------------------------------------------------------------- +// Statistics +// --------------------------------------------------------------------------- + +export interface Stats { + samples: number; + avg: number; + min: number; + max: number; + median: number; + p50: number; + p90: number; + p95: number; + p99: number; +} + +export function computeStats(values: number[]): Stats { + const sorted = [...values].sort((a, b) => a - b); + const n = sorted.length; + + const pct = (p: number) => { + const rank = Math.ceil((p / 100) * n); + return sorted[Math.min(rank, n) - 1]; + }; + + const avg = values.reduce((sum, v) => sum + v, 0) / n; + + return { + samples: n, + avg, + min: sorted[0], + max: sorted[n - 1], + median: pct(50), + p50: pct(50), + p90: pct(90), + p95: pct(95), + p99: pct(99), + }; +} + +// --------------------------------------------------------------------------- +// Benchmark state +// --------------------------------------------------------------------------- + +export interface BenchmarkState { + timings: Record; + errors: Record; +} + +export function createState(ops: readonly string[]): BenchmarkState { + return { + timings: Object.fromEntries(ops.map((op) => [op, []])), + errors: Object.fromEntries(ops.map((op) => [op, 0])), + }; +} + +export function record(state: BenchmarkState, op: string, ms: number): void { + state.timings[op].push(ms); +} + +export function recordError(state: BenchmarkState, op: string): void { + state.errors[op]++; +} + +// --------------------------------------------------------------------------- +// Cleanup helper +// --------------------------------------------------------------------------- + +export async function tryCleanup(sdk: CodeSandbox, sandboxId: string): Promise { + try { + await sdk.sandboxes.shutdown(sandboxId); + } catch { + /* best effort */ + } + try { + await sdk.sandboxes.delete(sandboxId); + } catch { + /* best effort */ + } +} + +// --------------------------------------------------------------------------- +// Port readiness helper +// Measures time from `opStart` until the given port is ready on the sandbox. +// --------------------------------------------------------------------------- + +export async function measurePortReady( + sandbox: Sandbox, + port: number, + opName: string, + opStart: number, + state: BenchmarkState +): Promise { + let client: SandboxClient | undefined; + try { + client = await sandbox.connect(); + await client.ports.waitForPort(port, { timeoutMs: 120_000 }); + record(state, opName, performance.now() - opStart); + console.log( + ` Port ${port} ready ${((performance.now() - opStart) / 1000).toFixed(2)}s ✓` + ); + } catch (err) { + console.log(` Port ${port} not ready ✗ ${String(err)}`); + recordError(state, opName); + } finally { + try { + await client?.disconnect(); + client?.dispose(); + } catch { + /* best effort */ + } + } +} + +// --------------------------------------------------------------------------- +// Report +// --------------------------------------------------------------------------- + +const CYAN = "\x1b[96m"; +const RESET = "\x1b[0m"; + +export function fmtField(key: string, ms: number, valueWidth: number): string { + const raw = `${(ms / 1000).toFixed(2)}s`; + const padded = raw.padEnd(valueWidth); + return `${key}=${CYAN}${padded}${RESET}`; +} + +export function printReport(ops: readonly string[], state: BenchmarkState): void { + const labelWidth = Math.max(...ops.map((o) => o.length)) + 2; + const label = (op: string) => op + ".".repeat(labelWidth - op.length); + + console.log("\n"); + console.log("BENCHMARK RESULTS"); + console.log("─────────────────\n"); + + for (const op of ops) { + const samples = state.timings[op]; + const errCount = state.errors[op]; + + if (samples.length === 0) { + if (errCount > 0) console.log(`${label(op)}: no data errors=${errCount}`); + continue; + } + + const s = computeStats(samples); + + const row = [ + fmtField("avg", s.avg, 8), + fmtField("min", s.min, 8), + fmtField("med", s.median, 8), + fmtField("max", s.max, 8), + fmtField("p(90)", s.p90, 8), + fmtField("p(95)", s.p95, 8), + fmtField("p(99)", s.p99, 8), + `n=${s.samples}`, + ...(errCount > 0 ? [`errors=${errCount}`] : []), + ].join(" "); + + console.log(`${label(op)}: ${row}`); + } +} From 8cc4818f42632c44f5ae05fc0321cf9267553416 Mon Sep 17 00:00:00 2001 From: Joji Augustine Date: Tue, 17 Mar 2026 17:32:13 +0100 Subject: [PATCH 42/46] improved benchmarks --- tests/benchmark/lifecycle.test.ts | 109 ++++++++++----------- tests/benchmark/start-from-archive.test.ts | 22 ++--- tests/benchmark/utils.ts | 95 +++++++++++++----- 3 files changed, 136 insertions(+), 90 deletions(-) diff --git a/tests/benchmark/lifecycle.test.ts b/tests/benchmark/lifecycle.test.ts index 1a60091..a20924d 100644 --- a/tests/benchmark/lifecycle.test.ts +++ b/tests/benchmark/lifecycle.test.ts @@ -25,8 +25,8 @@ import { initSDK, measurePortReady, printReport, - record, - recordError, + recordSandbox, + recordSandboxError, timeMs, tryCleanup, } from "./utils.js"; @@ -59,29 +59,19 @@ function parseArgs() { // Operation names // --------------------------------------------------------------------------- -type OperationName = - | "create" - | "hibernate" - | "resume" - | "shutdown" - | "start_after_shutdown" - | "create_to_port_ready" - | "resume_to_port_ready" - | "start_after_shutdown_to_port_ready"; - -const CORE_OPS: OperationName[] = [ +const CORE_OPS = [ "create", "hibernate", "resume", "shutdown", "start_after_shutdown", -]; +] as const; -const PORT_OPS: OperationName[] = [ +const PORT_OPS = [ "create_to_port_ready", "resume_to_port_ready", "start_after_shutdown_to_port_ready", -]; +] as const; // --------------------------------------------------------------------------- // Single benchmark iteration @@ -107,60 +97,63 @@ async function runIteration( [sandbox, ms] = await timeMs(() => sdk.sandboxes.create({ id: templateId, tags: ["benchmark"] }) ); - record(state, "create", ms); + recordSandbox(state, sandbox.id, "create", ms); console.log(` Created ${(ms / 1000).toFixed(2)}s ✓ (id: ${sandbox.id})`); } catch (err) { console.log(` Failed creating ✗ ${String(err)}`); - recordError(state, "create"); return; } - if (port) { - await measurePortReady(sandbox, port, "create_to_port_ready", opStart!, state); - } - const sandboxId = sandbox.id; - // ── hibernate ───────────────────────────────────────────────────────────── - console.log(" Hibernating..."); - try { - [, ms] = await timeMs(() => sdk.sandboxes.hibernate(sandboxId)); - record(state, "hibernate", ms); - console.log(` Hibernated ${(ms / 1000).toFixed(2)}s ✓`); - } catch (err) { - console.log(` Failed hibernating ✗ ${String(err)}`); - recordError(state, "hibernate"); - await tryCleanup(sdk, sandboxId); - return; - } - - // ── resume ──────────────────────────────────────────────────────────────── - console.log(" Resuming..."); - try { - opStart = performance.now(); - [sandbox, ms] = await timeMs(() => sdk.sandboxes.resume(sandboxId)); - record(state, "resume", ms); - console.log(` Resumed ${(ms / 1000).toFixed(2)}s ✓`); - } catch (err) { - console.log(` Failed resuming ✗ ${String(err)}`); - recordError(state, "resume"); - await tryCleanup(sdk, sandboxId); - return; - } - if (port) { - await measurePortReady(sandbox, port, "resume_to_port_ready", opStart!, state); + const portMs = await measurePortReady(sandbox, port, opStart!); + if (portMs !== null) recordSandbox(state, sandboxId, "create_to_port_ready", portMs); + else recordSandboxError(state, sandboxId, "create_to_port_ready"); } + // // ── hibernate ───────────────────────────────────────────────────────────── + // console.log(" Hibernating..."); + // try { + // [, ms] = await timeMs(() => sdk.sandboxes.hibernate(sandboxId)); + // recordSandbox(state, sandboxId, "hibernate", ms); + // console.log(` Hibernated ${(ms / 1000).toFixed(2)}s ✓`); + // } catch (err) { + // console.log(` Failed hibernating ✗ ${String(err)}`); + // recordSandboxError(state, sandboxId, "hibernate"); + // await tryCleanup(sdk, sandboxId); + // return; + // } + + // // ── resume ──────────────────────────────────────────────────────────────── + // console.log(" Resuming..."); + // try { + // opStart = performance.now(); + // [sandbox, ms] = await timeMs(() => sdk.sandboxes.resume(sandboxId)); + // recordSandbox(state, sandboxId, "resume", ms); + // console.log(` Resumed ${(ms / 1000).toFixed(2)}s ✓`); + // } catch (err) { + // console.log(` Failed resuming ✗ ${String(err)}`); + // recordSandboxError(state, sandboxId, "resume"); + // await tryCleanup(sdk, sandboxId); + // return; + // } + + // if (port) { + // const portMs = await measurePortReady(sandbox, port, opStart!); + // if (portMs !== null) recordSandbox(state, sandboxId, "resume_to_port_ready", portMs); + // else recordSandboxError(state, sandboxId, "resume_to_port_ready"); + // } + // ── shutdown ────────────────────────────────────────────────────────────── console.log(" Shutting down..."); try { [, ms] = await timeMs(() => sdk.sandboxes.shutdown(sandboxId)); - record(state, "shutdown", ms); + recordSandbox(state, sandboxId, "shutdown", ms); console.log(` Shut down ${(ms / 1000).toFixed(2)}s ✓`); } catch (err) { console.log(` Failed shutting down ✗ ${String(err)}`); - recordError(state, "shutdown"); + recordSandboxError(state, sandboxId, "shutdown"); await tryCleanup(sdk, sandboxId); return; } @@ -170,17 +163,19 @@ async function runIteration( try { opStart = performance.now(); [sandbox, ms] = await timeMs(() => sdk.sandboxes.resume(sandboxId)); - record(state, "start_after_shutdown", ms); + recordSandbox(state, sandboxId, "start_after_shutdown", ms); console.log(` Started (after shutdown) ${(ms / 1000).toFixed(2)}s ✓`); } catch (err) { console.log(` Failed starting (after shutdown) ✗ ${String(err)}`); - recordError(state, "start_after_shutdown"); + recordSandboxError(state, sandboxId, "start_after_shutdown"); await tryCleanup(sdk, sandboxId); return; } if (port) { - await measurePortReady(sandbox, port, "start_after_shutdown_to_port_ready", opStart!, state); + const portMs = await measurePortReady(sandbox, port, opStart!); + if (portMs !== null) recordSandbox(state, sandboxId, "start_after_shutdown_to_port_ready", portMs); + else recordSandboxError(state, sandboxId, "start_after_shutdown_to_port_ready"); } // ── final shutdown (unmeasured cleanup) ─────────────────────────────────── @@ -206,7 +201,7 @@ const TIMEOUT_MS = (iterations + 1) * 5 * 60 * 1000; test("sandbox lifecycle benchmark", { timeout: TIMEOUT_MS }, async () => { const sdk = initSDK(); - const state = createState([...CORE_OPS, ...PORT_OPS]); + const state = createState(); const baseUrl = process.env.CSB_BASE_URL ?? "https://api.codesandbox.io"; console.log("Sandbox Lifecycle Benchmark"); @@ -219,6 +214,6 @@ test("sandbox lifecycle benchmark", { timeout: TIMEOUT_MS }, async () => { await runIteration(sdk, state, templateId, port, i); } - const ops = port ? [...CORE_OPS, ...PORT_OPS] : CORE_OPS; + const ops = port ? [...CORE_OPS, ...PORT_OPS] : [...CORE_OPS]; printReport(ops, state); }); diff --git a/tests/benchmark/start-from-archive.test.ts b/tests/benchmark/start-from-archive.test.ts index ccc3b00..6e98f97 100644 --- a/tests/benchmark/start-from-archive.test.ts +++ b/tests/benchmark/start-from-archive.test.ts @@ -27,8 +27,8 @@ import { initSDK, measurePortReady, printReport, - record, - recordError, + recordSandbox, + recordSandboxError, timeMs, } from "./utils.js"; @@ -60,10 +60,8 @@ function parseArgs() { // Operation names // --------------------------------------------------------------------------- -type OperationName = "start_from_archive" | "start_from_archive_to_port_ready"; - -const CORE_OPS: OperationName[] = ["start_from_archive"]; -const PORT_OPS: OperationName[] = ["start_from_archive_to_port_ready"]; +const CORE_OPS = ["start_from_archive"] as const; +const PORT_OPS = ["start_from_archive_to_port_ready"] as const; // --------------------------------------------------------------------------- // Single benchmark iteration @@ -86,17 +84,19 @@ async function runIteration( try { opStart = performance.now(); [sandbox, ms] = await timeMs(() => sdk.sandboxes.resume(sandboxId)); - record(state, "start_from_archive", ms); + recordSandbox(state, sandboxId, "start_from_archive", ms); console.log(` Started from archive ${(ms / 1000).toFixed(2)}s ✓`); } catch (err) { console.log(` Failed to start from archive ✗ ${String(err)}`); - recordError(state, "start_from_archive"); + recordSandboxError(state, sandboxId, "start_from_archive"); return; } // ── port readiness ──────────────────────────────────────────────────────── if (port) { - await measurePortReady(sandbox, port, "start_from_archive_to_port_ready", opStart, state); + const portMs = await measurePortReady(sandbox, port, opStart); + if (portMs !== null) recordSandbox(state, sandboxId, "start_from_archive_to_port_ready", portMs); + else recordSandboxError(state, sandboxId, "start_from_archive_to_port_ready"); } // ── re-archive for next iteration ───────────────────────────────────────── @@ -122,7 +122,7 @@ const TIMEOUT_MS = (iterations + 1) * 5 * 60 * 1000; test("sandbox start-from-archive benchmark", { timeout: TIMEOUT_MS }, async () => { const sdk = initSDK(); - const state = createState([...CORE_OPS, ...PORT_OPS]); + const state = createState(); const baseUrl = process.env.CSB_BASE_URL ?? "https://api.codesandbox.io"; console.log("Sandbox Start-from-Archive Benchmark"); @@ -135,6 +135,6 @@ test("sandbox start-from-archive benchmark", { timeout: TIMEOUT_MS }, async () = await runIteration(sdk, state, sandboxId, port, i); } - const ops = port ? [...CORE_OPS, ...PORT_OPS] : CORE_OPS; + const ops = port ? [...CORE_OPS, ...PORT_OPS] : [...CORE_OPS]; printReport(ops, state); }); diff --git a/tests/benchmark/utils.ts b/tests/benchmark/utils.ts index 1e00867..45d71a3 100644 --- a/tests/benchmark/utils.ts +++ b/tests/benchmark/utils.ts @@ -64,24 +64,44 @@ export function computeStats(values: number[]): Stats { // Benchmark state // --------------------------------------------------------------------------- +export interface SandboxRecord { + id: string; + timings: Record; + errors: string[]; +} + export interface BenchmarkState { - timings: Record; - errors: Record; + sandboxes: SandboxRecord[]; } -export function createState(ops: readonly string[]): BenchmarkState { - return { - timings: Object.fromEntries(ops.map((op) => [op, []])), - errors: Object.fromEntries(ops.map((op) => [op, 0])), - }; +export function createState(): BenchmarkState { + return { sandboxes: [] }; +} + +function getOrCreate(state: BenchmarkState, id: string): SandboxRecord { + let entry = state.sandboxes.find((s) => s.id === id); + if (!entry) { + entry = { id, timings: {}, errors: [] }; + state.sandboxes.push(entry); + } + return entry; } -export function record(state: BenchmarkState, op: string, ms: number): void { - state.timings[op].push(ms); +export function recordSandbox( + state: BenchmarkState, + id: string, + op: string, + ms: number +): void { + getOrCreate(state, id).timings[op] = ms; } -export function recordError(state: BenchmarkState, op: string): void { - state.errors[op]++; +export function recordSandboxError( + state: BenchmarkState, + id: string, + op: string +): void { + getOrCreate(state, id).errors.push(op); } // --------------------------------------------------------------------------- @@ -104,26 +124,24 @@ export async function tryCleanup(sdk: CodeSandbox, sandboxId: string): Promise { + opStart: number +): Promise { let client: SandboxClient | undefined; try { client = await sandbox.connect(); await client.ports.waitForPort(port, { timeoutMs: 120_000 }); - record(state, opName, performance.now() - opStart); - console.log( - ` Port ${port} ready ${((performance.now() - opStart) / 1000).toFixed(2)}s ✓` - ); + const ms = performance.now() - opStart; + console.log(` Port ${port} ready ${(ms / 1000).toFixed(2)}s ✓`); + return ms; } catch (err) { console.log(` Port ${port} not ready ✗ ${String(err)}`); - recordError(state, opName); + return null; } finally { try { await client?.disconnect(); @@ -156,8 +174,10 @@ export function printReport(ops: readonly string[], state: BenchmarkState): void console.log("─────────────────\n"); for (const op of ops) { - const samples = state.timings[op]; - const errCount = state.errors[op]; + const samples = state.sandboxes + .map((s) => s.timings[op]) + .filter((v): v is number => v !== undefined); + const errCount = state.sandboxes.filter((s) => s.errors.includes(op)).length; if (samples.length === 0) { if (errCount > 0) console.log(`${label(op)}: no data errors=${errCount}`); @@ -180,4 +200,35 @@ export function printReport(ops: readonly string[], state: BenchmarkState): void console.log(`${label(op)}: ${row}`); } + + if (state.sandboxes.length > 0) { + console.log("\nPER-SANDBOX TIMINGS"); + console.log("───────────────────\n"); + + const headers = ["SANDBOX ID", ...ops.map((op) => op.toUpperCase())]; + const rows = state.sandboxes.map(({ id, timings, errors }) => [ + id, + ...ops.map((op) => { + if (timings[op] !== undefined) return `${(timings[op] / 1000).toFixed(2)}s`; + if (errors.includes(op)) return "ERROR"; + return "-"; + }), + ]); + + const colWidths = headers.map((h, i) => + Math.max(h.length, ...rows.map((r) => r[i].length)) + ); + + const sep = " "; + console.log(headers.map((h, i) => h.padEnd(colWidths[i])).join(sep)); + for (const row of rows) { + const line = row.map((val, i) => { + const plain = val.padEnd(colWidths[i]); + if (val === "ERROR") return `\x1b[91m${plain}\x1b[0m`; + if (val === "-") return `\x1b[2m${plain}\x1b[0m`; + return plain; + }); + console.log(line.join(sep)); + } + } } From a35b3aa337c754969b49abe7264aabad145f433e Mon Sep 17 00:00:00 2001 From: Fernando Tapia Rico Date: Fri, 27 Mar 2026 14:22:20 +0100 Subject: [PATCH 43/46] Send content as-is in fs.WriteFile The previous implementation could corrupt binary files. --- src/PintClient/fs.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/PintClient/fs.ts b/src/PintClient/fs.ts index c920f51..5207ad0 100644 --- a/src/PintClient/fs.ts +++ b/src/PintClient/fs.ts @@ -97,17 +97,15 @@ export class PintFsClient implements IAgentClientFS { overwrite?: boolean ): Promise> { try { - // Convert Uint8Array content to string for the API - const decoder = new TextDecoder(); - const contentString = decoder.decode(content); - const response = await createFile({ client: this.apiClient, path: { path: path, }, - body: { - content: contentString, + body: content as unknown as { content: string }, + bodySerializer: (body) => body as unknown as string, + headers: { + "Content-Type": "application/octet-stream", }, }); From 8f4707fb45c787a384706774db4479eb9c8fb095 Mon Sep 17 00:00:00 2001 From: Fernando Tapia Rico Date: Fri, 27 Mar 2026 14:23:28 +0100 Subject: [PATCH 44/46] Refactor batchWrite to use command args instead of inline string --- src/SandboxClient/filesystem.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SandboxClient/filesystem.ts b/src/SandboxClient/filesystem.ts index d190f73..707b824 100644 --- a/src/SandboxClient/filesystem.ts +++ b/src/SandboxClient/filesystem.ts @@ -187,8 +187,8 @@ export class FileSystem { const result = await this.agentClient.shells.create({ projectPath: this.agentClient.workspacePath, size: { cols: 128, rows: 24 }, - command: `unzip -o ${tempZipPath}`, - args: [], + command: "unzip", + args: ["-o", tempZipPath], type: "COMMAND", isSystemShell: true, cwd: this.agentClient.workspacePath, From a1fdc4ce4bf969ddf3da9bce16012bb207d50c36 Mon Sep 17 00:00:00 2001 From: Fernando Tapia Rico Date: Fri, 27 Mar 2026 14:25:22 +0100 Subject: [PATCH 45/46] Add benchmark for file operations and commands --- tests/benchmark/files-and-commands.test.ts | 464 +++++++++++++++++++++ 1 file changed, 464 insertions(+) create mode 100644 tests/benchmark/files-and-commands.test.ts diff --git a/tests/benchmark/files-and-commands.test.ts b/tests/benchmark/files-and-commands.test.ts new file mode 100644 index 0000000..4e8d7e1 --- /dev/null +++ b/tests/benchmark/files-and-commands.test.ts @@ -0,0 +1,464 @@ +/** + * Files & Commands Benchmark + * + * Measures timing for file operations and command execution inside a sandbox. + * Useful for comparing performance between the old infra (Pitcher) and new infra (Pint). + * + * File operations measured: + * - write_small_file Write a small text file (~1 KB) via writeTextFile + * - write_large_text_file Write a large text file (~10 MB) via writeTextFile + * - write_large_binary_file Write a large binary file (~10 MB) via writeFile + * - read_small_file Read the small file back + * - read_large_file Read the large text file back + * - batch_write_relative Write 50 small files via batchWrite with workspace-relative paths + * - batch_write_absolute Write 50 small files via batchWrite with absolute /tmp paths + * - mkdir Create a nested directory tree + * - readdir List directory contents + * - stat Stat a file + * - copy_file Copy the small file + * - rename_file Rename the copied file + * - remove_file Remove the renamed file + * + * Command operations measured: + * - cmd_echo Simple echo (baseline round-trip latency) + * - cmd_cpu_pi CPU-intensive: compute π digits with python3 + * - cmd_cpu_hash CPU-intensive: sha256 of /dev/urandom (256 MB) + * - cmd_disk_write Disk write: dd 256 MB to a temp file + * - cmd_disk_read Disk read: dd 256 MB from the temp file + * - cmd_find Filesystem traversal: find /usr -type f + * + * Usage: + * CSB_API_KEY= CSB_TEMPLATE_ID= npm run benchmark:files + * CSB_API_KEY= CSB_TEMPLATE_ID= CSB_ITERATIONS=10 npm run benchmark:files + * + * Environment Variables: + * CSB_API_KEY CodeSandbox API key (required) + * CSB_TEMPLATE_ID Template ID to fork from (required) + * CSB_BASE_URL API base URL (default: https://api.codesandbox.io) + * CSB_ITERATIONS Number of benchmark iterations (default: 5) + */ + +import { test } from "vitest"; +import { CodeSandbox, Sandbox } from "../../src/index.js"; +import { SandboxClient, CommandError } from "../../src/SandboxClient/index.js"; +import { + BenchmarkState, + createState, + initSDK, + printReport, + recordSandbox, + recordSandboxError, + timeMs, + tryCleanup, +} from "./utils.js"; + +// --------------------------------------------------------------------------- +// CLI / env argument parsing +// --------------------------------------------------------------------------- + +function parseArgs() { + const templateId = process.env.CSB_TEMPLATE_ID; + const iterations = process.env.CSB_ITERATIONS + ? parseInt(process.env.CSB_ITERATIONS, 10) + : 5; + + if (!templateId) { + throw new Error("CSB_TEMPLATE_ID environment variable is required."); + } + + if (!process.env.CSB_API_KEY) { + throw new Error("CSB_API_KEY environment variable is required."); + } + + return { templateId, iterations }; +} + +// --------------------------------------------------------------------------- +// Operation names +// --------------------------------------------------------------------------- + +const FILE_OPS = [ + "write_small_file", + "write_large_text_file", + "write_large_binary_file", + "read_small_file", + "read_large_file", + "batch_write_relative", + "batch_write_absolute", + "mkdir", + "readdir", + "stat", + "copy_file", + "rename_file", + "remove_file", +] as const; + +const CMD_OPS = [ + "cmd_echo", + "cmd_cpu_pi", + "cmd_cpu_hash", + "cmd_disk_write", + "cmd_disk_read", + "cmd_find", +] as const; + +const ALL_OPS = [...FILE_OPS, ...CMD_OPS] as const; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeRandomBytes(size: number): Uint8Array { + const buf = new Uint8Array(size); + let x = 0x12345678; + for (let i = 0; i < size; i++) { + x = (Math.imul(x, 1664525) + 1013904223) >>> 0; + buf[i] = x & 0xff; + } + return buf; +} + +// --------------------------------------------------------------------------- +// Single benchmark iteration +// --------------------------------------------------------------------------- + +async function runIteration( + sdk: CodeSandbox, + state: BenchmarkState, + templateId: string, + index: number +): Promise { + console.log(`\n── Iteration ${index + 1} ──────────────────────────────`); + let sandbox: Sandbox | undefined; + let client: SandboxClient | undefined; + + try { + // ── create sandbox ──────────────────────────────────────────────────────── + console.log(" Creating sandbox..."); + let ms: number; + try { + [sandbox, ms] = await timeMs(() => + sdk.sandboxes.create({ id: templateId, tags: ["benchmark"] }) + ); + console.log(` Created ${(ms / 1000).toFixed(2)}s ✓ (id: ${sandbox.id})`); + } catch (err) { + console.log(` Failed creating sandbox ✗ ${String(err)}`); + return; + } + + const sandboxId = sandbox.id; + const benchDir = `/tmp/benchmark_${index}`; + + // ── connect ─────────────────────────────────────────────────────────────── + console.log(" Connecting..."); + try { + client = await sandbox.connect(); + } catch (err) { + console.log(` Failed connecting ✗ ${String(err)}`); + await tryCleanup(sdk, sandboxId); + return; + } + + const fs = client.fs; + const commands = client.commands; + + // ── mkdir ───────────────────────────────────────────────────────────────── + console.log(" mkdir..."); + try { + [, ms] = await timeMs(() => fs.mkdir(benchDir, true)); + recordSandbox(state, sandboxId, "mkdir", ms); + console.log(` mkdir ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` mkdir ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "mkdir"); + } + + // ── write small file (~1 KB) ────────────────────────────────────────────── + const smallPath = `${benchDir}/small.txt`; + const smallContent = "x".repeat(1024); // 1 KB + console.log(" write_small_file..."); + try { + [, ms] = await timeMs(() => fs.writeTextFile(smallPath, smallContent)); + recordSandbox(state, sandboxId, "write_small_file", ms); + console.log(` write_small_file ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` write_small_file ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "write_small_file"); + } + + // ── write large text file (~10 MB via writeTextFile) ──────────────────── + const largePath = `${benchDir}/large.txt`; + const largeTextContent = "x".repeat(10 * 1024 * 1024); // 10 MB + console.log(" write_large_text_file..."); + try { + [, ms] = await timeMs(() => fs.writeTextFile(largePath, largeTextContent)); + recordSandbox(state, sandboxId, "write_large_text_file", ms); + console.log(` write_large_text_file ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` write_large_text_file ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "write_large_text_file"); + } + + // ── write large binary file (~10 MB via writeFile) ─────────────────────── + const largeBinPath = `${benchDir}/large.bin`; + const largeBinContent = makeRandomBytes(10 * 1024 * 1024); // 10 MB + console.log(" write_large_binary_file..."); + try { + [, ms] = await timeMs(() => fs.writeFile(largeBinPath, largeBinContent)); + recordSandbox(state, sandboxId, "write_large_binary_file", ms); + console.log(` write_large_binary_file ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` write_large_binary_file ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "write_large_binary_file"); + } + + // ── read small file ─────────────────────────────────────────────────────── + console.log(" read_small_file..."); + try { + [, ms] = await timeMs(() => fs.readTextFile(smallPath)); + recordSandbox(state, sandboxId, "read_small_file", ms); + console.log(` read_small_file ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` read_small_file ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "read_small_file"); + } + + // ── read large file ─────────────────────────────────────────────────────── + console.log(" read_large_file..."); + try { + [, ms] = await timeMs(() => fs.readFile(largePath)); + recordSandbox(state, sandboxId, "read_large_file", ms); + console.log(` read_large_file ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` read_large_file ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "read_large_file"); + } + + // ── batch write relative (50 files, paths relative to workspace) ────────── + console.log(" batch_write_relative..."); + try { + const batchFilesRelative = Array.from({ length: 50 }, (_, i) => ({ + path: `benchmark_${index}/batch/file_${i}.txt`, + content: `batch file ${i}\n`.repeat(20), + })); + [, ms] = await timeMs(() => fs.batchWrite(batchFilesRelative)); + recordSandbox(state, sandboxId, "batch_write_relative", ms); + console.log(` batch_write_relative ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` batch_write_relative ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "batch_write_relative"); + } + + // ── batch write absolute (50 files, absolute paths in /tmp) ─────────────── + console.log(" batch_write_absolute..."); + try { + const batchFilesAbsolute = Array.from({ length: 50 }, (_, i) => ({ + path: `${benchDir}/batch/file_${i}.txt`, + content: `batch file ${i}\n`.repeat(20), + })); + [, ms] = await timeMs(() => fs.batchWrite(batchFilesAbsolute)); + recordSandbox(state, sandboxId, "batch_write_absolute", ms); + console.log(` batch_write_absolute ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` batch_write_absolute ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "batch_write_absolute"); + } + + // ── readdir ─────────────────────────────────────────────────────────────── + console.log(" readdir..."); + try { + [, ms] = await timeMs(() => fs.readdir(benchDir)); + recordSandbox(state, sandboxId, "readdir", ms); + console.log(` readdir ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` readdir ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "readdir"); + } + + // ── stat ────────────────────────────────────────────────────────────────── + console.log(" stat..."); + try { + [, ms] = await timeMs(() => fs.stat(smallPath)); + recordSandbox(state, sandboxId, "stat", ms); + console.log(` stat ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` stat ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "stat"); + } + + // ── copy file ───────────────────────────────────────────────────────────── + const copyPath = `${benchDir}/small_copy.txt`; + console.log(" copy_file..."); + try { + [, ms] = await timeMs(() => fs.copy(smallPath, copyPath, false, true)); + recordSandbox(state, sandboxId, "copy_file", ms); + console.log(` copy_file ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` copy_file ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "copy_file"); + } + + // ── rename file ─────────────────────────────────────────────────────────── + const renamedPath = `${benchDir}/small_renamed.txt`; + console.log(" rename_file..."); + try { + [, ms] = await timeMs(() => fs.rename(copyPath, renamedPath, true)); + recordSandbox(state, sandboxId, "rename_file", ms); + console.log(` rename_file ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` rename_file ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "rename_file"); + } + + // ── remove file ─────────────────────────────────────────────────────────── + console.log(" remove_file..."); + try { + [, ms] = await timeMs(() => fs.remove(renamedPath)); + recordSandbox(state, sandboxId, "remove_file", ms); + console.log(` remove_file ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + console.log(` remove_file ✗ ${String(err)}`); + recordSandboxError(state, sandboxId, "remove_file"); + } + + // ── cmd: echo (baseline latency) ────────────────────────────────────────── + console.log(" cmd_echo..."); + try { + [, ms] = await timeMs(() => commands.run("echo hello")); + recordSandbox(state, sandboxId, "cmd_echo", ms); + console.log(` cmd_echo ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + const detail = err instanceof CommandError ? `exit ${err.exitCode}: ${err.output.trim()}` : String(err); + console.log(` cmd_echo ✗ ${detail}`); + recordSandboxError(state, sandboxId, "cmd_echo"); + } + + // ── cmd: CPU intensive — compute π with python3 (5000 decimal places) ────── + console.log(" cmd_cpu_pi..."); + try { + [, ms] = await timeMs(() => + commands.run( + `python3 -c "from decimal import Decimal, getcontext; getcontext().prec=5000; print(sum(Decimal((-1)**k) / Decimal(2*k+1) for k in range(10000)) * 4)"` + ) + ); + recordSandbox(state, sandboxId, "cmd_cpu_pi", ms); + console.log(` cmd_cpu_pi ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + const detail = err instanceof CommandError ? `exit ${err.exitCode}: ${err.output.trim()}` : String(err); + console.log(` cmd_cpu_pi ✗ ${detail}`); + recordSandboxError(state, sandboxId, "cmd_cpu_pi"); + } + + // ── cmd: CPU intensive — sha256 of 256 MB of random data ────────────────── + console.log(" cmd_cpu_hash..."); + try { + [, ms] = await timeMs(() => + commands.run( + `dd if=/dev/urandom bs=1M count=256 2>/dev/null | sha256sum` + ) + ); + recordSandbox(state, sandboxId, "cmd_cpu_hash", ms); + console.log(` cmd_cpu_hash ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + const detail = err instanceof CommandError ? `exit ${err.exitCode}: ${err.output.trim()}` : String(err); + console.log(` cmd_cpu_hash ✗ ${detail}`); + recordSandboxError(state, sandboxId, "cmd_cpu_hash"); + } + + // ── cmd: disk write — dd 256 MB to temp file ────────────────────────────── + const ddFile = `${client.workspacePath}/dd_test_${index}.bin`; + console.log(" cmd_disk_write..."); + try { + [, ms] = await timeMs(() => + commands.run( + `dd if=/dev/zero of=${ddFile} bs=1M count=256 conv=fdatasync 2>&1` + ) + ); + recordSandbox(state, sandboxId, "cmd_disk_write", ms); + console.log(` cmd_disk_write ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + const detail = err instanceof CommandError ? `exit ${err.exitCode}: ${err.output.trim()}` : String(err); + console.log(` cmd_disk_write ✗ ${detail}`); + recordSandboxError(state, sandboxId, "cmd_disk_write"); + } + + // ── cmd: disk read — dd 256 MB from temp file ───────────────────────────── + console.log(" cmd_disk_read..."); + try { + [, ms] = await timeMs(() => + commands.run( + `dd if=${ddFile} of=/dev/null bs=1M 2>&1` + ) + ); + recordSandbox(state, sandboxId, "cmd_disk_read", ms); + console.log(` cmd_disk_read ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + const detail = err instanceof CommandError ? `exit ${err.exitCode}: ${err.output.trim()}` : String(err); + console.log(` cmd_disk_read ✗ ${detail}`); + recordSandboxError(state, sandboxId, "cmd_disk_read"); + } + + // ── cmd: filesystem traversal — find /usr -type f ───────────────────────── + console.log(" cmd_find..."); + try { + [, ms] = await timeMs(() => + commands.run(`find /usr -type f 2>/dev/null | wc -l`) + ); + recordSandbox(state, sandboxId, "cmd_find", ms); + console.log(` cmd_find ${(ms / 1000).toFixed(2)}s ✓`); + } catch (err) { + const detail = err instanceof CommandError ? `exit ${err.exitCode}: ${err.output.trim()}` : String(err); + console.log(` cmd_find ✗ ${detail}`); + recordSandboxError(state, sandboxId, "cmd_find"); + } + + // ── disconnect & cleanup ────────────────────────────────────────────────── + console.log(" Disconnecting & shutting down (cleanup)..."); + try { + await client.disconnect(); + client.dispose(); + client = undefined; + } catch { + /* best effort */ + } + await tryCleanup(sdk, sandboxId); + sandbox = undefined; + console.log(" Done"); + } finally { + try { + await client?.disconnect(); + client?.dispose(); + } catch { + /* best effort */ + } + if (sandbox) { + await tryCleanup(sdk, sandbox.id); + } + } +} + +// --------------------------------------------------------------------------- +// Vitest test entry point +// --------------------------------------------------------------------------- + +const { templateId, iterations } = parseArgs(); + +// Allow up to 10 minutes per iteration (CPU/disk ops can be slow) +const TIMEOUT_MS = (iterations + 1) * 10 * 60 * 1000; + +test("sandbox files-and-commands benchmark", { timeout: TIMEOUT_MS }, async () => { + const sdk = initSDK(); + const state = createState(); + + const baseUrl = process.env.CSB_BASE_URL ?? "https://api.codesandbox.io"; + console.log("Sandbox Files & Commands Benchmark"); + console.log(` Template: ${templateId}`); + console.log(` Iterations: ${iterations}`); + console.log(` API URL: ${baseUrl}`); + + for (let i = 0; i < iterations; i++) { + await runIteration(sdk, state, templateId, i); + } + + printReport(ALL_OPS, state); +}); From b9065ced249b317ceba5620087674c029140f7b4 Mon Sep 17 00:00:00 2001 From: mohaimen Date: Wed, 8 Apr 2026 16:12:18 +0200 Subject: [PATCH 46/46] update pint spec file --- pint-openapi-bundled.json | 104 +++++++++++++++++++++++++++----------- 1 file changed, 74 insertions(+), 30 deletions(-) diff --git a/pint-openapi-bundled.json b/pint-openapi-bundled.json index b6da8e4..f362e50 100644 --- a/pint-openapi-bundled.json +++ b/pint-openapi-bundled.json @@ -40,7 +40,7 @@ "tags": [ "files" ], - "description": "Creates a new file at the specified path with optional content.", + "description": "Creates a new file at the specified path with binary content from request body.", "operationId": "createFile", "security": [ { @@ -60,11 +60,14 @@ } ], "requestBody": { - "description": "File creation request", + "description": "Raw binary file content", + "required": true, "content": { - "application/json": { + "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/FileCreateRequest" + "type": "string", + "format": "binary", + "description": "Raw binary file content" } } } @@ -852,12 +855,6 @@ "schema": { "$ref": "#/components/schemas/ExecItem" } - }, - "text/event-stream": { - "schema": { - "type": "string", - "description": "Server-Sent Events stream of exec updates" - } } } }, @@ -1127,10 +1124,8 @@ "content": { "text/event-stream": { "schema": { - "type": "string", - "description": "Server-Sent Events stream of exec updates with same format as ExecStdout" - }, - "example": "data: {\"type\":\"stdout\",\"output\":\"Exec output line 1\\n\", \"sequence\" : 1, \"timestamp\":\"2024-10-01T12:00:00Z\"}\n" + "$ref": "#/components/schemas/ExecStdout" + } } } }, @@ -1734,8 +1729,7 @@ "content": { "text/event-stream": { "schema": { - "type": "string", - "description": "Server-Sent Events stream of exec updates" + "$ref": "#/components/schemas/ExecListResponse" } } } @@ -1782,8 +1776,7 @@ "content": { "text/event-stream": { "schema": { - "type": "string", - "description": "Server-Sent Events stream of ports list updates" + "$ref": "#/components/schemas/PortsListResponse" } } } @@ -1882,8 +1875,7 @@ "content": { "text/event-stream": { "schema": { - "type": "string", - "description": "Server-Sent Events stream of directory files updates" + "$ref": "#/components/schemas/WatcherEvent" } } } @@ -1978,15 +1970,6 @@ "content" ] }, - "FileCreateRequest": { - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "File content to create" - } - } - }, "FileOperationResponse": { "type": "object", "properties": { @@ -2018,6 +2001,10 @@ "destination": { "type": "string", "description": "Destination path for move operation" + }, + "recursive": { + "type": "boolean", + "description": "Whether to perform the action recursively for directories" } }, "required": [ @@ -2190,6 +2177,17 @@ "pty": { "type": "boolean", "description": "Whether to start pty shell session or not (defaults to false)" + }, + "cwd": { + "type": "string", + "description": "Working directory for the command (defaults to workspace directory if not specified)" + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Environment variables to set for the command (key-value pairs)" } }, "required": [ @@ -2555,8 +2553,54 @@ "ports" ] }, + "WatcherEvent": { + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "File paths affected by the event" + }, + "type": { + "type": "string", + "description": "Type of file system event", + "enum": [ + "ADD", + "REMOVE", + "CHANGE", + "connected", + "error" + ] + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "Timestamp of when the event occurred" + } + }, + "required": [ + "paths", + "type", + "timestamp" + ] + }, + "FileCreateRequest": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "File content to create" + } + } + }, "Task": { - "$ref": "#/components/schemas/TaskItem" + "allOf": [ + { + "$ref": "#/components/schemas/TaskItem" + } + ] } } }