diff --git a/package-lock.json b/package-lock.json index 03267f57..7102a5bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7911,6 +7911,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7931,6 +7932,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7951,6 +7953,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7971,6 +7974,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7991,6 +7995,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8011,6 +8016,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8031,6 +8037,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8051,6 +8058,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8071,6 +8079,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8091,6 +8100,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8111,6 +8121,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, diff --git a/packages/sandbox-container/src/handlers/ws-handler.ts b/packages/sandbox-container/src/handlers/ws-handler.ts new file mode 100644 index 00000000..7d6c9aa0 --- /dev/null +++ b/packages/sandbox-container/src/handlers/ws-handler.ts @@ -0,0 +1,347 @@ +/** + * WebSocket Handler for Container + * + * Handles WebSocket connections and routes messages to HTTP handlers. + * This enables multiplexing multiple requests over a single WebSocket connection, + * reducing sub-request count when the SDK runs inside Workers/Durable Objects. + */ + +import type { Logger } from '@repo/shared'; +import { + isWSRequest, + type WSError, + type WSRequest, + type WSResponse, + type WSServerMessage, + type WSStreamChunk +} from '@repo/shared'; +import type { ServerWebSocket } from 'bun'; +import type { Router } from '../core/router'; + +/** + * WebSocket data attached to each connection + */ +export interface WSData { + /** Connection ID for logging */ + connectionId: string; +} + +/** + * WebSocket handler that bridges WebSocket messages to HTTP handlers + */ +export class WebSocketHandler { + private router: Router; + private logger: Logger; + + constructor(router: Router, logger: Logger) { + this.router = router; + this.logger = logger.child({ component: 'ws-handler' }); + } + + /** + * Handle WebSocket connection open + */ + onOpen(ws: ServerWebSocket): void { + this.logger.debug('WebSocket connection opened', { + connectionId: ws.data.connectionId + }); + } + + /** + * Handle WebSocket connection close + */ + onClose(ws: ServerWebSocket, code: number, reason: string): void { + this.logger.debug('WebSocket connection closed', { + connectionId: ws.data.connectionId, + code, + reason + }); + } + + /** + * Handle incoming WebSocket message + */ + async onMessage( + ws: ServerWebSocket, + message: string | Buffer + ): Promise { + const messageStr = + typeof message === 'string' ? message : message.toString('utf-8'); + + let parsed: unknown; + try { + parsed = JSON.parse(messageStr); + } catch (error) { + this.sendError(ws, undefined, 'PARSE_ERROR', 'Invalid JSON message', 400); + return; + } + + if (!isWSRequest(parsed)) { + this.sendError( + ws, + undefined, + 'INVALID_REQUEST', + 'Message must be a valid WSRequest', + 400 + ); + return; + } + + const request = parsed as WSRequest; + + this.logger.debug('WebSocket request received', { + connectionId: ws.data.connectionId, + id: request.id, + method: request.method, + path: request.path + }); + + try { + await this.handleRequest(ws, request); + } catch (error) { + this.logger.error( + 'Error handling WebSocket request', + error instanceof Error ? error : new Error(String(error)), + { requestId: request.id } + ); + this.sendError( + ws, + request.id, + 'INTERNAL_ERROR', + error instanceof Error ? error.message : 'Unknown error', + 500 + ); + } + } + + /** + * Handle a WebSocket request by routing it to HTTP handlers + */ + private async handleRequest( + ws: ServerWebSocket, + request: WSRequest + ): Promise { + // Build URL for the request + const url = `http://localhost:3000${request.path}`; + + // Build headers + const headers: Record = { + 'Content-Type': 'application/json', + ...request.headers + }; + + // Build request options + const requestInit: RequestInit = { + method: request.method, + headers + }; + + // Add body for POST/PUT + if ( + request.body !== undefined && + (request.method === 'POST' || request.method === 'PUT') + ) { + requestInit.body = JSON.stringify(request.body); + } + + // Create a fetch Request object + const httpRequest = new Request(url, requestInit); + + // Route through the existing router + const httpResponse = await this.router.route(httpRequest); + + // Check if this is a streaming response + const contentType = httpResponse.headers.get('Content-Type') || ''; + const isStreaming = contentType.includes('text/event-stream'); + + if (isStreaming && httpResponse.body) { + // Handle SSE streaming response + await this.handleStreamingResponse(ws, request.id, httpResponse); + } else { + // Handle regular response + await this.handleRegularResponse(ws, request.id, httpResponse); + } + } + + /** + * Handle a regular (non-streaming) HTTP response + */ + private async handleRegularResponse( + ws: ServerWebSocket, + requestId: string, + response: Response + ): Promise { + let body: unknown; + + try { + const text = await response.text(); + body = text ? JSON.parse(text) : undefined; + } catch { + body = undefined; + } + + const wsResponse: WSResponse = { + type: 'response', + id: requestId, + status: response.status, + body, + done: true + }; + + this.send(ws, wsResponse); + } + + /** + * Handle a streaming (SSE) HTTP response + */ + private async handleStreamingResponse( + ws: ServerWebSocket, + requestId: string, + response: Response + ): Promise { + if (!response.body) { + this.sendError(ws, requestId, 'STREAM_ERROR', 'No response body', 500); + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + // Decode chunk and add to buffer + buffer += decoder.decode(value, { stream: true }); + + // Parse SSE events from buffer + const events = this.parseSSEEvents(buffer); + buffer = events.remaining; + + // Send each parsed event as a stream chunk + for (const event of events.events) { + const chunk: WSStreamChunk = { + type: 'stream', + id: requestId, + event: event.event, + data: event.data + }; + this.send(ws, chunk); + } + } + + // Send final response to close the stream + const wsResponse: WSResponse = { + type: 'response', + id: requestId, + status: response.status, + done: true + }; + this.send(ws, wsResponse); + } catch (error) { + this.logger.error( + 'Error reading stream', + error instanceof Error ? error : new Error(String(error)), + { requestId } + ); + this.sendError( + ws, + requestId, + 'STREAM_ERROR', + error instanceof Error ? error.message : 'Stream read failed', + 500 + ); + } finally { + reader.releaseLock(); + } + } + + /** + * Parse SSE events from a buffer + */ + private parseSSEEvents(buffer: string): { + events: Array<{ event?: string; data: string }>; + remaining: string; + } { + const events: Array<{ event?: string; data: string }> = []; + const lines = buffer.split('\n'); + let currentEvent: { event?: string; data: string[] } = { data: [] }; + let processedIndex = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Check if we have a complete event (empty line after data) + if (line === '' && currentEvent.data.length > 0) { + events.push({ + event: currentEvent.event, + data: currentEvent.data.join('\n') + }); + currentEvent = { data: [] }; + processedIndex = buffer.indexOf(line, processedIndex) + line.length + 1; + continue; + } + + if (line.startsWith('event:')) { + currentEvent.event = line.substring(6).trim(); + processedIndex = buffer.indexOf(line, processedIndex) + line.length + 1; + } else if (line.startsWith('data:')) { + currentEvent.data.push(line.substring(5).trim()); + processedIndex = buffer.indexOf(line, processedIndex) + line.length + 1; + } else if (line === '') { + processedIndex = buffer.indexOf(line, processedIndex) + line.length + 1; + } + } + + return { + events, + remaining: buffer.substring(processedIndex) + }; + } + + /** + * Send a message over WebSocket + */ + private send(ws: ServerWebSocket, message: WSServerMessage): void { + try { + ws.send(JSON.stringify(message)); + } catch (error) { + this.logger.error( + 'Failed to send WebSocket message', + error instanceof Error ? error : new Error(String(error)) + ); + } + } + + /** + * Send an error message over WebSocket + */ + private sendError( + ws: ServerWebSocket, + requestId: string | undefined, + code: string, + message: string, + status: number + ): void { + const error: WSError = { + type: 'error', + id: requestId, + code, + message, + status + }; + this.send(ws, error); + } +} + +/** + * Generate a unique connection ID + */ +export function generateConnectionId(): string { + return `conn_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; +} diff --git a/packages/sandbox-container/src/index.ts b/packages/sandbox-container/src/index.ts index f5d8dd49..aa266c1a 100644 --- a/packages/sandbox-container/src/index.ts +++ b/packages/sandbox-container/src/index.ts @@ -1,14 +1,24 @@ import { createLogger } from '@repo/shared'; +import type { ServerWebSocket } from 'bun'; import { serve } from 'bun'; import { Container } from './core/container'; import { Router } from './core/router'; +import { + generateConnectionId, + WebSocketHandler, + type WSData +} from './handlers/ws-handler'; import { setupRoutes } from './routes/setup'; // Create module-level logger for server lifecycle events const logger = createLogger({ component: 'container' }); +// WebSocket handler (initialized after router is ready) +let wsHandler: WebSocketHandler | null = null; + async function createApplication(): Promise<{ - fetch: (req: Request) => Promise; + fetch: (req: Request, server: ReturnType) => Promise; + router: Router; }> { // Initialize dependency injection container const container = new Container(); @@ -24,23 +34,61 @@ async function createApplication(): Promise<{ setupRoutes(router, container); return { - fetch: (req: Request) => router.route(req) + fetch: async (req: Request, server: ReturnType) => { + // Check for WebSocket upgrade request + const upgradeHeader = req.headers.get('Upgrade'); + if (upgradeHeader?.toLowerCase() === 'websocket') { + // Handle WebSocket upgrade for control plane + const url = new URL(req.url); + if (url.pathname === '/ws' || url.pathname === '/api/ws') { + const upgraded = server.upgrade(req, { + data: { + connectionId: generateConnectionId() + } as WSData + }); + if (upgraded) { + return undefined as unknown as Response; // Bun handles the upgrade + } + return new Response('WebSocket upgrade failed', { status: 500 }); + } + } + + // Regular HTTP request + return router.route(req); + }, + router }; } // Initialize the application const app = await createApplication(); +// Initialize WebSocket handler with the router +wsHandler = new WebSocketHandler(app.router, logger); + // Start the Bun server const server = serve({ idleTimeout: 255, - fetch: app.fetch, + fetch: (req, server) => app.fetch(req, server), hostname: '0.0.0.0', port: 3000, - // Enhanced WebSocket placeholder for future streaming features + // WebSocket handlers for control plane multiplexing websocket: { - async message() { - // WebSocket functionality can be added here in the future + open(ws) { + wsHandler?.onOpen(ws as unknown as ServerWebSocket); + }, + close(ws, code: number, reason: string) { + wsHandler?.onClose( + ws as unknown as ServerWebSocket, + code, + reason + ); + }, + async message(ws, message: string | Buffer) { + await wsHandler?.onMessage( + ws as unknown as ServerWebSocket, + message + ); } } }); diff --git a/packages/sandbox-container/tests/handlers/ws-handler.test.ts b/packages/sandbox-container/tests/handlers/ws-handler.test.ts new file mode 100644 index 00000000..8028f55a --- /dev/null +++ b/packages/sandbox-container/tests/handlers/ws-handler.test.ts @@ -0,0 +1,382 @@ +import type { Logger, WSError, WSRequest, WSResponse } from '@repo/shared'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Router } from '../../src/core/router'; +import { + generateConnectionId, + WebSocketHandler, + type WSData +} from '../../src/handlers/ws-handler'; + +// Mock ServerWebSocket +class MockServerWebSocket { + data: WSData; + sentMessages: string[] = []; + + constructor(data: WSData) { + this.data = data; + } + + send(message: string) { + this.sentMessages.push(message); + } + + getSentMessages(): T[] { + return this.sentMessages.map((m) => JSON.parse(m)); + } + + getLastMessage(): T { + return JSON.parse(this.sentMessages[this.sentMessages.length - 1]); + } +} + +// Mock Router +function createMockRouter(): Router { + return { + route: vi.fn() + } as unknown as Router; +} + +// Mock Logger +function createMockLogger(): Logger { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn(() => createMockLogger()) + } as unknown as Logger; +} + +describe('WebSocketHandler', () => { + let handler: WebSocketHandler; + let mockRouter: Router; + let mockLogger: Logger; + let mockWs: MockServerWebSocket; + + beforeEach(() => { + vi.clearAllMocks(); + mockRouter = createMockRouter(); + mockLogger = createMockLogger(); + handler = new WebSocketHandler(mockRouter, mockLogger); + mockWs = new MockServerWebSocket({ connectionId: 'test-conn-123' }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('onOpen', () => { + it('should log connection open', () => { + handler.onOpen(mockWs as any); + + expect(mockLogger.child).toHaveBeenCalled(); + }); + }); + + describe('onClose', () => { + it('should log connection close with code and reason', () => { + handler.onClose(mockWs as any, 1000, 'Normal closure'); + + expect(mockLogger.child).toHaveBeenCalled(); + }); + }); + + describe('onMessage', () => { + it('should handle valid request and return response', async () => { + const request: WSRequest = { + type: 'request', + id: 'req-123', + method: 'GET', + path: '/api/health' + }; + + // Mock router to return a successful response + (mockRouter.route as any).mockResolvedValue( + new Response(JSON.stringify({ status: 'ok' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ); + + await handler.onMessage(mockWs as any, JSON.stringify(request)); + + expect(mockRouter.route).toHaveBeenCalled(); + + const response = mockWs.getLastMessage(); + expect(response.type).toBe('response'); + expect(response.id).toBe('req-123'); + expect(response.status).toBe(200); + expect(response.body).toEqual({ status: 'ok' }); + expect(response.done).toBe(true); + }); + + it('should handle POST request with body', async () => { + const request: WSRequest = { + type: 'request', + id: 'req-456', + method: 'POST', + path: '/api/execute', + body: { command: 'echo hello', sessionId: 'sess-1' } + }; + + (mockRouter.route as any).mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + stdout: 'hello\n', + exitCode: 0 + }), + { status: 200 } + ) + ); + + await handler.onMessage(mockWs as any, JSON.stringify(request)); + + // Verify router was called with correct Request + const routerCall = (mockRouter.route as any).mock.calls[0][0] as Request; + expect(routerCall.method).toBe('POST'); + expect(routerCall.url).toContain('/api/execute'); + + const body = (await routerCall.clone().json()) as { command: string }; + expect(body.command).toBe('echo hello'); + }); + + it('should return error for invalid JSON', async () => { + await handler.onMessage(mockWs as any, 'not valid json'); + + const response = mockWs.getLastMessage(); + expect(response.type).toBe('error'); + expect(response.code).toBe('PARSE_ERROR'); + expect(response.status).toBe(400); + }); + + it('should return error for invalid request format', async () => { + await handler.onMessage( + mockWs as any, + JSON.stringify({ notARequest: true }) + ); + + const response = mockWs.getLastMessage(); + expect(response.type).toBe('error'); + expect(response.code).toBe('INVALID_REQUEST'); + expect(response.status).toBe(400); + }); + + it('should handle router errors gracefully', async () => { + const request: WSRequest = { + type: 'request', + id: 'req-err', + method: 'GET', + path: '/api/fail' + }; + + (mockRouter.route as any).mockRejectedValue(new Error('Router failed')); + + await handler.onMessage(mockWs as any, JSON.stringify(request)); + + const response = mockWs.getLastMessage(); + expect(response.type).toBe('error'); + expect(response.id).toBe('req-err'); + expect(response.code).toBe('INTERNAL_ERROR'); + expect(response.message).toContain('Router failed'); + expect(response.status).toBe(500); + }); + + it('should handle 404 responses', async () => { + const request: WSRequest = { + type: 'request', + id: 'req-404', + method: 'GET', + path: '/api/notfound' + }; + + (mockRouter.route as any).mockResolvedValue( + new Response( + JSON.stringify({ + code: 'NOT_FOUND', + message: 'Resource not found' + }), + { status: 404 } + ) + ); + + await handler.onMessage(mockWs as any, JSON.stringify(request)); + + const response = mockWs.getLastMessage(); + expect(response.type).toBe('response'); + expect(response.id).toBe('req-404'); + expect(response.status).toBe(404); + }); + + it('should handle streaming responses', async () => { + const request: WSRequest = { + type: 'request', + id: 'req-stream', + method: 'POST', + path: '/api/execute/stream', + body: { command: 'echo test' } + }; + + // Create a mock SSE stream + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode('event: start\ndata: {"type":"start"}\n\n') + ); + controller.enqueue( + encoder.encode('data: {"type":"stdout","text":"test\\n"}\n\n') + ); + controller.enqueue( + encoder.encode( + 'event: complete\ndata: {"type":"complete","exitCode":0}\n\n' + ) + ); + controller.close(); + } + }); + + (mockRouter.route as any).mockResolvedValue( + new Response(stream, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' } + }) + ); + + await handler.onMessage(mockWs as any, JSON.stringify(request)); + + // Should have received stream chunks and final response + const messages = mockWs.getSentMessages(); + + // Find stream chunks + const streamChunks = messages.filter((m) => m.type === 'stream'); + expect(streamChunks.length).toBeGreaterThan(0); + + // Find final response + const finalResponse = messages.find((m) => m.type === 'response'); + expect(finalResponse).toBeDefined(); + expect(finalResponse.done).toBe(true); + }); + + it('should handle Buffer messages', async () => { + const request: WSRequest = { + type: 'request', + id: 'req-buffer', + method: 'GET', + path: '/api/test' + }; + + (mockRouter.route as any).mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { status: 200 }) + ); + + // Send as Buffer + const buffer = Buffer.from(JSON.stringify(request)); + await handler.onMessage(mockWs as any, buffer); + + expect(mockRouter.route).toHaveBeenCalled(); + }); + }); + + describe('generateConnectionId', () => { + it('should generate unique connection IDs', () => { + const id1 = generateConnectionId(); + const id2 = generateConnectionId(); + + expect(id1).toMatch(/^conn_\d+_[a-z0-9]+$/); + expect(id2).toMatch(/^conn_\d+_[a-z0-9]+$/); + expect(id1).not.toBe(id2); + }); + }); +}); + +describe('WebSocket Integration', () => { + let handler: WebSocketHandler; + let mockRouter: Router; + let mockLogger: Logger; + + beforeEach(() => { + mockRouter = createMockRouter(); + mockLogger = createMockLogger(); + handler = new WebSocketHandler(mockRouter, mockLogger); + }); + + it('should handle multiple concurrent requests', async () => { + const mockWs = new MockServerWebSocket({ connectionId: 'concurrent-test' }); + + const requests: WSRequest[] = [ + { type: 'request', id: 'req-1', method: 'GET', path: '/api/one' }, + { type: 'request', id: 'req-2', method: 'GET', path: '/api/two' }, + { type: 'request', id: 'req-3', method: 'GET', path: '/api/three' } + ]; + + // Router returns different responses based on path + (mockRouter.route as any).mockImplementation((req: Request) => { + const path = new URL(req.url).pathname; + return new Response(JSON.stringify({ path }), { status: 200 }); + }); + + // Process all requests concurrently + await Promise.all( + requests.map((req) => + handler.onMessage(mockWs as any, JSON.stringify(req)) + ) + ); + + const responses = mockWs.getSentMessages(); + expect(responses).toHaveLength(3); + + // Verify each request got its correct response + const responseIds = responses.map((r) => r.id).sort(); + expect(responseIds).toEqual(['req-1', 'req-2', 'req-3']); + + // Verify response bodies match request paths + responses.forEach((r) => { + expect(r.body).toBeDefined(); + }); + }); + + it('should maintain request isolation', async () => { + const mockWs = new MockServerWebSocket({ connectionId: 'isolation-test' }); + + // First request fails + const failRequest: WSRequest = { + type: 'request', + id: 'fail-req', + method: 'GET', + path: '/api/fail' + }; + + // Second request succeeds + const successRequest: WSRequest = { + type: 'request', + id: 'success-req', + method: 'GET', + path: '/api/success' + }; + + (mockRouter.route as any).mockImplementation((req: Request) => { + const path = new URL(req.url).pathname; + if (path === '/api/fail') { + throw new Error('Intentional failure'); + } + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }); + + // Process both requests + await handler.onMessage(mockWs as any, JSON.stringify(failRequest)); + await handler.onMessage(mockWs as any, JSON.stringify(successRequest)); + + const messages = mockWs.getSentMessages(); + expect(messages).toHaveLength(2); + + // First should be error + const errorMsg = messages.find((m) => m.id === 'fail-req'); + expect(errorMsg.type).toBe('error'); + + // Second should succeed + const successMsg = messages.find((m) => m.id === 'success-req'); + expect(successMsg.type).toBe('response'); + expect(successMsg.status).toBe(200); + }); +}); diff --git a/packages/sandbox/src/clients/base-client.ts b/packages/sandbox/src/clients/base-client.ts index fcd64634..1a3c9b4e 100644 --- a/packages/sandbox/src/clients/base-client.ts +++ b/packages/sandbox/src/clients/base-client.ts @@ -4,6 +4,7 @@ import { getHttpStatus } from '@repo/shared/errors'; import type { ErrorResponse as NewErrorResponse } from '../errors'; import { createErrorFromResponse, ErrorCode } from '../errors'; import type { SandboxError } from '../errors/classes'; +import { createTransport, type Transport } from './transport'; import type { HttpClientOptions, ResponseHandler } from './types'; // Container startup retry configuration @@ -11,26 +12,107 @@ const TIMEOUT_MS = 120_000; // 2 minutes total retry budget const MIN_TIME_FOR_RETRY_MS = 15_000; // Need at least 15s remaining to retry (allows for longer container startups) /** - * Abstract base class providing common HTTP functionality for all domain clients + * Abstract base class providing common HTTP/WebSocket functionality for all domain clients + * + * Supports two transport modes: + * - HTTP (default): Each request is a separate HTTP call + * - WebSocket: All requests multiplexed over a single connection + * + * WebSocket mode is useful when running inside Workers/Durable Objects + * where sub-request limits apply. */ export abstract class BaseHttpClient { protected baseUrl: string; protected options: HttpClientOptions; protected logger: Logger; + protected transport: Transport | null = null; constructor(options: HttpClientOptions = {}) { this.options = options; this.logger = options.logger ?? createNoOpLogger(); this.baseUrl = this.options.baseUrl!; + + // Use provided transport or create one if WebSocket mode is enabled + if (options.transport) { + this.transport = options.transport; + } else if (options.transportMode === 'websocket' && options.wsUrl) { + this.transport = createTransport({ + mode: 'websocket', + wsUrl: options.wsUrl, + logger: this.logger + }); + } + } + + /** + * Check if using WebSocket transport + */ + protected isWebSocketMode(): boolean { + return this.transport?.getMode() === 'websocket'; } /** * Core HTTP request method with automatic retry for container startup delays * Retries both 503 (provisioning) and 500 (startup failure) errors when they're container-related + * + * When WebSocket transport is enabled, this creates a Response-like object + * from the WebSocket response for compatibility with existing code. */ protected async doFetch( path: string, options?: RequestInit + ): Promise { + // Use WebSocket transport if available + if (this.transport?.getMode() === 'websocket') { + return this.doWebSocketFetch(path, options); + } + + // Fall back to HTTP transport + return this.doHttpFetch(path, options); + } + + /** + * WebSocket-based fetch implementation + * Converts WebSocket request/response to Response object for compatibility + */ + private async doWebSocketFetch( + path: string, + options?: RequestInit + ): Promise { + if (!this.transport) { + throw new Error('WebSocket transport not initialized'); + } + + const method = (options?.method || 'GET') as + | 'GET' + | 'POST' + | 'PUT' + | 'DELETE'; + let body: unknown; + + if (options?.body && typeof options.body === 'string') { + try { + body = JSON.parse(options.body); + } catch { + body = options.body; + } + } + + const result = await this.transport.request(method, path, body); + + // Create a Response-like object for compatibility + return new Response(JSON.stringify(result.body), { + status: result.status, + headers: { 'Content-Type': 'application/json' } + }); + } + + /** + * HTTP-based fetch implementation with retry logic + */ + private async doHttpFetch( + path: string, + options?: RequestInit ): Promise { const startTime = Date.now(); let attempt = 0; @@ -201,6 +283,33 @@ export abstract class BaseHttpClient { return response.body; } + /** + * Stream request handler for WebSocket transport + * Returns a ReadableStream that receives data over WebSocket + * @param path - The API path to call + * @param body - Optional request body (for POST requests) + * @param method - HTTP method (default: POST, use GET for process logs) + */ + protected async doStreamFetch( + path: string, + body?: unknown, + method: 'GET' | 'POST' = 'POST' + ): Promise> { + // Use WebSocket transport if available + if (this.transport?.getMode() === 'websocket') { + return this.transport.requestStream(method, path, body); + } + + // Fall back to HTTP streaming + const response = await this.doFetch(path, { + method, + headers: { 'Content-Type': 'application/json' }, + body: body && method === 'POST' ? JSON.stringify(body) : undefined + }); + + return this.handleStreamResponse(response); + } + /** * Utility method to log successful operations */ diff --git a/packages/sandbox/src/clients/command-client.ts b/packages/sandbox/src/clients/command-client.ts index f97c0114..c4e88b99 100644 --- a/packages/sandbox/src/clients/command-client.ts +++ b/packages/sandbox/src/clients/command-client.ts @@ -105,15 +105,8 @@ export class CommandClient extends BaseHttpClient { ...(options?.cwd !== undefined && { cwd: options.cwd }) }; - const response = await this.doFetch('/api/execute/stream', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(data) - }); - - const stream = await this.handleStreamResponse(response); + // Use doStreamFetch which handles both WebSocket and HTTP streaming + const stream = await this.doStreamFetch('/api/execute/stream', data); this.logSuccess('Command stream started', command); diff --git a/packages/sandbox/src/clients/file-client.ts b/packages/sandbox/src/clients/file-client.ts index ab99cfcc..af81c797 100644 --- a/packages/sandbox/src/clients/file-client.ts +++ b/packages/sandbox/src/clients/file-client.ts @@ -158,15 +158,8 @@ export class FileClient extends BaseHttpClient { sessionId }; - const response = await this.doFetch('/api/read/stream', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(data) - }); - - const stream = await this.handleStreamResponse(response); + // Use doStreamFetch which handles both WebSocket and HTTP streaming + const stream = await this.doStreamFetch('/api/read/stream', data); this.logSuccess('File stream started', path); return stream; } catch (error) { diff --git a/packages/sandbox/src/clients/index.ts b/packages/sandbox/src/clients/index.ts index 840ef1ce..1201689a 100644 --- a/packages/sandbox/src/clients/index.ts +++ b/packages/sandbox/src/clients/index.ts @@ -41,6 +41,13 @@ export type { } from './process-client'; export { ProcessClient } from './process-client'; export { SandboxClient } from './sandbox-client'; +// Transport layer +export type { + TransportMode, + TransportOptions, + TransportResponse +} from './transport'; +export { createTransport, Transport } from './transport'; // Types and interfaces export type { BaseApiResponse, @@ -62,3 +69,4 @@ export type { VersionResponse } from './utility-client'; export { UtilityClient } from './utility-client'; +export { WSTransport } from './ws-transport'; diff --git a/packages/sandbox/src/clients/interpreter-client.ts b/packages/sandbox/src/clients/interpreter-client.ts index 9ce9d8b6..f47d2f83 100644 --- a/packages/sandbox/src/clients/interpreter-client.ts +++ b/packages/sandbox/src/clients/interpreter-client.ts @@ -104,31 +104,16 @@ export class InterpreterClient extends BaseHttpClient { timeoutMs?: number ): Promise { return this.executeWithRetry(async () => { - const response = await this.doFetch('/api/execute/code', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'text/event-stream' - }, - body: JSON.stringify({ - context_id: contextId, - code, - language, - ...(timeoutMs !== undefined && { timeout_ms: timeoutMs }) - }) + // Use doStreamFetch which handles both HTTP and WebSocket streaming + const stream = await this.doStreamFetch('/api/execute/code', { + context_id: contextId, + code, + language, + ...(timeoutMs !== undefined && { timeout_ms: timeoutMs }) }); - if (!response.ok) { - const error = await this.parseErrorResponse(response); - throw error; - } - - if (!response.body) { - throw new Error('No response body for streaming execution'); - } - // Process streaming response - for await (const chunk of this.readLines(response.body)) { + for await (const chunk of this.readLines(stream)) { await this.parseExecutionResult(chunk, callbacks); } }); diff --git a/packages/sandbox/src/clients/process-client.ts b/packages/sandbox/src/clients/process-client.ts index 4dbb32de..1c308551 100644 --- a/packages/sandbox/src/clients/process-client.ts +++ b/packages/sandbox/src/clients/process-client.ts @@ -181,11 +181,8 @@ export class ProcessClient extends BaseHttpClient { ): Promise> { try { const url = `/api/process/${processId}/stream`; - const response = await this.doFetch(url, { - method: 'GET' - }); - - const stream = await this.handleStreamResponse(response); + // Use doStreamFetch with GET method (process log streaming is GET) + const stream = await this.doStreamFetch(url, undefined, 'GET'); this.logSuccess('Process log stream started', `ID: ${processId}`); diff --git a/packages/sandbox/src/clients/sandbox-client.ts b/packages/sandbox/src/clients/sandbox-client.ts index fa1599eb..2023f98c 100644 --- a/packages/sandbox/src/clients/sandbox-client.ts +++ b/packages/sandbox/src/clients/sandbox-client.ts @@ -4,12 +4,23 @@ import { GitClient } from './git-client'; import { InterpreterClient } from './interpreter-client'; import { PortClient } from './port-client'; import { ProcessClient } from './process-client'; +import { + createTransport, + type Transport, + type TransportMode +} from './transport'; import type { HttpClientOptions } from './types'; import { UtilityClient } from './utility-client'; /** * Main sandbox client that composes all domain-specific clients * Provides organized access to all sandbox functionality + * + * Supports two transport modes: + * - HTTP (default): Each request is a separate HTTP call + * - WebSocket: All requests multiplexed over a single connection + * + * WebSocket mode reduces sub-request count when running inside Workers/Durable Objects. */ export class SandboxClient { public readonly commands: CommandClient; @@ -20,11 +31,27 @@ export class SandboxClient { public readonly interpreter: InterpreterClient; public readonly utils: UtilityClient; + private transport: Transport | null = null; + constructor(options: HttpClientOptions) { + // Create shared transport if WebSocket mode is enabled + if (options.transportMode === 'websocket' && options.wsUrl) { + this.transport = createTransport({ + mode: 'websocket', + wsUrl: options.wsUrl, + baseUrl: options.baseUrl, + logger: options.logger, + stub: options.stub, + port: options.port + }); + } + // Ensure baseUrl is provided for all clients const clientOptions: HttpClientOptions = { baseUrl: 'http://localhost:3000', - ...options + ...options, + // Share transport across all clients + transport: this.transport ?? options.transport }; // Initialize all domain clients with shared options @@ -36,4 +63,39 @@ export class SandboxClient { this.interpreter = new InterpreterClient(clientOptions); this.utils = new UtilityClient(clientOptions); } + + /** + * Get the current transport mode + */ + getTransportMode(): TransportMode { + return this.transport?.getMode() ?? 'http'; + } + + /** + * Check if WebSocket is connected (only relevant in WebSocket mode) + */ + isWebSocketConnected(): boolean { + return this.transport?.isWebSocketConnected() ?? false; + } + + /** + * Connect WebSocket transport (no-op in HTTP mode) + * Called automatically on first request, but can be called explicitly + * to establish connection upfront. + */ + async connect(): Promise { + if (this.transport) { + await this.transport.connect(); + } + } + + /** + * Disconnect WebSocket transport (no-op in HTTP mode) + * Should be called when the sandbox is destroyed. + */ + disconnect(): void { + if (this.transport) { + this.transport.disconnect(); + } + } } diff --git a/packages/sandbox/src/clients/transport.ts b/packages/sandbox/src/clients/transport.ts new file mode 100644 index 00000000..69c317b5 --- /dev/null +++ b/packages/sandbox/src/clients/transport.ts @@ -0,0 +1,245 @@ +import type { Logger } from '@repo/shared'; +import { createNoOpLogger } from '@repo/shared'; +import type { ContainerStub } from './types'; +import { WSTransport } from './ws-transport'; + +/** + * Transport mode for SDK communication + */ +export type TransportMode = 'http' | 'websocket'; + +/** + * Transport configuration options + */ +export interface TransportOptions { + /** Transport mode */ + mode: TransportMode; + + /** Base URL for HTTP mode */ + baseUrl?: string; + + /** WebSocket URL for WebSocket mode */ + wsUrl?: string; + + /** Logger instance */ + logger?: Logger; + + /** Container stub for DO-internal requests */ + stub?: ContainerStub; + + /** Port number */ + port?: number; + + /** Request timeout in milliseconds */ + requestTimeoutMs?: number; +} + +/** + * HTTP response-like structure + */ +export interface TransportResponse { + status: number; + ok: boolean; + body: unknown; + stream?: ReadableStream; +} + +/** + * Transport abstraction layer + * + * Provides a unified interface for HTTP and WebSocket transports. + * The SandboxClient uses this to communicate with the container. + */ +export class Transport { + private mode: TransportMode; + private baseUrl: string; + private wsTransport: WSTransport | null = null; + private logger: Logger; + private stub?: ContainerStub; + private port?: number; + + constructor(options: TransportOptions) { + this.mode = options.mode; + this.baseUrl = options.baseUrl ?? 'http://localhost:3000'; + this.logger = options.logger ?? createNoOpLogger(); + this.stub = options.stub; + this.port = options.port; + + if (this.mode === 'websocket' && options.wsUrl) { + this.wsTransport = new WSTransport(options.wsUrl, { + logger: this.logger, + requestTimeoutMs: options.requestTimeoutMs, + stub: options.stub, + port: options.port + }); + } + } + + /** + * Get the current transport mode + */ + getMode(): TransportMode { + return this.mode; + } + + /** + * Check if WebSocket is connected + */ + isWebSocketConnected(): boolean { + return this.wsTransport?.isConnected() ?? false; + } + + /** + * Connect WebSocket (no-op for HTTP mode) + */ + async connect(): Promise { + if (this.mode === 'websocket' && this.wsTransport) { + await this.wsTransport.connect(); + } + } + + /** + * Disconnect WebSocket (no-op for HTTP mode) + */ + disconnect(): void { + if (this.wsTransport) { + this.wsTransport.disconnect(); + } + } + + /** + * Make a request using the configured transport + */ + async request( + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, + body?: unknown + ): Promise { + if (this.mode === 'websocket' && this.wsTransport) { + return this.wsRequest(method, path, body); + } + return this.httpRequest(method, path, body); + } + + /** + * Make a streaming request using the configured transport + */ + async requestStream( + method: 'GET' | 'POST', + path: string, + body?: unknown + ): Promise> { + if (this.mode === 'websocket' && this.wsTransport) { + return this.wsTransport.requestStream(method, path, body); + } + return this.httpRequestStream(method, path, body); + } + + /** + * Make an HTTP request + */ + private async httpRequest( + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, + body?: unknown + ): Promise { + const url = this.stub + ? `http://localhost:${this.port}${path}` + : `${this.baseUrl}${path}`; + + const options: RequestInit = { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined + }; + + let response: Response; + if (this.stub) { + response = await this.stub.containerFetch(url, options, this.port); + } else { + response = await fetch(url, options); + } + + // Parse JSON body if possible + let responseBody: unknown; + try { + responseBody = await response.json(); + } catch { + responseBody = undefined; + } + + return { + status: response.status, + ok: response.ok, + body: responseBody + }; + } + + /** + * Make an HTTP streaming request + */ + private async httpRequestStream( + method: 'GET' | 'POST', + path: string, + body?: unknown + ): Promise> { + const url = this.stub + ? `http://localhost:${this.port}${path}` + : `${this.baseUrl}${path}`; + + const options: RequestInit = { + method, + headers: + body && method === 'POST' + ? { 'Content-Type': 'application/json' } + : undefined, + body: body && method === 'POST' ? JSON.stringify(body) : undefined + }; + + let response: Response; + if (this.stub) { + response = await this.stub.containerFetch(url, options, this.port); + } else { + response = await fetch(url, options); + } + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`HTTP error! status: ${response.status} - ${errorBody}`); + } + + if (!response.body) { + throw new Error('No response body for streaming'); + } + + return response.body; + } + + /** + * Make a WebSocket request + */ + private async wsRequest( + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, + body?: unknown + ): Promise { + if (!this.wsTransport) { + throw new Error('WebSocket transport not initialized'); + } + + const result = await this.wsTransport.request(method, path, body); + + return { + status: result.status, + ok: result.status >= 200 && result.status < 300, + body: result.body + }; + } +} + +/** + * Create a transport instance based on options + */ +export function createTransport(options: TransportOptions): Transport { + return new Transport(options); +} diff --git a/packages/sandbox/src/clients/types.ts b/packages/sandbox/src/clients/types.ts index b59d8af3..7c0545a7 100644 --- a/packages/sandbox/src/clients/types.ts +++ b/packages/sandbox/src/clients/types.ts @@ -1,4 +1,5 @@ import type { Logger } from '@repo/shared'; +import type { Transport, TransportMode } from './transport'; /** * Minimal interface for container fetch functionality @@ -27,6 +28,25 @@ export interface HttpClientOptions { command: string ) => void; onError?: (error: string, command?: string) => void; + + /** + * Transport mode: 'http' (default) or 'websocket' + * WebSocket mode multiplexes all requests over a single connection, + * reducing sub-request count in Workers/Durable Objects. + */ + transportMode?: TransportMode; + + /** + * WebSocket URL for WebSocket transport mode. + * Required when transportMode is 'websocket'. + */ + wsUrl?: string; + + /** + * Shared transport instance (for internal use). + * When provided, clients will use this transport instead of creating their own. + */ + transport?: Transport; } /** diff --git a/packages/sandbox/src/clients/ws-transport.ts b/packages/sandbox/src/clients/ws-transport.ts new file mode 100644 index 00000000..2e1efc1a --- /dev/null +++ b/packages/sandbox/src/clients/ws-transport.ts @@ -0,0 +1,512 @@ +import type { Logger } from '@repo/shared'; +import { + createNoOpLogger, + generateRequestId, + isWSError, + isWSResponse, + isWSStreamChunk, + type WSMethod, + type WSRequest, + type WSResponse, + type WSServerMessage, + type WSStreamChunk +} from '@repo/shared'; +import type { ContainerStub } from './types'; + +/** + * Pending request tracker for response matching + */ +interface PendingRequest { + resolve: (response: WSResponse) => void; + reject: (error: Error) => void; + streamController?: ReadableStreamDefaultController; + isStreaming: boolean; +} + +/** + * WebSocket transport configuration + */ +export interface WSTransportOptions { + /** Logger instance */ + logger?: Logger; + + /** Connection timeout in milliseconds */ + connectTimeoutMs?: number; + + /** Request timeout in milliseconds */ + requestTimeoutMs?: number; + + /** + * Container stub for DO-internal WebSocket connections. + * When provided, uses fetch-based WebSocket (Workers style) instead of new WebSocket(). + */ + stub?: ContainerStub; + + /** Port number for container connection */ + port?: number; +} + +/** + * WebSocket transport state + */ +type WSTransportState = 'disconnected' | 'connecting' | 'connected' | 'error'; + +/** + * WebSocket transport layer for multiplexing HTTP-like requests + * + * Maintains a single WebSocket connection and multiplexes requests using + * unique IDs. Supports both request/response and streaming patterns. + */ +export class WSTransport { + private ws: WebSocket | null = null; + private state: WSTransportState = 'disconnected'; + private pendingRequests: Map = new Map(); + private connectPromise: Promise | null = null; + private logger: Logger; + private options: WSTransportOptions; + private url: string; + private stub?: ContainerStub; + private port?: number; + + // Bound event handlers for proper add/remove + private boundHandleMessage: (event: MessageEvent) => void; + private boundHandleClose: (event: CloseEvent) => void; + + constructor(url: string, options: WSTransportOptions = {}) { + this.url = url; + this.options = options; + this.logger = options.logger ?? createNoOpLogger(); + this.stub = options.stub; + this.port = options.port; + + // Bind handlers once in constructor + this.boundHandleMessage = this.handleMessage.bind(this); + this.boundHandleClose = this.handleClose.bind(this); + } + + /** + * Check if WebSocket is connected + */ + isConnected(): boolean { + return this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN; + } + + /** + * Connect to the WebSocket server + */ + async connect(): Promise { + // Already connected + if (this.isConnected()) { + return; + } + + // Connection in progress + if (this.connectPromise) { + return this.connectPromise; + } + + this.state = 'connecting'; + + // Use fetch-based WebSocket for DO context (Workers style) + if (this.stub) { + this.connectPromise = this.connectViaFetch(); + } else { + // Use standard WebSocket for browser/Node + this.connectPromise = this.connectViaWebSocket(); + } + + try { + await this.connectPromise; + } finally { + this.connectPromise = null; + } + } + + /** + * Connect using fetch-based WebSocket (Cloudflare Workers style) + * This is required when running inside a Durable Object. + */ + private async connectViaFetch(): Promise { + const timeoutMs = this.options.connectTimeoutMs ?? 30000; + + // Create abort controller for timeout + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + // Build the WebSocket URL for the container + const wsPath = new URL(this.url).pathname; + const httpUrl = `http://localhost:${this.port || 3000}${wsPath}`; + + // Use containerFetch with upgrade headers to establish WebSocket + const response = await this.stub!.containerFetch( + httpUrl, + { + headers: { + Upgrade: 'websocket', + Connection: 'Upgrade' + }, + signal: controller.signal + }, + this.port || 3000 + ); + + clearTimeout(timeout); + + // Check if upgrade was successful + if (response.status !== 101) { + throw new Error( + `WebSocket upgrade failed: ${response.status} ${response.statusText}` + ); + } + + // Get the WebSocket from the response (Workers-specific API) + const ws = (response as unknown as { webSocket?: WebSocket }).webSocket; + if (!ws) { + throw new Error('No WebSocket in upgrade response'); + } + + // Accept the WebSocket connection (Workers-specific) + (ws as unknown as { accept: () => void }).accept(); + + this.ws = ws; + this.state = 'connected'; + + // Set up event handlers + this.ws.addEventListener('close', this.boundHandleClose); + this.ws.addEventListener('message', this.boundHandleMessage); + + this.logger.debug('WebSocket connected via fetch', { url: this.url }); + } catch (error) { + clearTimeout(timeout); + this.state = 'error'; + this.logger.error( + 'WebSocket fetch connection failed', + error instanceof Error ? error : new Error(String(error)) + ); + throw error; + } + } + + /** + * Connect using standard WebSocket API (browser/Node style) + */ + private connectViaWebSocket(): Promise { + return new Promise((resolve, reject) => { + const timeoutMs = this.options.connectTimeoutMs ?? 30000; + const timeout = setTimeout(() => { + this.cleanup(); + reject(new Error(`WebSocket connection timeout after ${timeoutMs}ms`)); + }, timeoutMs); + + try { + this.ws = new WebSocket(this.url); + + // One-time open handler for connection + const onOpen = () => { + clearTimeout(timeout); + this.ws?.removeEventListener('open', onOpen); + this.ws?.removeEventListener('error', onConnectError); + this.state = 'connected'; + this.logger.debug('WebSocket connected', { url: this.url }); + resolve(); + }; + + // One-time error handler for connection + const onConnectError = () => { + clearTimeout(timeout); + this.ws?.removeEventListener('open', onOpen); + this.ws?.removeEventListener('error', onConnectError); + this.state = 'error'; + this.logger.error( + 'WebSocket error', + new Error('WebSocket connection failed') + ); + reject(new Error('WebSocket connection failed')); + }; + + this.ws.addEventListener('open', onOpen); + this.ws.addEventListener('error', onConnectError); + this.ws.addEventListener('close', this.boundHandleClose); + this.ws.addEventListener('message', this.boundHandleMessage); + } catch (error) { + clearTimeout(timeout); + this.state = 'error'; + reject(error); + } + }); + } + + /** + * Disconnect from the WebSocket server + */ + disconnect(): void { + this.cleanup(); + } + + /** + * Send a request and wait for response + */ + async request( + method: WSMethod, + path: string, + body?: unknown + ): Promise<{ status: number; body: T }> { + await this.connect(); + + const id = generateRequestId(); + const request: WSRequest = { + type: 'request', + id, + method, + path, + body + }; + + return new Promise((resolve, reject) => { + const timeoutMs = this.options.requestTimeoutMs ?? 120000; + const timeout = setTimeout(() => { + this.pendingRequests.delete(id); + reject( + new Error(`Request timeout after ${timeoutMs}ms: ${method} ${path}`) + ); + }, timeoutMs); + + this.pendingRequests.set(id, { + resolve: (response: WSResponse) => { + clearTimeout(timeout); + this.pendingRequests.delete(id); + resolve({ status: response.status, body: response.body as T }); + }, + reject: (error: Error) => { + clearTimeout(timeout); + this.pendingRequests.delete(id); + reject(error); + }, + isStreaming: false + }); + + this.send(request); + }); + } + + /** + * Send a streaming request and return a ReadableStream + * + * The stream will receive data chunks as they arrive over the WebSocket. + * Format matches SSE for compatibility with existing streaming code. + */ + async requestStream( + method: WSMethod, + path: string, + body?: unknown + ): Promise> { + await this.connect(); + + const id = generateRequestId(); + const request: WSRequest = { + type: 'request', + id, + method, + path, + body + }; + + const encoder = new TextEncoder(); + + return new ReadableStream({ + start: (controller) => { + const timeoutMs = this.options.requestTimeoutMs ?? 120000; + const timeout = setTimeout(() => { + this.pendingRequests.delete(id); + controller.error( + new Error(`Stream timeout after ${timeoutMs}ms: ${method} ${path}`) + ); + }, timeoutMs); + + this.pendingRequests.set(id, { + resolve: (response: WSResponse) => { + clearTimeout(timeout); + this.pendingRequests.delete(id); + // Final response - close the stream + if (response.status >= 400) { + controller.error( + new Error( + `Stream error: ${response.status} - ${JSON.stringify(response.body)}` + ) + ); + } else { + controller.close(); + } + }, + reject: (error: Error) => { + clearTimeout(timeout); + this.pendingRequests.delete(id); + controller.error(error); + }, + streamController: controller, + isStreaming: true + }); + + this.send(request); + }, + cancel: () => { + this.pendingRequests.delete(id); + // Could send a cancel message to server if needed + } + }); + } + + /** + * Send a message over the WebSocket + */ + private send(message: WSRequest): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket not connected'); + } + + this.ws.send(JSON.stringify(message)); + this.logger.debug('WebSocket sent', { + id: message.id, + method: message.method, + path: message.path + }); + } + + /** + * Handle incoming WebSocket messages + */ + private handleMessage(event: MessageEvent): void { + try { + const message = JSON.parse(event.data) as WSServerMessage; + + if (isWSResponse(message)) { + this.handleResponse(message); + } else if (isWSStreamChunk(message)) { + this.handleStreamChunk(message); + } else if (isWSError(message)) { + this.handleError(message); + } else { + this.logger.warn('Unknown WebSocket message type', { message }); + } + } catch (error) { + this.logger.error( + 'Failed to parse WebSocket message', + error instanceof Error ? error : new Error(String(error)) + ); + } + } + + /** + * Handle a response message + */ + private handleResponse(response: WSResponse): void { + const pending = this.pendingRequests.get(response.id); + if (!pending) { + this.logger.warn('Received response for unknown request', { + id: response.id + }); + return; + } + + this.logger.debug('WebSocket response', { + id: response.id, + status: response.status, + done: response.done + }); + + // Only resolve when done is true + if (response.done) { + pending.resolve(response); + } + } + + /** + * Handle a stream chunk message + */ + private handleStreamChunk(chunk: WSStreamChunk): void { + const pending = this.pendingRequests.get(chunk.id); + if (!pending || !pending.streamController) { + this.logger.warn('Received stream chunk for unknown request', { + id: chunk.id + }); + return; + } + + // Convert to SSE format for compatibility with existing parsers + const encoder = new TextEncoder(); + let sseData: string; + if (chunk.event) { + sseData = `event: ${chunk.event}\ndata: ${chunk.data}\n\n`; + } else { + sseData = `data: ${chunk.data}\n\n`; + } + + try { + pending.streamController.enqueue(encoder.encode(sseData)); + } catch (error) { + // Stream may have been cancelled + this.logger.debug('Failed to enqueue stream chunk', { + id: chunk.id, + error: error instanceof Error ? error.message : String(error) + }); + } + } + + /** + * Handle an error message + */ + private handleError(error: { + id?: string; + code: string; + message: string; + status: number; + }): void { + if (error.id) { + const pending = this.pendingRequests.get(error.id); + if (pending) { + pending.reject(new Error(`${error.code}: ${error.message}`)); + return; + } + } + + // Global error - log it + this.logger.error('WebSocket error message', new Error(error.message), { + code: error.code, + status: error.status + }); + } + + /** + * Handle WebSocket close + */ + private handleClose(event: CloseEvent): void { + this.state = 'disconnected'; + this.ws = null; + + // Reject all pending requests + for (const [id, pending] of this.pendingRequests) { + pending.reject( + new Error( + `WebSocket closed: ${event.code} ${event.reason || 'No reason'}` + ) + ); + } + this.pendingRequests.clear(); + } + + /** + * Cleanup resources + */ + private cleanup(): void { + if (this.ws) { + this.ws.removeEventListener('close', this.boundHandleClose); + this.ws.removeEventListener('message', this.boundHandleMessage); + this.ws.close(); + this.ws = null; + } + this.state = 'disconnected'; + this.connectPromise = null; + this.pendingRequests.clear(); + } +} diff --git a/packages/sandbox/src/interpreter.ts b/packages/sandbox/src/interpreter.ts index ec42cc6f..2dc2c775 100644 --- a/packages/sandbox/src/interpreter.ts +++ b/packages/sandbox/src/interpreter.ts @@ -96,38 +96,12 @@ export class CodeInterpreter { context = await this.getOrCreateDefaultContext(language); } - // Create streaming response - // Note: doFetch is protected but we need direct access for raw stream response - const response = await (this.interpreterClient as any).doFetch( - '/api/execute/code', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'text/event-stream' - }, - body: JSON.stringify({ - context_id: context.id, - code, - language: options.language - }) - } - ); - - if (!response.ok) { - const errorData = (await response - .json() - .catch(() => ({ error: 'Unknown error' }))) as { error?: string }; - throw new Error( - errorData.error || `Failed to execute code: ${response.status}` - ); - } - - if (!response.body) { - throw new Error('No response body for streaming execution'); - } - - return response.body; + // Use doStreamFetch which handles both HTTP and WebSocket streaming + return (this.interpreterClient as any).doStreamFetch('/api/execute/code', { + context_id: context.id, + code, + language: options.language + }); } /** diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index cc74869c..c94b278c 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -95,6 +95,10 @@ export function getSandbox>( stub.setContainerTimeouts(options.containerTimeouts); } + if (options?.useWebSocket !== undefined) { + stub.setUseWebSocket(options.useWebSocket); + } + return Object.assign(stub, { wsConnect: connect(stub) }) as T; @@ -129,6 +133,7 @@ export class Sandbox extends Container implements ISandbox { private logger: ReturnType; private keepAliveEnabled: boolean = false; private activeMounts: Map = new Map(); + private useWebSocketTransport: boolean = false; /** * Default container startup timeouts (conservative for production) @@ -212,6 +217,23 @@ export class Sandbox extends Container implements ISandbox { ...storedTimeouts }; } + + // Load WebSocket transport setting + const storedUseWebSocket = + (await this.ctx.storage.get('useWebSocket')) || false; + if (storedUseWebSocket) { + this.useWebSocketTransport = true; + // Recreate client with WebSocket transport + this.client = new SandboxClient({ + logger: this.logger, + port: 3000, + stub: this, + transportMode: 'websocket', + wsUrl: 'ws://localhost:3000/ws' + }); + // Re-initialize code interpreter with new client + this.codeInterpreter = new CodeInterpreter(this); + } }); } @@ -257,6 +279,48 @@ export class Sandbox extends Container implements ISandbox { } } + /** + * RPC method to enable WebSocket transport for control plane communication + * + * When enabled, all sandbox operations are multiplexed over a single WebSocket + * connection instead of individual HTTP requests, reducing sub-request count. + * + * Note: The WebSocket connection is established on the first request. + */ + async setUseWebSocket(useWebSocket: boolean): Promise { + if (this.useWebSocketTransport === useWebSocket) { + return; // No change needed + } + + this.useWebSocketTransport = useWebSocket; + await this.ctx.storage.put('useWebSocket', useWebSocket); + + if (useWebSocket) { + this.logger.info( + 'WebSocket transport enabled - requests will be multiplexed over single connection' + ); + // Recreate client with WebSocket transport + this.client = new SandboxClient({ + logger: this.logger, + port: 3000, + stub: this, + transportMode: 'websocket', + wsUrl: 'ws://localhost:3000/ws' + }); + } else { + this.logger.info('WebSocket transport disabled - using HTTP requests'); + // Recreate client with HTTP transport + this.client = new SandboxClient({ + logger: this.logger, + port: 3000, + stub: this + }); + } + + // Re-initialize code interpreter with new client + this.codeInterpreter = new CodeInterpreter(this); + } + // RPC method to set environment variables async setEnvVars(envVars: Record): Promise { // Update local state for new sessions diff --git a/packages/sandbox/tests/transport.test.ts b/packages/sandbox/tests/transport.test.ts new file mode 100644 index 00000000..34c22e35 --- /dev/null +++ b/packages/sandbox/tests/transport.test.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createTransport, Transport } from '../src/clients/transport'; + +describe('Transport', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('HTTP mode', () => { + it('should create transport in HTTP mode by default', () => { + const transport = createTransport({ + mode: 'http', + baseUrl: 'http://localhost:3000' + }); + + expect(transport.getMode()).toBe('http'); + }); + + it('should make HTTP GET request', async () => { + const transport = createTransport({ + mode: 'http', + baseUrl: 'http://localhost:3000' + }); + + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ data: 'test' }), { status: 200 }) + ); + + const result = await transport.request('GET', '/api/test'); + + expect(result.status).toBe(200); + expect(result.body).toEqual({ data: 'test' }); + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:3000/api/test', + expect.objectContaining({ method: 'GET' }) + ); + }); + + it('should make HTTP POST request with body', async () => { + const transport = createTransport({ + mode: 'http', + baseUrl: 'http://localhost:3000' + }); + + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + const result = await transport.request('POST', '/api/execute', { + command: 'echo hello' + }); + + expect(result.status).toBe(200); + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:3000/api/execute', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ command: 'echo hello' }) + }) + ); + }); + + it('should handle HTTP errors', async () => { + const transport = createTransport({ + mode: 'http', + baseUrl: 'http://localhost:3000' + }); + + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ error: 'Not found' }), { status: 404 }) + ); + + const result = await transport.request('GET', '/api/missing'); + + expect(result.status).toBe(404); + }); + + it('should stream HTTP responses', async () => { + const transport = createTransport({ + mode: 'http', + baseUrl: 'http://localhost:3000' + }); + + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('data: test\n\n')); + controller.close(); + } + }); + + mockFetch.mockResolvedValue( + new Response(mockStream, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' } + }) + ); + + const stream = await transport.requestStream('POST', '/api/stream', {}); + + expect(stream).toBeInstanceOf(ReadableStream); + }); + + it('should use stub.containerFetch when stub is provided', async () => { + const mockContainerFetch = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { status: 200 }) + ); + + const transport = createTransport({ + mode: 'http', + baseUrl: 'http://localhost:3000', + stub: { containerFetch: mockContainerFetch }, + port: 3000 + }); + + await transport.request('GET', '/api/test'); + + expect(mockContainerFetch).toHaveBeenCalledWith( + 'http://localhost:3000/api/test', + expect.any(Object), + 3000 + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('WebSocket mode', () => { + // Note: Full WebSocket tests are in ws-transport.test.ts + // These tests verify the Transport wrapper behavior + + it('should create transport in WebSocket mode', () => { + const transport = createTransport({ + mode: 'websocket', + wsUrl: 'ws://localhost:3000/ws' + }); + + expect(transport.getMode()).toBe('websocket'); + }); + + it('should report WebSocket connection state', () => { + const transport = createTransport({ + mode: 'websocket', + wsUrl: 'ws://localhost:3000/ws' + }); + + // Initially not connected + expect(transport.isWebSocketConnected()).toBe(false); + }); + + it('should handle missing WebSocket URL gracefully', () => { + // When wsUrl is missing, transport is created but won't connect + const transport = createTransport({ + mode: 'websocket' + // wsUrl missing - will fail on connect attempt + }); + + // Transport is created but in an invalid state for WebSocket + expect(transport.getMode()).toBe('websocket'); + expect(transport.isWebSocketConnected()).toBe(false); + }); + }); + + describe('createTransport factory', () => { + it('should create HTTP transport with minimal options', () => { + const transport = createTransport({ + mode: 'http', + baseUrl: 'http://localhost:3000' + }); + + expect(transport).toBeInstanceOf(Transport); + expect(transport.getMode()).toBe('http'); + }); + + it('should create WebSocket transport with URL', () => { + const transport = createTransport({ + mode: 'websocket', + wsUrl: 'ws://localhost:3000/ws' + }); + + expect(transport).toBeInstanceOf(Transport); + expect(transport.getMode()).toBe('websocket'); + }); + + it('should pass logger to transport', () => { + const mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn() + }; + + const transport = createTransport({ + mode: 'http', + baseUrl: 'http://localhost:3000', + logger: mockLogger as any + }); + + expect(transport).toBeDefined(); + }); + }); + + describe('mode switching', () => { + it('should maintain mode throughout lifecycle', async () => { + const httpTransport = createTransport({ + mode: 'http', + baseUrl: 'http://localhost:3000' + }); + + expect(httpTransport.getMode()).toBe('http'); + + mockFetch.mockResolvedValue( + new Response(JSON.stringify({}), { status: 200 }) + ); + + await httpTransport.request('GET', '/test'); + + // Mode should still be http + expect(httpTransport.getMode()).toBe('http'); + }); + }); +}); + +describe('Transport with SandboxClient', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should work with shared transport across clients', async () => { + // Create a shared transport + const transport = createTransport({ + mode: 'http', + baseUrl: 'http://localhost:3000' + }); + + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + + // Multiple requests through same transport + await transport.request('POST', '/api/mkdir', { path: '/test' }); + await transport.request('POST', '/api/write', { path: '/test/file.txt' }); + await transport.request('GET', '/api/read'); + + expect(mockFetch).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/sandbox/tests/ws-transport.test.ts b/packages/sandbox/tests/ws-transport.test.ts new file mode 100644 index 00000000..af9966c1 --- /dev/null +++ b/packages/sandbox/tests/ws-transport.test.ts @@ -0,0 +1,315 @@ +import type { + WSError, + WSRequest, + WSResponse, + WSStreamChunk +} from '@repo/shared'; +import { + generateRequestId, + isWSError, + isWSRequest, + isWSResponse, + isWSStreamChunk +} from '@repo/shared'; +import { describe, expect, it } from 'vitest'; + +/** + * Tests for WebSocket protocol types and utilities. + * + * Note: Full WSTransport integration tests require a real WebSocket environment + * and are covered in E2E tests. These unit tests focus on the protocol layer: + * message types, type guards, and request ID generation. + */ +describe('WebSocket Protocol Types', () => { + describe('generateRequestId', () => { + it('should generate unique request IDs', () => { + const id1 = generateRequestId(); + const id2 = generateRequestId(); + const id3 = generateRequestId(); + + expect(id1).toMatch(/^ws_\d+_[a-z0-9]+$/); + expect(id2).toMatch(/^ws_\d+_[a-z0-9]+$/); + expect(id3).toMatch(/^ws_\d+_[a-z0-9]+$/); + + // All should be unique + expect(new Set([id1, id2, id3]).size).toBe(3); + }); + + it('should include timestamp in ID', () => { + const before = Date.now(); + const id = generateRequestId(); + const after = Date.now(); + + // Extract timestamp from ID (format: ws__) + const parts = id.split('_'); + const timestamp = parseInt(parts[1], 10); + + expect(timestamp).toBeGreaterThanOrEqual(before); + expect(timestamp).toBeLessThanOrEqual(after); + }); + }); + + describe('isWSRequest', () => { + it('should return true for valid WSRequest', () => { + const request: WSRequest = { + type: 'request', + id: 'req-123', + method: 'POST', + path: '/api/execute', + body: { command: 'echo hello' } + }; + + expect(isWSRequest(request)).toBe(true); + }); + + it('should return true for minimal WSRequest', () => { + const request = { + type: 'request', + id: 'req-456', + method: 'GET', + path: '/api/health' + }; + + expect(isWSRequest(request)).toBe(true); + }); + + it('should return false for non-request types', () => { + expect(isWSRequest(null)).toBe(false); + expect(isWSRequest(undefined)).toBe(false); + expect(isWSRequest('string')).toBe(false); + expect(isWSRequest({ type: 'response' })).toBe(false); + expect(isWSRequest({ type: 'error' })).toBe(false); + }); + }); + + describe('isWSResponse', () => { + it('should return true for valid WSResponse', () => { + const response: WSResponse = { + type: 'response', + id: 'req-123', + status: 200, + body: { data: 'test' }, + done: true + }; + + expect(isWSResponse(response)).toBe(true); + }); + + it('should return true for minimal WSResponse', () => { + const response = { + type: 'response', + id: 'req-456', + status: 404, + done: false + }; + + expect(isWSResponse(response)).toBe(true); + }); + + it('should return false for non-response types', () => { + expect(isWSResponse(null)).toBe(false); + expect(isWSResponse(undefined)).toBe(false); + expect(isWSResponse('string')).toBe(false); + expect(isWSResponse({ type: 'error' })).toBe(false); + expect(isWSResponse({ type: 'stream' })).toBe(false); + expect(isWSResponse({ type: 'request' })).toBe(false); + }); + }); + + describe('isWSError', () => { + it('should return true for valid WSError', () => { + const error: WSError = { + type: 'error', + id: 'req-123', + code: 'NOT_FOUND', + message: 'Resource not found', + status: 404 + }; + + expect(isWSError(error)).toBe(true); + }); + + it('should return true for WSError without id', () => { + const error = { + type: 'error', + code: 'PARSE_ERROR', + message: 'Invalid JSON', + status: 400 + }; + + expect(isWSError(error)).toBe(true); + }); + + it('should return false for non-error types', () => { + expect(isWSError(null)).toBe(false); + expect(isWSError(undefined)).toBe(false); + expect(isWSError({ type: 'response' })).toBe(false); + expect(isWSError({ type: 'stream' })).toBe(false); + }); + }); + + describe('isWSStreamChunk', () => { + it('should return true for valid WSStreamChunk', () => { + const chunk: WSStreamChunk = { + type: 'stream', + id: 'req-123', + data: 'chunk data' + }; + + expect(isWSStreamChunk(chunk)).toBe(true); + }); + + it('should return true for WSStreamChunk with event', () => { + const chunk = { + type: 'stream', + id: 'req-456', + event: 'output', + data: 'line of output' + }; + + expect(isWSStreamChunk(chunk)).toBe(true); + }); + + it('should return false for non-stream types', () => { + expect(isWSStreamChunk(null)).toBe(false); + expect(isWSStreamChunk({ type: 'response' })).toBe(false); + expect(isWSStreamChunk({ type: 'error' })).toBe(false); + }); + }); +}); + +describe('WebSocket Message Serialization', () => { + it('should serialize WSRequest correctly', () => { + const request: WSRequest = { + type: 'request', + id: generateRequestId(), + method: 'POST', + path: '/api/execute', + body: { command: 'echo hello', sessionId: 'sess-1' } + }; + + const serialized = JSON.stringify(request); + const parsed = JSON.parse(serialized); + + expect(parsed.type).toBe('request'); + expect(parsed.method).toBe('POST'); + expect(parsed.path).toBe('/api/execute'); + expect(parsed.body.command).toBe('echo hello'); + expect(isWSRequest(parsed)).toBe(true); + }); + + it('should serialize WSResponse correctly', () => { + const response: WSResponse = { + type: 'response', + id: 'req-123', + status: 200, + body: { + success: true, + stdout: 'hello\n', + stderr: '', + exitCode: 0 + }, + done: true + }; + + const serialized = JSON.stringify(response); + const parsed = JSON.parse(serialized); + + expect(parsed.type).toBe('response'); + expect(parsed.status).toBe(200); + expect(parsed.body.stdout).toBe('hello\n'); + expect(parsed.done).toBe(true); + expect(isWSResponse(parsed)).toBe(true); + }); + + it('should serialize WSError correctly', () => { + const error: WSError = { + type: 'error', + id: 'req-123', + code: 'FILE_NOT_FOUND', + message: 'File not found: /test.txt', + status: 404, + context: { path: '/test.txt' } + }; + + const serialized = JSON.stringify(error); + const parsed = JSON.parse(serialized); + + expect(parsed.type).toBe('error'); + expect(parsed.code).toBe('FILE_NOT_FOUND'); + expect(parsed.status).toBe(404); + expect(isWSError(parsed)).toBe(true); + }); + + it('should serialize WSStreamChunk correctly', () => { + const chunk: WSStreamChunk = { + type: 'stream', + id: 'req-123', + event: 'stdout', + data: 'output line\n' + }; + + const serialized = JSON.stringify(chunk); + const parsed = JSON.parse(serialized); + + expect(parsed.type).toBe('stream'); + expect(parsed.event).toBe('stdout'); + expect(parsed.data).toBe('output line\n'); + expect(isWSStreamChunk(parsed)).toBe(true); + }); + + it('should handle special characters in body', () => { + const response: WSResponse = { + type: 'response', + id: 'req-123', + status: 200, + body: { + content: 'Line 1\nLine 2\tTabbed\r\nWindows line' + }, + done: true + }; + + const serialized = JSON.stringify(response); + const parsed = JSON.parse(serialized); + + expect(parsed.body.content).toBe('Line 1\nLine 2\tTabbed\r\nWindows line'); + }); + + it('should handle binary data as base64', () => { + const binaryData = 'SGVsbG8gV29ybGQ='; // "Hello World" in base64 + + const response: WSResponse = { + type: 'response', + id: 'req-123', + status: 200, + body: { + content: binaryData, + encoding: 'base64' + }, + done: true + }; + + const serialized = JSON.stringify(response); + const parsed = JSON.parse(serialized); + + expect(parsed.body.content).toBe(binaryData); + expect(parsed.body.encoding).toBe('base64'); + }); + + it('should handle large payloads', () => { + const largeContent = 'x'.repeat(100000); + + const response: WSResponse = { + type: 'response', + id: 'req-123', + status: 200, + body: { content: largeContent }, + done: true + }; + + const serialized = JSON.stringify(response); + const parsed = JSON.parse(serialized); + + expect(parsed.body.content.length).toBe(100000); + }); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2e0362e3..96950c44 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -116,3 +116,20 @@ export { isProcessStatus, isTerminalStatus } from './types.js'; +// Export WebSocket protocol types +export type { + WSClientMessage, + WSError, + WSMethod, + WSRequest, + WSResponse, + WSServerMessage, + WSStreamChunk +} from './ws-types.js'; +export { + generateRequestId, + isWSError, + isWSRequest, + isWSResponse, + isWSStreamChunk +} from './ws-types.js'; diff --git a/packages/shared/src/logger/types.ts b/packages/shared/src/logger/types.ts index 1b6edb59..6de0e4c9 100644 --- a/packages/shared/src/logger/types.ts +++ b/packages/shared/src/logger/types.ts @@ -14,7 +14,11 @@ export enum LogLevel { ERROR = 3 } -export type LogComponent = 'container' | 'sandbox-do' | 'executor'; +export type LogComponent = + | 'container' + | 'sandbox-do' + | 'executor' + | 'ws-handler'; /** * Context metadata included in every log entry diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 71be1109..ec3523c3 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -473,6 +473,22 @@ export interface SandboxOptions { */ waitIntervalMS?: number; }; + + /** + * Use WebSocket transport for control plane communication + * + * When enabled, all sandbox operations (file I/O, command execution, etc.) + * are multiplexed over a single WebSocket connection instead of individual + * HTTP requests. This significantly reduces sub-request count when running + * inside Workers or Durable Objects. + * + * **Use cases:** + * - Agent loops with many file operations inside a Worker/DO + * - Any scenario where sub-request limits are a concern + * + * @default false + */ + useWebSocket?: boolean; } /** diff --git a/packages/shared/src/ws-types.ts b/packages/shared/src/ws-types.ts new file mode 100644 index 00000000..bd29c847 --- /dev/null +++ b/packages/shared/src/ws-types.ts @@ -0,0 +1,166 @@ +/** + * WebSocket transport protocol types + * + * Enables multiplexing HTTP-like requests over a single WebSocket connection. + * This reduces sub-request count when running inside Workers/Durable Objects. + * + * Protocol: + * - Client sends WSRequest messages + * - Server responds with WSResponse messages (matched by id) + * - For streaming endpoints, server sends multiple WSStreamChunk messages + * followed by a final WSResponse + */ + +/** + * HTTP methods supported over WebSocket + */ +export type WSMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; + +/** + * WebSocket request message sent from client to server + */ +export interface WSRequest { + /** Message type discriminator */ + type: 'request'; + + /** Unique request ID for response matching */ + id: string; + + /** HTTP method */ + method: WSMethod; + + /** Request path (e.g., '/api/execute', '/api/read') */ + path: string; + + /** Request body (for POST/PUT requests) */ + body?: unknown; + + /** Request headers (optional, for special cases) */ + headers?: Record; +} + +/** + * WebSocket response message sent from server to client + */ +export interface WSResponse { + /** Message type discriminator */ + type: 'response'; + + /** Request ID this response corresponds to */ + id: string; + + /** HTTP status code */ + status: number; + + /** Response body (JSON parsed) */ + body?: unknown; + + /** Whether this is the final response (for streaming, false until complete) */ + done: boolean; +} + +/** + * WebSocket stream chunk for streaming responses (SSE replacement) + * Sent for streaming endpoints like /api/execute/stream, /api/read/stream + */ +export interface WSStreamChunk { + /** Message type discriminator */ + type: 'stream'; + + /** Request ID this chunk belongs to */ + id: string; + + /** Stream event type (matches SSE event types) */ + event?: string; + + /** Chunk data */ + data: string; +} + +/** + * WebSocket error response + */ +export interface WSError { + /** Message type discriminator */ + type: 'error'; + + /** Request ID this error corresponds to (if available) */ + id?: string; + + /** Error code */ + code: string; + + /** Error message */ + message: string; + + /** HTTP status code equivalent */ + status: number; + + /** Additional error context */ + context?: Record; +} + +/** + * Union type for all WebSocket messages from server to client + */ +export type WSServerMessage = WSResponse | WSStreamChunk | WSError; + +/** + * Union type for all WebSocket messages from client to server + */ +export type WSClientMessage = WSRequest; + +/** + * Type guard for WSRequest + */ +export function isWSRequest(msg: unknown): msg is WSRequest { + return ( + typeof msg === 'object' && + msg !== null && + 'type' in msg && + (msg as WSRequest).type === 'request' + ); +} + +/** + * Type guard for WSResponse + */ +export function isWSResponse(msg: unknown): msg is WSResponse { + return ( + typeof msg === 'object' && + msg !== null && + 'type' in msg && + (msg as WSResponse).type === 'response' + ); +} + +/** + * Type guard for WSStreamChunk + */ +export function isWSStreamChunk(msg: unknown): msg is WSStreamChunk { + return ( + typeof msg === 'object' && + msg !== null && + 'type' in msg && + (msg as WSStreamChunk).type === 'stream' + ); +} + +/** + * Type guard for WSError + */ +export function isWSError(msg: unknown): msg is WSError { + return ( + typeof msg === 'object' && + msg !== null && + 'type' in msg && + (msg as WSError).type === 'error' + ); +} + +/** + * Generate a unique request ID + */ +export function generateRequestId(): string { + return `ws_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; +} diff --git a/tests/e2e/build-test-workflow.test.ts b/tests/e2e/build-test-workflow.test.ts index 7d3b8ed0..b00530b9 100644 --- a/tests/e2e/build-test-workflow.test.ts +++ b/tests/e2e/build-test-workflow.test.ts @@ -12,92 +12,105 @@ import type { ErrorResponse } from './test-worker/types'; * Tests the README "Build and Test Code" example. * Uses the shared sandbox with a unique session. */ -describe('Build and Test Workflow', () => { - describe('local', () => { - let workerUrl: string; - let headers: Record; - beforeAll(async () => { - const sandbox = await getSharedSandbox(); - workerUrl = sandbox.workerUrl; - headers = sandbox.createHeaders(createUniqueSession()); - }, 120000); +// Transport modes to test +const transportModes = [ + { name: 'HTTP', useWebSocket: false }, + { name: 'WebSocket', useWebSocket: true } +]; - test('should execute basic commands and verify file operations', async () => { - // Step 1: Execute simple command - const echoResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "Hello from sandbox"' - }) - }); +describe.each(transportModes)( + 'Build and Test Workflow ($name transport)', + ({ useWebSocket }) => { + describe('local', () => { + let workerUrl: string; + let headers: Record; - expect(echoResponse.status).toBe(200); - const echoData = (await echoResponse.json()) as ExecResult; - expect(echoData.exitCode).toBe(0); - expect(echoData.stdout).toContain('Hello from sandbox'); + beforeAll(async () => { + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + const baseHeaders = sandbox.createHeaders(createUniqueSession()); + headers = useWebSocket + ? { ...baseHeaders, 'X-Use-WebSocket': 'true' } + : baseHeaders; + }, 120000); - // Step 2: Write a file (using absolute path per README pattern) - const writeResponse = await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/test-file.txt', - content: 'Integration test content' - }) - }); + test('should execute basic commands and verify file operations', async () => { + // Step 1: Execute simple command + const echoResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "Hello from sandbox"' + }) + }); - expect(writeResponse.status).toBe(200); - const writeData = (await writeResponse.json()) as WriteFileResult; - expect(writeData.success).toBe(true); + expect(echoResponse.status).toBe(200); + const echoData = (await echoResponse.json()) as ExecResult; + expect(echoData.exitCode).toBe(0); + expect(echoData.stdout).toContain('Hello from sandbox'); - // Step 3: Read the file back to verify persistence - const readResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/test-file.txt' - }) - }); + // Step 2: Write a file (using absolute path per README pattern) + const writeResponse = await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/test-file.txt', + content: 'Integration test content' + }) + }); - expect(readResponse.status).toBe(200); - const readData = (await readResponse.json()) as ReadFileResult; - expect(readData.content).toBe('Integration test content'); + expect(writeResponse.status).toBe(200); + const writeData = (await writeResponse.json()) as WriteFileResult; + expect(writeData.success).toBe(true); - // Step 4: Verify pwd to understand working directory - const pwdResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'pwd' - }) - }); + // Step 3: Read the file back to verify persistence + const readResponse = await fetch(`${workerUrl}/api/file/read`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/test-file.txt' + }) + }); - expect(pwdResponse.status).toBe(200); - const pwdData = (await pwdResponse.json()) as ExecResult; - expect(pwdData.stdout).toMatch(/\/workspace/); - }); + expect(readResponse.status).toBe(200); + const readData = (await readResponse.json()) as ReadFileResult; + expect(readData.content).toBe('Integration test content'); + + // Step 4: Verify pwd to understand working directory + const pwdResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'pwd' + }) + }); - test('should detect shell termination when exit command is used', async () => { - // Execute 'exit 1' which will terminate the shell itself - // This should now be detected and reported as a shell termination error - const response = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'exit 1' - }) + expect(pwdResponse.status).toBe(200); + const pwdData = (await pwdResponse.json()) as ExecResult; + expect(pwdData.stdout).toMatch(/\/workspace/); }); - // Should return 500 error since shell terminated unexpectedly - expect(response.status).toBe(500); - const data = (await response.json()) as ErrorResponse; + test('should detect shell termination when exit command is used', async () => { + // Execute 'exit 1' which will terminate the shell itself + // This should now be detected and reported as a shell termination error + const response = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'exit 1' + }) + }); - // Should have an error object (500 responses may not have success field) - expect(data.error).toBeDefined(); - expect(data.error).toMatch(/shell terminated unexpectedly/i); - expect(data.error).toMatch(/exit code.*1/i); + // Should return 500 error since shell terminated unexpectedly + expect(response.status).toBe(500); + const data = (await response.json()) as ErrorResponse; + + // Should have an error object (500 responses may not have success field) + expect(data.error).toBeDefined(); + expect(data.error).toMatch(/shell terminated unexpectedly/i); + expect(data.error).toMatch(/exit code.*1/i); + }); }); - }); -}); + } +); diff --git a/tests/e2e/code-interpreter-workflow.test.ts b/tests/e2e/code-interpreter-workflow.test.ts index 22a76a48..f7364db9 100644 --- a/tests/e2e/code-interpreter-workflow.test.ts +++ b/tests/e2e/code-interpreter-workflow.test.ts @@ -23,471 +23,493 @@ import { import type { CodeContext, ExecutionResult } from '@repo/shared'; import type { ErrorResponse } from './test-worker/types'; -describe('Code Interpreter Workflow (E2E)', () => { - let workerUrl: string; - let headers: Record; - - beforeAll(async () => { - const sandbox = await getSharedSandbox(); - workerUrl = sandbox.workerUrl; - headers = sandbox.createPythonHeaders(createUniqueSession()); - }, 120000); - - // Helper to create context - async function createContext(language: 'python' | 'javascript') { - const res = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language }) - }); - expect(res.status).toBe(200); - return (await res.json()) as CodeContext; - } +// Transport modes to test +const transportModes = [ + { name: 'HTTP', useWebSocket: false }, + { name: 'WebSocket', useWebSocket: true } +]; + +describe.each(transportModes)( + 'Code Interpreter Workflow (E2E) ($name transport)', + ({ useWebSocket }) => { + let workerUrl: string; + let headers: Record; + + beforeAll(async () => { + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + const baseHeaders = sandbox.createPythonHeaders(createUniqueSession()); + headers = useWebSocket + ? { ...baseHeaders, 'X-Use-WebSocket': 'true' } + : baseHeaders; + }, 120000); + + // Helper to create context + async function createContext(language: 'python' | 'javascript') { + const res = await fetch(`${workerUrl}/api/code/context/create`, { + method: 'POST', + headers, + body: JSON.stringify({ language }) + }); + expect(res.status).toBe(200); + return (await res.json()) as CodeContext; + } - // Helper to execute code - async function executeCode(context: CodeContext, code: string) { - const res = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ code, options: { context } }) - }); - expect(res.status).toBe(200); - return (await res.json()) as ExecutionResult; - } + // Helper to execute code + async function executeCode(context: CodeContext, code: string) { + const res = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ code, options: { context } }) + }); + expect(res.status).toBe(200); + return (await res.json()) as ExecutionResult; + } - // Helper to delete context - async function deleteContext(contextId: string) { - return fetch(`${workerUrl}/api/code/context/${contextId}`, { - method: 'DELETE', - headers - }); - } + // Helper to delete context + async function deleteContext(contextId: string) { + return fetch(`${workerUrl}/api/code/context/${contextId}`, { + method: 'DELETE', + headers + }); + } - // ============================================================================ - // Test 1: Context Lifecycle (create, list, delete) - // ============================================================================ - - test('context lifecycle: create, list, and delete contexts', async () => { - // Create Python context - const pythonCtx = await createContext('python'); - expect(pythonCtx.id).toBeTruthy(); - expect(pythonCtx.language).toBe('python'); - - // Create JavaScript context - const jsCtx = await createContext('javascript'); - expect(jsCtx.id).toBeTruthy(); - expect(jsCtx.language).toBe('javascript'); - expect(jsCtx.id).not.toBe(pythonCtx.id); - - // List all contexts - should contain both - const listResponse = await fetch(`${workerUrl}/api/code/context/list`, { - method: 'GET', - headers - }); - expect(listResponse.status).toBe(200); - const contexts = (await listResponse.json()) as CodeContext[]; - expect(contexts.length).toBeGreaterThanOrEqual(2); - const contextIds = contexts.map((ctx) => ctx.id); - expect(contextIds).toContain(pythonCtx.id); - expect(contextIds).toContain(jsCtx.id); - - // Delete Python context - const deleteResponse = await deleteContext(pythonCtx.id); - expect(deleteResponse.status).toBe(200); - const deleteData = (await deleteResponse.json()) as { success: boolean }; - expect(deleteData.success).toBe(true); - - // Verify context is removed from list - const listAfterDelete = await fetch(`${workerUrl}/api/code/context/list`, { - method: 'GET', - headers - }); - const contextsAfter = (await listAfterDelete.json()) as CodeContext[]; - expect(contextsAfter.map((c) => c.id)).not.toContain(pythonCtx.id); - expect(contextsAfter.map((c) => c.id)).toContain(jsCtx.id); - - // Cleanup - await deleteContext(jsCtx.id); - }, 120000); - - // ============================================================================ - // Test 2: Python Workflow (execute, state persistence, errors) - // ============================================================================ - - test('Python workflow: execute, maintain state, handle errors', async () => { - const ctx = await createContext('python'); - - // Simple execution - const exec1 = await executeCode(ctx, 'print("Hello from Python!")'); - expect(exec1.code).toBe('print("Hello from Python!")'); - expect(exec1.logs.stdout.join('')).toContain('Hello from Python!'); - expect(exec1.error).toBeUndefined(); - - // Set variables for state persistence - const exec2 = await executeCode(ctx, 'x = 42\ny = 10'); - expect(exec2.error).toBeUndefined(); - - // Verify state persists across executions - const exec3 = await executeCode(ctx, 'result = x + y\nprint(result)'); - expect(exec3.logs.stdout.join('')).toContain('52'); - expect(exec3.error).toBeUndefined(); - - // Error handling - division by zero - const exec4 = await executeCode(ctx, 'x = 1 / 0'); - expect(exec4.error).toBeDefined(); - expect(exec4.error!.name).toContain('Error'); - expect(exec4.error!.message || exec4.error!.traceback).toContain( - 'division' - ); - - // Cleanup - await deleteContext(ctx.id); - }, 120000); - - // ============================================================================ - // Test 3: JavaScript Workflow (execute, state, top-level await, IIFE, errors) - // ============================================================================ - - test('JavaScript workflow: execute, state, top-level await, IIFE, errors', async () => { - const ctx = await createContext('javascript'); - - // Simple execution - const exec1 = await executeCode( - ctx, - 'console.log("Hello from JavaScript!");' - ); - expect(exec1.logs.stdout.join('')).toContain('Hello from JavaScript!'); - expect(exec1.error).toBeUndefined(); - - // State persistence with global - await executeCode(ctx, 'global.counter = 0;'); - const exec2 = await executeCode(ctx, 'console.log(++global.counter);'); - expect(exec2.logs.stdout.join('')).toContain('1'); - - // Top-level await - basic - const exec3 = await executeCode( - ctx, - 'const result = await Promise.resolve(42);\nresult' - ); - expect(exec3.error).toBeUndefined(); - expect(exec3.results![0].text).toContain('42'); - - // Top-level await - multiple awaits returning last expression - const exec4 = await executeCode( - ctx, - ` + // ============================================================================ + // Test 1: Context Lifecycle (create, list, delete) + // ============================================================================ + + test('context lifecycle: create, list, and delete contexts', async () => { + // Create Python context + const pythonCtx = await createContext('python'); + expect(pythonCtx.id).toBeTruthy(); + expect(pythonCtx.language).toBe('python'); + + // Create JavaScript context + const jsCtx = await createContext('javascript'); + expect(jsCtx.id).toBeTruthy(); + expect(jsCtx.language).toBe('javascript'); + expect(jsCtx.id).not.toBe(pythonCtx.id); + + // List all contexts - should contain both + const listResponse = await fetch(`${workerUrl}/api/code/context/list`, { + method: 'GET', + headers + }); + expect(listResponse.status).toBe(200); + const contexts = (await listResponse.json()) as CodeContext[]; + expect(contexts.length).toBeGreaterThanOrEqual(2); + const contextIds = contexts.map((ctx) => ctx.id); + expect(contextIds).toContain(pythonCtx.id); + expect(contextIds).toContain(jsCtx.id); + + // Delete Python context + const deleteResponse = await deleteContext(pythonCtx.id); + expect(deleteResponse.status).toBe(200); + const deleteData = (await deleteResponse.json()) as { success: boolean }; + expect(deleteData.success).toBe(true); + + // Verify context is removed from list + const listAfterDelete = await fetch( + `${workerUrl}/api/code/context/list`, + { + method: 'GET', + headers + } + ); + const contextsAfter = (await listAfterDelete.json()) as CodeContext[]; + expect(contextsAfter.map((c) => c.id)).not.toContain(pythonCtx.id); + expect(contextsAfter.map((c) => c.id)).toContain(jsCtx.id); + + // Cleanup + await deleteContext(jsCtx.id); + }, 120000); + + // ============================================================================ + // Test 2: Python Workflow (execute, state persistence, errors) + // ============================================================================ + + test('Python workflow: execute, maintain state, handle errors', async () => { + const ctx = await createContext('python'); + + // Simple execution + const exec1 = await executeCode(ctx, 'print("Hello from Python!")'); + expect(exec1.code).toBe('print("Hello from Python!")'); + expect(exec1.logs.stdout.join('')).toContain('Hello from Python!'); + expect(exec1.error).toBeUndefined(); + + // Set variables for state persistence + const exec2 = await executeCode(ctx, 'x = 42\ny = 10'); + expect(exec2.error).toBeUndefined(); + + // Verify state persists across executions + const exec3 = await executeCode(ctx, 'result = x + y\nprint(result)'); + expect(exec3.logs.stdout.join('')).toContain('52'); + expect(exec3.error).toBeUndefined(); + + // Error handling - division by zero + const exec4 = await executeCode(ctx, 'x = 1 / 0'); + expect(exec4.error).toBeDefined(); + expect(exec4.error!.name).toContain('Error'); + expect(exec4.error!.message || exec4.error!.traceback).toContain( + 'division' + ); + + // Cleanup + await deleteContext(ctx.id); + }, 120000); + + // ============================================================================ + // Test 3: JavaScript Workflow (execute, state, top-level await, IIFE, errors) + // ============================================================================ + + test('JavaScript workflow: execute, state, top-level await, IIFE, errors', async () => { + const ctx = await createContext('javascript'); + + // Simple execution + const exec1 = await executeCode( + ctx, + 'console.log("Hello from JavaScript!");' + ); + expect(exec1.logs.stdout.join('')).toContain('Hello from JavaScript!'); + expect(exec1.error).toBeUndefined(); + + // State persistence with global + await executeCode(ctx, 'global.counter = 0;'); + const exec2 = await executeCode(ctx, 'console.log(++global.counter);'); + expect(exec2.logs.stdout.join('')).toContain('1'); + + // Top-level await - basic + const exec3 = await executeCode( + ctx, + 'const result = await Promise.resolve(42);\nresult' + ); + expect(exec3.error).toBeUndefined(); + expect(exec3.results![0].text).toContain('42'); + + // Top-level await - multiple awaits returning last expression + const exec4 = await executeCode( + ctx, + ` const a = await Promise.resolve(10); const b = await Promise.resolve(20); a + b `.trim() - ); - expect(exec4.error).toBeUndefined(); - expect(exec4.results![0].text).toContain('30'); - - // Top-level await - async error handling - const exec5 = await executeCode( - ctx, - 'await Promise.reject(new Error("async error"))' - ); - expect(exec5.error).toBeDefined(); - expect(exec5.error!.message || exec5.logs.stderr.join('')).toContain( - 'async error' - ); - - // Top-level await - LLM-generated pattern with delay - const exec6 = await executeCode( - ctx, - ` + ); + expect(exec4.error).toBeUndefined(); + expect(exec4.results![0].text).toContain('30'); + + // Top-level await - async error handling + const exec5 = await executeCode( + ctx, + 'await Promise.reject(new Error("async error"))' + ); + expect(exec5.error).toBeDefined(); + expect(exec5.error!.message || exec5.logs.stderr.join('')).toContain( + 'async error' + ); + + // Top-level await - LLM-generated pattern with delay + const exec6 = await executeCode( + ctx, + ` const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); await delay(10); const data = { status: 'success', value: 123 }; data `.trim() - ); - expect(exec6.error).toBeUndefined(); - const resultData = exec6.results![0].json ?? exec6.results![0].text; - expect(JSON.stringify(resultData)).toContain('success'); - - // Variable persistence with await across executions - await executeCode(ctx, 'const persistedValue = await Promise.resolve(99);'); - const exec7 = await executeCode(ctx, 'persistedValue'); - expect(exec7.results![0].text).toContain('99'); - - // Promise auto-resolution without await keyword - const exec8 = await executeCode(ctx, 'Promise.resolve(123)'); - expect(exec8.error).toBeUndefined(); - expect(exec8.results![0].text).toContain('123'); - - // IIFE pattern for backward compatibility - const exec9 = await executeCode( - ctx, - `(async () => { + ); + expect(exec6.error).toBeUndefined(); + const resultData = exec6.results![0].json ?? exec6.results![0].text; + expect(JSON.stringify(resultData)).toContain('success'); + + // Variable persistence with await across executions + await executeCode( + ctx, + 'const persistedValue = await Promise.resolve(99);' + ); + const exec7 = await executeCode(ctx, 'persistedValue'); + expect(exec7.results![0].text).toContain('99'); + + // Promise auto-resolution without await keyword + const exec8 = await executeCode(ctx, 'Promise.resolve(123)'); + expect(exec8.error).toBeUndefined(); + expect(exec8.results![0].text).toContain('123'); + + // IIFE pattern for backward compatibility + const exec9 = await executeCode( + ctx, + `(async () => { const value = await Promise.resolve('hello'); return value + ' world'; })()` - ); - expect(exec9.error).toBeUndefined(); - expect(exec9.results![0].text).toContain('hello world'); - - // Error handling - reference error - const exec10 = await executeCode(ctx, 'console.log(undefinedVariable);'); - expect(exec10.error).toBeDefined(); - expect(exec10.error!.name || exec10.error!.message).toMatch( - /Error|undefined/i - ); - - // Cleanup - await deleteContext(ctx.id); - }, 120000); - - // ============================================================================ - // Test 4: Multi-language Workflow + Streaming - // ============================================================================ - - test('multi-language workflow: Python→JS data sharing + streaming', async () => { - // Create Python context and generate data - const pythonCtx = await createContext('python'); - const pythonExec = await executeCode( - pythonCtx, - ` + ); + expect(exec9.error).toBeUndefined(); + expect(exec9.results![0].text).toContain('hello world'); + + // Error handling - reference error + const exec10 = await executeCode(ctx, 'console.log(undefinedVariable);'); + expect(exec10.error).toBeDefined(); + expect(exec10.error!.name || exec10.error!.message).toMatch( + /Error|undefined/i + ); + + // Cleanup + await deleteContext(ctx.id); + }, 120000); + + // ============================================================================ + // Test 4: Multi-language Workflow + Streaming + // ============================================================================ + + test('multi-language workflow: Python→JS data sharing + streaming', async () => { + // Create Python context and generate data + const pythonCtx = await createContext('python'); + const pythonExec = await executeCode( + pythonCtx, + ` import json data = {'values': [1, 2, 3, 4, 5]} with open('/tmp/shared_data.json', 'w') as f: json.dump(data, f) print("Data saved") `.trim() - ); - expect(pythonExec.error).toBeUndefined(); - expect(pythonExec.logs.stdout.join('')).toContain('Data saved'); - - // Create JavaScript context and consume data - const jsCtx = await createContext('javascript'); - const jsExec = await executeCode( - jsCtx, - ` + ); + expect(pythonExec.error).toBeUndefined(); + expect(pythonExec.logs.stdout.join('')).toContain('Data saved'); + + // Create JavaScript context and consume data + const jsCtx = await createContext('javascript'); + const jsExec = await executeCode( + jsCtx, + ` const fs = require('fs'); const data = JSON.parse(fs.readFileSync('/tmp/shared_data.json', 'utf8')); const sum = data.values.reduce((a, b) => a + b, 0); console.log('Sum:', sum); `.trim() - ); - expect(jsExec.error).toBeUndefined(); - expect(jsExec.logs.stdout.join('')).toContain('Sum: 15'); - - // Test streaming execution - const streamCtx = await createContext('python'); - const streamResponse = await fetch(`${workerUrl}/api/code/execute/stream`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: ` + ); + expect(jsExec.error).toBeUndefined(); + expect(jsExec.logs.stdout.join('')).toContain('Sum: 15'); + + // Test streaming execution + const streamCtx = await createContext('python'); + const streamResponse = await fetch( + `${workerUrl}/api/code/execute/stream`, + { + method: 'POST', + headers, + body: JSON.stringify({ + code: ` import time for i in range(3): print(f"Step {i}") time.sleep(0.1) `.trim(), - options: { context: streamCtx } - }) - }); - - expect(streamResponse.status).toBe(200); - expect(streamResponse.headers.get('content-type')).toBe( - 'text/event-stream' - ); - - // Collect streaming events - const reader = streamResponse.body?.getReader(); - expect(reader).toBeDefined(); - - const decoder = new TextDecoder(); - const events: any[] = []; - let buffer = ''; - - while (true) { - const { done, value } = await reader!.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - } + options: { context: streamCtx } + }) + } + ); - // Parse SSE events - for (const line of buffer.split('\n')) { - if (line.startsWith('data: ')) { - try { - events.push(JSON.parse(line.slice(6))); - } catch { - // Ignore parse errors + expect(streamResponse.status).toBe(200); + expect(streamResponse.headers.get('content-type')).toBe( + 'text/event-stream' + ); + + // Collect streaming events + const reader = streamResponse.body?.getReader(); + expect(reader).toBeDefined(); + + const decoder = new TextDecoder(); + const events: any[] = []; + let buffer = ''; + + while (true) { + const { done, value } = await reader!.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + } + + // Parse SSE events + for (const line of buffer.split('\n')) { + if (line.startsWith('data: ')) { + try { + events.push(JSON.parse(line.slice(6))); + } catch { + // Ignore parse errors + } } } - } - // Verify streaming output - const stdoutEvents = events.filter((e) => e.type === 'stdout'); - expect(stdoutEvents.length).toBeGreaterThan(0); - const allOutput = stdoutEvents.map((e) => e.text).join(''); - expect(allOutput).toContain('Step 0'); - expect(allOutput).toContain('Step 1'); - expect(allOutput).toContain('Step 2'); - - // Cleanup all contexts in parallel - await Promise.all([ - deleteContext(pythonCtx.id), - deleteContext(jsCtx.id), - deleteContext(streamCtx.id) - ]); - }, 120000); - - // ============================================================================ - // Test 5: Context Isolation + Concurrency - // ============================================================================ - - test('context isolation and concurrency: isolation, many contexts, mutex', async () => { - // Test basic isolation between two contexts - const ctx1 = await createContext('python'); - const ctx2 = await createContext('python'); - - await executeCode(ctx1, 'secret = "context1"'); - const isolationCheck = await executeCode(ctx2, 'print(secret)'); - expect(isolationCheck.error).toBeDefined(); - expect(isolationCheck.error!.name || isolationCheck.error!.message).toMatch( - /NameError|not defined/i - ); - - // Cleanup basic isolation contexts sequentially - await deleteContext(ctx1.id); - await deleteContext(ctx2.id); - - // Test isolation across 3 contexts - const manyContexts: CodeContext[] = []; - for (let i = 0; i < 3; i++) { - manyContexts.push(await createContext('javascript')); - } + // Verify streaming output + const stdoutEvents = events.filter((e) => e.type === 'stdout'); + expect(stdoutEvents.length).toBeGreaterThan(0); + const allOutput = stdoutEvents.map((e) => e.text).join(''); + expect(allOutput).toContain('Step 0'); + expect(allOutput).toContain('Step 1'); + expect(allOutput).toContain('Step 2'); + + // Cleanup all contexts in parallel + await Promise.all([ + deleteContext(pythonCtx.id), + deleteContext(jsCtx.id), + deleteContext(streamCtx.id) + ]); + }, 120000); + + // ============================================================================ + // Test 5: Context Isolation + Concurrency + // ============================================================================ + + test('context isolation and concurrency: isolation, many contexts, mutex', async () => { + // Test basic isolation between two contexts + const ctx1 = await createContext('python'); + const ctx2 = await createContext('python'); + + await executeCode(ctx1, 'secret = "context1"'); + const isolationCheck = await executeCode(ctx2, 'print(secret)'); + expect(isolationCheck.error).toBeDefined(); + expect( + isolationCheck.error!.name || isolationCheck.error!.message + ).toMatch(/NameError|not defined/i); + + // Cleanup basic isolation contexts sequentially + await deleteContext(ctx1.id); + await deleteContext(ctx2.id); + + // Test isolation across 3 contexts + const manyContexts: CodeContext[] = []; + for (let i = 0; i < 3; i++) { + manyContexts.push(await createContext('javascript')); + } - // Set unique values in each context - for (let i = 0; i < manyContexts.length; i++) { - const exec = await executeCode( - manyContexts[i], - `const contextValue = ${i}; contextValue;` - ); - expect(exec.error, `Context ${i} set error`).toBeUndefined(); - expect(exec.results![0].text).toContain(String(i)); - } + // Set unique values in each context + for (let i = 0; i < manyContexts.length; i++) { + const exec = await executeCode( + manyContexts[i], + `const contextValue = ${i}; contextValue;` + ); + expect(exec.error, `Context ${i} set error`).toBeUndefined(); + expect(exec.results![0].text).toContain(String(i)); + } - // Verify isolated state - for (let i = 0; i < manyContexts.length; i++) { - const exec = await executeCode(manyContexts[i], 'contextValue;'); - expect(exec.error, `Context ${i} read error`).toBeUndefined(); - expect(exec.results![0].text).toContain(String(i)); - } + // Verify isolated state + for (let i = 0; i < manyContexts.length; i++) { + const exec = await executeCode(manyContexts[i], 'contextValue;'); + expect(exec.error, `Context ${i} read error`).toBeUndefined(); + expect(exec.results![0].text).toContain(String(i)); + } - // Cleanup contexts sequentially - for (const ctx of manyContexts) { - await deleteContext(ctx.id); - } + // Cleanup contexts sequentially + for (const ctx of manyContexts) { + await deleteContext(ctx.id); + } + + // Test concurrent execution on same context (mutex test) + const mutexCtx = await createContext('javascript'); + await executeCode(mutexCtx, 'let counter = 0;'); - // Test concurrent execution on same context (mutex test) - const mutexCtx = await createContext('javascript'); - await executeCode(mutexCtx, 'let counter = 0;'); - - // Launch 5 concurrent increments - const concurrentRequests = 5; - const results = await Promise.allSettled( - Array.from({ length: concurrentRequests }, () => - executeCode(mutexCtx, 'counter++; counter;') - ) - ); - - // Collect counter values - const counterValues: number[] = []; - for (const result of results) { - if (result.status === 'fulfilled') { - const exec = result.value; - expect(exec.error).toBeUndefined(); - const match = exec.results?.[0]?.text?.match(/\d+/); - if (match) counterValues.push(parseInt(match[0], 10)); + // Launch 5 concurrent increments + const concurrentRequests = 5; + const results = await Promise.allSettled( + Array.from({ length: concurrentRequests }, () => + executeCode(mutexCtx, 'counter++; counter;') + ) + ); + + // Collect counter values + const counterValues: number[] = []; + for (const result of results) { + if (result.status === 'fulfilled') { + const exec = result.value; + expect(exec.error).toBeUndefined(); + const match = exec.results?.[0]?.text?.match(/\d+/); + if (match) counterValues.push(parseInt(match[0], 10)); + } } - } - // All 5 should succeed with values 1-5 (serial execution via mutex) - expect(counterValues.length).toBe(concurrentRequests); - counterValues.sort((a, b) => a - b); - expect(counterValues).toEqual(Array.from({ length: 5 }, (_, i) => i + 1)); - - // Verify final counter state - const finalExec = await executeCode(mutexCtx, 'counter;'); - const finalValue = parseInt( - finalExec.results?.[0]?.text?.match(/\d+/)?.[0] || '0', - 10 - ); - expect(finalValue).toBe(5); - - await deleteContext(mutexCtx.id); - }, 30000); - - // ============================================================================ - // Test 6: Error Handling - // ============================================================================ - - test('error handling: invalid language, non-existent context, Python unavailable', async () => { - // Invalid language - const invalidLangResponse = await fetch( - `${workerUrl}/api/code/context/create`, - { + // All 5 should succeed with values 1-5 (serial execution via mutex) + expect(counterValues.length).toBe(concurrentRequests); + counterValues.sort((a, b) => a - b); + expect(counterValues).toEqual(Array.from({ length: 5 }, (_, i) => i + 1)); + + // Verify final counter state + const finalExec = await executeCode(mutexCtx, 'counter;'); + const finalValue = parseInt( + finalExec.results?.[0]?.text?.match(/\d+/)?.[0] || '0', + 10 + ); + expect(finalValue).toBe(5); + + await deleteContext(mutexCtx.id); + }, 30000); + + // ============================================================================ + // Test 6: Error Handling + // ============================================================================ + + test('error handling: invalid language, non-existent context, Python unavailable', async () => { + // Invalid language + const invalidLangResponse = await fetch( + `${workerUrl}/api/code/context/create`, + { + method: 'POST', + headers, + body: JSON.stringify({ language: 'invalid-lang' }) + } + ); + expect(invalidLangResponse.status).toBeGreaterThanOrEqual(400); + const invalidLangError = + (await invalidLangResponse.json()) as ErrorResponse; + expect(invalidLangError.error).toBeTruthy(); + + // Non-existent context execution + const fakeContextExec = await fetch(`${workerUrl}/api/code/execute`, { method: 'POST', headers, - body: JSON.stringify({ language: 'invalid-lang' }) - } - ); - expect(invalidLangResponse.status).toBeGreaterThanOrEqual(400); - const invalidLangError = - (await invalidLangResponse.json()) as ErrorResponse; - expect(invalidLangError.error).toBeTruthy(); - - // Non-existent context execution - const fakeContextExec = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: 'print("test")', - options: { - context: { id: 'fake-context-id-12345', language: 'python' } - } - }) - }); - expect(fakeContextExec.status).toBeGreaterThanOrEqual(400); - const fakeContextError = (await fakeContextExec.json()) as ErrorResponse; - expect(fakeContextError.error).toBeTruthy(); - - // Delete non-existent context - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ command: 'echo "init"' }) - }); - const deleteFakeResponse = await fetch( - `${workerUrl}/api/code/context/fake-id-99999`, - { method: 'DELETE', headers } - ); - expect(deleteFakeResponse.status).toBeGreaterThanOrEqual(400); - const deleteFakeError = (await deleteFakeResponse.json()) as ErrorResponse; - expect(deleteFakeError.error).toBeTruthy(); - - // Python unavailable on base image - const sandbox = await getSharedSandbox(); - const baseImageHeaders = sandbox.createHeaders(createUniqueSession()); - const pythonUnavailableResponse = await fetch( - `${workerUrl}/api/code/context/create`, - { + body: JSON.stringify({ + code: 'print("test")', + options: { + context: { id: 'fake-context-id-12345', language: 'python' } + } + }) + }); + expect(fakeContextExec.status).toBeGreaterThanOrEqual(400); + const fakeContextError = (await fakeContextExec.json()) as ErrorResponse; + expect(fakeContextError.error).toBeTruthy(); + + // Delete non-existent context + await fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers: baseImageHeaders, - body: JSON.stringify({ language: 'python' }) - } - ); - expect(pythonUnavailableResponse.status).toBe(500); - const pythonUnavailableError = - (await pythonUnavailableResponse.json()) as ErrorResponse; - expect(pythonUnavailableError.error).toContain( - 'Python interpreter not available' - ); - expect(pythonUnavailableError.error).toMatch(/-python/); - }, 30000); -}); + headers, + body: JSON.stringify({ command: 'echo "init"' }) + }); + const deleteFakeResponse = await fetch( + `${workerUrl}/api/code/context/fake-id-99999`, + { method: 'DELETE', headers } + ); + expect(deleteFakeResponse.status).toBeGreaterThanOrEqual(400); + const deleteFakeError = + (await deleteFakeResponse.json()) as ErrorResponse; + expect(deleteFakeError.error).toBeTruthy(); + + // Python unavailable on base image + const sandbox = await getSharedSandbox(); + const baseImageHeaders = sandbox.createHeaders(createUniqueSession()); + const pythonUnavailableResponse = await fetch( + `${workerUrl}/api/code/context/create`, + { + method: 'POST', + headers: baseImageHeaders, + body: JSON.stringify({ language: 'python' }) + } + ); + expect(pythonUnavailableResponse.status).toBe(500); + const pythonUnavailableError = + (await pythonUnavailableResponse.json()) as ErrorResponse; + expect(pythonUnavailableError.error).toContain( + 'Python interpreter not available' + ); + expect(pythonUnavailableError.error).toMatch(/-python/); + }, 30000); + } +); diff --git a/tests/e2e/comprehensive-workflow.test.ts b/tests/e2e/comprehensive-workflow.test.ts index 34728c7a..c0ea0425 100644 --- a/tests/e2e/comprehensive-workflow.test.ts +++ b/tests/e2e/comprehensive-workflow.test.ts @@ -16,6 +16,8 @@ * 3. Catch integration issues between features * * Individual edge cases and error handling remain in dedicated test files. + * + * Tests run TWICE: once with HTTP transport (default) and once with WebSocket transport. */ import { describe, test, expect, beforeAll } from 'vitest'; @@ -39,225 +41,238 @@ import type { ExecEvent } from '@repo/shared'; -describe('Comprehensive Workflow', () => { - let workerUrl: string; - let headers: Record; - - beforeAll(async () => { - // Use the shared sandbox with a unique session for this test file - const sandbox = await getSharedSandbox(); - workerUrl = sandbox.workerUrl; - headers = sandbox.createHeaders(createUniqueSession()); - }, 120000); - - /** - * Test 1: Complete Developer Workflow - * - * Simulates a realistic workflow: - * 1. Clone a repository - * 2. Set up environment variables - * 3. Explore and modify files - * 4. Run commands with environment - * 5. Start a background process - * 6. Monitor via streaming - * 7. Clean up - */ - test('should execute complete developer workflow: clone → env → files → process', async () => { - // ======================================== - // Phase 1: Clone a repository - // ======================================== - const testDir = uniqueTestPath('hello-world'); - const cloneResponse = await fetch(`${workerUrl}/api/git/clone`, { - method: 'POST', - headers, - body: JSON.stringify({ - repoUrl: 'https://github.com/octocat/Hello-World', - branch: 'master', - targetDir: testDir - }) - }); - - expect(cloneResponse.status).toBe(200); - const cloneData = (await cloneResponse.json()) as GitCheckoutResult; - expect(cloneData.success).toBe(true); - - // Verify repo structure using listFiles - const listResponse = await fetch(`${workerUrl}/api/list-files`, { - method: 'POST', - headers, - body: JSON.stringify({ path: testDir }) - }); - - expect(listResponse.status).toBe(200); - const listData = (await listResponse.json()) as ListFilesResult; - expect(listData.files.some((f: FileInfo) => f.name === 'README')).toBe( - true - ); - - // ======================================== - // Phase 2: Set up environment variables - // ======================================== - const setEnvResponse = await fetch(`${workerUrl}/api/env/set`, { - method: 'POST', - headers, - body: JSON.stringify({ - envVars: { - PROJECT_NAME: 'hello-world', - BUILD_ENV: 'test', - API_KEY: 'test-key-123' - } - }) - }); - - expect(setEnvResponse.status).toBe(200); - const envData = (await setEnvResponse.json()) as EnvSetResult; - expect(envData.success).toBe(true); - - // Verify env vars are available - const envCheckResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "$PROJECT_NAME|$BUILD_ENV|$API_KEY"' - }) - }); - - expect(envCheckResponse.status).toBe(200); - const envCheckData = (await envCheckResponse.json()) as ExecResult; - expect(envCheckData.stdout.trim()).toBe('hello-world|test|test-key-123'); - - // ======================================== - // Phase 3: File operations on cloned repo - // ======================================== - - // Read the README from cloned repo - const readReadmeResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers, - body: JSON.stringify({ path: `${testDir}/README` }) - }); - - expect(readReadmeResponse.status).toBe(200); - const readmeData = (await readReadmeResponse.json()) as ReadFileResult; - expect(readmeData.content).toContain('Hello'); - - // Create a new directory structure - const mkdirResponse = await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: `${testDir}/src/utils`, - recursive: true - }) - }); - - expect(mkdirResponse.status).toBe(200); - - // Write a config file using env vars in filename generation - const configContent = JSON.stringify( - { - name: 'hello-world', - env: 'test', - version: '1.0.0' - }, - null, - 2 - ); - - const writeConfigResponse = await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: `${testDir}/config.json`, - content: configContent - }) - }); - - expect(writeConfigResponse.status).toBe(200); - - // Write a source file - const sourceCode = ` +// Transport modes to test - runs all tests with both HTTP and WebSocket +const transportModes = [ + { name: 'HTTP', useWebSocket: false }, + { name: 'WebSocket', useWebSocket: true } +]; + +describe.each(transportModes)( + 'Comprehensive Workflow ($name transport)', + ({ useWebSocket }) => { + let workerUrl: string; + let headers: Record; + + beforeAll(async () => { + // Use the shared sandbox with a unique session for this test file + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + // Create unique session per transport mode to avoid state conflicts + const baseHeaders = sandbox.createHeaders(createUniqueSession()); + // Add WebSocket transport header if enabled + headers = useWebSocket + ? { ...baseHeaders, 'X-Use-WebSocket': 'true' } + : baseHeaders; + }, 120000); + + /** + * Test 1: Complete Developer Workflow + * + * Simulates a realistic workflow: + * 1. Clone a repository + * 2. Set up environment variables + * 3. Explore and modify files + * 4. Run commands with environment + * 5. Start a background process + * 6. Monitor via streaming + * 7. Clean up + */ + test('should execute complete developer workflow: clone → env → files → process', async () => { + // ======================================== + // Phase 1: Clone a repository + // ======================================== + const testDir = uniqueTestPath('hello-world'); + const cloneResponse = await fetch(`${workerUrl}/api/git/clone`, { + method: 'POST', + headers, + body: JSON.stringify({ + repoUrl: 'https://github.com/octocat/Hello-World', + branch: 'master', + targetDir: testDir + }) + }); + + expect(cloneResponse.status).toBe(200); + const cloneData = (await cloneResponse.json()) as GitCheckoutResult; + expect(cloneData.success).toBe(true); + + // Verify repo structure using listFiles + const listResponse = await fetch(`${workerUrl}/api/list-files`, { + method: 'POST', + headers, + body: JSON.stringify({ path: testDir }) + }); + + expect(listResponse.status).toBe(200); + const listData = (await listResponse.json()) as ListFilesResult; + expect(listData.files.some((f: FileInfo) => f.name === 'README')).toBe( + true + ); + + // ======================================== + // Phase 2: Set up environment variables + // ======================================== + const setEnvResponse = await fetch(`${workerUrl}/api/env/set`, { + method: 'POST', + headers, + body: JSON.stringify({ + envVars: { + PROJECT_NAME: 'hello-world', + BUILD_ENV: 'test', + API_KEY: 'test-key-123' + } + }) + }); + + expect(setEnvResponse.status).toBe(200); + const envData = (await setEnvResponse.json()) as EnvSetResult; + expect(envData.success).toBe(true); + + // Verify env vars are available + const envCheckResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "$PROJECT_NAME|$BUILD_ENV|$API_KEY"' + }) + }); + + expect(envCheckResponse.status).toBe(200); + const envCheckData = (await envCheckResponse.json()) as ExecResult; + expect(envCheckData.stdout.trim()).toBe('hello-world|test|test-key-123'); + + // ======================================== + // Phase 3: File operations on cloned repo + // ======================================== + + // Read the README from cloned repo + const readReadmeResponse = await fetch(`${workerUrl}/api/file/read`, { + method: 'POST', + headers, + body: JSON.stringify({ path: `${testDir}/README` }) + }); + + expect(readReadmeResponse.status).toBe(200); + const readmeData = (await readReadmeResponse.json()) as ReadFileResult; + expect(readmeData.content).toContain('Hello'); + + // Create a new directory structure + const mkdirResponse = await fetch(`${workerUrl}/api/file/mkdir`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: `${testDir}/src/utils`, + recursive: true + }) + }); + + expect(mkdirResponse.status).toBe(200); + + // Write a config file using env vars in filename generation + const configContent = JSON.stringify( + { + name: 'hello-world', + env: 'test', + version: '1.0.0' + }, + null, + 2 + ); + + const writeConfigResponse = await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: `${testDir}/config.json`, + content: configContent + }) + }); + + expect(writeConfigResponse.status).toBe(200); + + // Write a source file + const sourceCode = ` // Generated file using env: $BUILD_ENV export function greet(name) { return \`Hello, \${name}!\`; } `.trim(); - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: `${testDir}/src/utils/greet.js`, - content: sourceCode - }) - }); - - // Rename the file - const renameResponse = await fetch(`${workerUrl}/api/file/rename`, { - method: 'POST', - headers, - body: JSON.stringify({ - oldPath: `${testDir}/src/utils/greet.js`, - newPath: `${testDir}/src/utils/greeter.js` - }) - }); - - expect(renameResponse.status).toBe(200); - - // Verify rename worked by reading new path - const readRenamedResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: `${testDir}/src/utils/greeter.js` - }) - }); - - expect(readRenamedResponse.status).toBe(200); - const renamedData = (await readRenamedResponse.json()) as ReadFileResult; - expect(renamedData.content).toContain('greet'); - - // ======================================== - // Phase 4: Run commands with environment - // ======================================== - - // Use env vars in a command - const buildResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: `echo "Building $PROJECT_NAME in $BUILD_ENV mode" && ls -la ${testDir}/src`, - cwd: testDir - }) - }); - - expect(buildResponse.status).toBe(200); - const buildData = (await buildResponse.json()) as ExecResult; - expect(buildData.stdout).toContain('Building hello-world in test mode'); - expect(buildData.stdout).toContain('utils'); - - // Run git status to verify we're in a git repo - const gitStatusResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'git status --porcelain', - cwd: testDir - }) - }); - - expect(gitStatusResponse.status).toBe(200); - const gitStatusData = (await gitStatusResponse.json()) as ExecResult; - // Should show our new files as untracked - expect(gitStatusData.stdout).toContain('config.json'); - expect(gitStatusData.stdout).toContain('src/'); - - // ======================================== - // Phase 5: Background process with streaming - // ======================================== - - // Write a simple server script that uses env vars - const serverScript = ` + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: `${testDir}/src/utils/greet.js`, + content: sourceCode + }) + }); + + // Rename the file + const renameResponse = await fetch(`${workerUrl}/api/file/rename`, { + method: 'POST', + headers, + body: JSON.stringify({ + oldPath: `${testDir}/src/utils/greet.js`, + newPath: `${testDir}/src/utils/greeter.js` + }) + }); + + expect(renameResponse.status).toBe(200); + + // Verify rename worked by reading new path + const readRenamedResponse = await fetch(`${workerUrl}/api/file/read`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: `${testDir}/src/utils/greeter.js` + }) + }); + + expect(readRenamedResponse.status).toBe(200); + const renamedData = (await readRenamedResponse.json()) as ReadFileResult; + expect(renamedData.content).toContain('greet'); + + // ======================================== + // Phase 4: Run commands with environment + // ======================================== + + // Use env vars in a command + const buildResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `echo "Building $PROJECT_NAME in $BUILD_ENV mode" && ls -la ${testDir}/src`, + cwd: testDir + }) + }); + + expect(buildResponse.status).toBe(200); + const buildData = (await buildResponse.json()) as ExecResult; + expect(buildData.stdout).toContain('Building hello-world in test mode'); + expect(buildData.stdout).toContain('utils'); + + // Run git status to verify we're in a git repo + const gitStatusResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'git status --porcelain', + cwd: testDir + }) + }); + + expect(gitStatusResponse.status).toBe(200); + const gitStatusData = (await gitStatusResponse.json()) as ExecResult; + // Should show our new files as untracked + expect(gitStatusData.stdout).toContain('config.json'); + expect(gitStatusData.stdout).toContain('src/'); + + // ======================================== + // Phase 5: Background process with streaming + // ======================================== + + // Write a simple server script that uses env vars + const serverScript = ` const port = 8888; console.log(\`[Server] Starting on port \${port}\`); console.log(\`[Server] PROJECT_NAME = \${process.env.PROJECT_NAME}\`); @@ -275,352 +290,355 @@ const interval = setInterval(() => { }, 500); `.trim(); - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: `${testDir}/server.js`, - content: serverScript - }) - }); - - // Start the background process - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: `bun run ${testDir}/server.js` - }) - }); - - expect(startResponse.status).toBe(200); - const processData = (await startResponse.json()) as Process; - expect(processData.id).toBeTruthy(); - const processId = processData.id; - - // Wait for process to complete - await new Promise((resolve) => setTimeout(resolve, 3000)); - - // Get process logs - const logsResponse = await fetch( - `${workerUrl}/api/process/${processId}/logs`, - { - method: 'GET', - headers - } - ); - - expect(logsResponse.status).toBe(200); - const logsData = (await logsResponse.json()) as ProcessLogsResult; - - // Verify env vars were available to the process - expect(logsData.stdout).toContain('PROJECT_NAME = hello-world'); - expect(logsData.stdout).toContain('BUILD_ENV = test'); - expect(logsData.stdout).toContain('Heartbeat 3'); - expect(logsData.stdout).toContain('Done'); - - // ======================================== - // Phase 6: Cleanup - move and delete files - // ======================================== - - // Move config to a backup location - await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: `${testDir}/backup`, - recursive: true - }) - }); - - const moveResponse = await fetch(`${workerUrl}/api/file/move`, { - method: 'POST', - headers, - body: JSON.stringify({ - sourcePath: `${testDir}/config.json`, - destinationPath: `${testDir}/backup/config.json` - }) - }); - - expect(moveResponse.status).toBe(200); - - // Delete the server script - const deleteResponse = await fetch(`${workerUrl}/api/file/delete`, { - method: 'DELETE', - headers, - body: JSON.stringify({ - path: `${testDir}/server.js` - }) - }); - - expect(deleteResponse.status).toBe(200); - - // Verify final state - const finalListResponse = await fetch(`${workerUrl}/api/list-files`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: testDir, - options: { recursive: true } - }) - }); - - expect(finalListResponse.status).toBe(200); - const finalListData = (await finalListResponse.json()) as ListFilesResult; - - // Should have backup/config.json but not server.js at root - const fileNames = finalListData.files.map((f: FileInfo) => f.relativePath); - expect(fileNames).toContain('backup/config.json'); - expect(fileNames).not.toContain('server.js'); - expect(fileNames).toContain('src/utils/greeter.js'); - }, 180000); - - /** - * Test 2: Streaming execution with real-time output - * - * Tests execStream to verify SSE streaming works correctly - * within the same sandbox context. - */ - test('should stream command output in real-time', async () => { - // Helper to collect SSE events - async function collectSSEEvents( - response: Response, - maxEvents: number = 50 - ): Promise { - if (!response.body) throw new Error('No body'); - - const events: ExecEvent[] = []; - const abortController = new AbortController(); - - try { - for await (const event of parseSSEStream( - response.body, - abortController.signal - )) { - events.push(event); - if (event.type === 'complete' || event.type === 'error') { - abortController.abort(); - break; + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: `${testDir}/server.js`, + content: serverScript + }) + }); + + // Start the background process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `bun run ${testDir}/server.js` + }) + }); + + expect(startResponse.status).toBe(200); + const processData = (await startResponse.json()) as Process; + expect(processData.id).toBeTruthy(); + const processId = processData.id; + + // Wait for process to complete + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // Get process logs + const logsResponse = await fetch( + `${workerUrl}/api/process/${processId}/logs`, + { + method: 'GET', + headers + } + ); + + expect(logsResponse.status).toBe(200); + const logsData = (await logsResponse.json()) as ProcessLogsResult; + + // Verify env vars were available to the process + expect(logsData.stdout).toContain('PROJECT_NAME = hello-world'); + expect(logsData.stdout).toContain('BUILD_ENV = test'); + expect(logsData.stdout).toContain('Heartbeat 3'); + expect(logsData.stdout).toContain('Done'); + + // ======================================== + // Phase 6: Cleanup - move and delete files + // ======================================== + + // Move config to a backup location + await fetch(`${workerUrl}/api/file/mkdir`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: `${testDir}/backup`, + recursive: true + }) + }); + + const moveResponse = await fetch(`${workerUrl}/api/file/move`, { + method: 'POST', + headers, + body: JSON.stringify({ + sourcePath: `${testDir}/config.json`, + destinationPath: `${testDir}/backup/config.json` + }) + }); + + expect(moveResponse.status).toBe(200); + + // Delete the server script + const deleteResponse = await fetch(`${workerUrl}/api/file/delete`, { + method: 'DELETE', + headers, + body: JSON.stringify({ + path: `${testDir}/server.js` + }) + }); + + expect(deleteResponse.status).toBe(200); + + // Verify final state + const finalListResponse = await fetch(`${workerUrl}/api/list-files`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: testDir, + options: { recursive: true } + }) + }); + + expect(finalListResponse.status).toBe(200); + const finalListData = (await finalListResponse.json()) as ListFilesResult; + + // Should have backup/config.json but not server.js at root + const fileNames = finalListData.files.map( + (f: FileInfo) => f.relativePath + ); + expect(fileNames).toContain('backup/config.json'); + expect(fileNames).not.toContain('server.js'); + expect(fileNames).toContain('src/utils/greeter.js'); + }, 180000); + + /** + * Test 2: Streaming execution with real-time output + * + * Tests execStream to verify SSE streaming works correctly + * within the same sandbox context. + */ + test('should stream command output in real-time', async () => { + // Helper to collect SSE events + async function collectSSEEvents( + response: Response, + maxEvents: number = 50 + ): Promise { + if (!response.body) throw new Error('No body'); + + const events: ExecEvent[] = []; + const abortController = new AbortController(); + + try { + for await (const event of parseSSEStream( + response.body, + abortController.signal + )) { + events.push(event); + if (event.type === 'complete' || event.type === 'error') { + abortController.abort(); + break; + } + if (events.length >= maxEvents) { + abortController.abort(); + break; + } } - if (events.length >= maxEvents) { - abortController.abort(); - break; + } catch (error) { + if ( + error instanceof Error && + error.message !== 'Operation was aborted' + ) { + throw error; } } - } catch (error) { - if ( - error instanceof Error && - error.message !== 'Operation was aborted' - ) { - throw error; - } + + return events; } - return events; - } - - // Stream a command that outputs multiple lines with timestamps - const streamResponse = await fetch(`${workerUrl}/api/execStream`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: - 'for i in 1 2 3; do echo "[$PROJECT_NAME] Step $i at $(date +%s)"; sleep 0.3; done' - }) - }); - - expect(streamResponse.status).toBe(200); - expect(streamResponse.headers.get('content-type')).toBe( - 'text/event-stream' - ); - - const events = await collectSSEEvents(streamResponse); - - // Verify event types - const eventTypes = new Set(events.map((e) => e.type)); - expect(eventTypes.has('start')).toBe(true); - expect(eventTypes.has('stdout')).toBe(true); - expect(eventTypes.has('complete')).toBe(true); - - // Verify output includes env var from earlier phase - const output = events - .filter((e) => e.type === 'stdout') - .map((e) => e.data) - .join(''); - expect(output).toContain('[hello-world]'); - expect(output).toContain('Step 1'); - expect(output).toContain('Step 3'); - - // Verify successful completion - const completeEvent = events.find((e) => e.type === 'complete'); - expect(completeEvent?.exitCode).toBe(0); - }, 60000); - - /** - * Test 3: Per-command env and cwd without mutating session - * - * Verifies that per-command options work correctly and - * don't affect the session state. - */ - test('should support per-command env and cwd without affecting session', async () => { - // Execute with per-command env - const cmdEnvResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "TEMP=$TEMP_VAR, PROJECT=$PROJECT_NAME"', - env: { TEMP_VAR: 'temporary-value' } - }) - }); - - expect(cmdEnvResponse.status).toBe(200); - const cmdEnvData = (await cmdEnvResponse.json()) as ExecResult; - // Should have both per-command env AND session env - expect(cmdEnvData.stdout.trim()).toBe( - 'TEMP=temporary-value, PROJECT=hello-world' - ); - - // Verify TEMP_VAR didn't persist to session - const verifyEnvResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "TEMP=$TEMP_VAR"' - }) - }); - - const verifyEnvData = (await verifyEnvResponse.json()) as ExecResult; - expect(verifyEnvData.stdout.trim()).toBe('TEMP='); - - // Execute with per-command cwd - const cmdCwdResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'pwd', - cwd: '/tmp' - }) - }); - - expect(cmdCwdResponse.status).toBe(200); - const cmdCwdData = (await cmdCwdResponse.json()) as ExecResult; - expect(cmdCwdData.stdout.trim()).toBe('/tmp'); - - // Verify session cwd wasn't changed - const verifyCwdResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'pwd' - }) - }); - - const verifyCwdData = (await verifyCwdResponse.json()) as ExecResult; - expect(verifyCwdData.stdout.trim()).toBe('/workspace'); - }, 60000); - - /** - * Test 4: Binary file handling - * - * Tests reading and writing binary files. - */ - test('should handle binary file operations', async () => { - // Create a binary file using base64 - const pngBase64 = - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jYlkKQAAAABJRU5ErkJggg=='; - - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: `echo '${pngBase64}' | base64 -d > /workspace/test-image.png` - }) - }); - - // Read the binary file - const readBinaryResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/test-image.png' - }) - }); - - expect(readBinaryResponse.status).toBe(200); - const binaryData = (await readBinaryResponse.json()) as ReadFileResult; - - expect(binaryData.isBinary).toBe(true); - expect(binaryData.encoding).toBe('base64'); - expect(binaryData.mimeType).toMatch(/image\/png/); - expect(binaryData.content).toBeTruthy(); - - // Clean up - await fetch(`${workerUrl}/api/file/delete`, { - method: 'DELETE', - headers, - body: JSON.stringify({ path: '/workspace/test-image.png' }) - }); - }, 60000); - - /** - * Test 5: Process list and management - * - * Tests starting multiple processes and listing them. - */ - test('should manage multiple background processes', async () => { - // Start two background processes - const process1Response = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ command: 'sleep 30' }) - }); - const process1 = (await process1Response.json()) as Process; - - const process2Response = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ command: 'sleep 30' }) - }); - const process2 = (await process2Response.json()) as Process; - - // Wait for processes to be registered - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // List all processes - const listResponse = await fetch(`${workerUrl}/api/process/list`, { - method: 'GET', - headers - }); - - expect(listResponse.status).toBe(200); - const processList = (await listResponse.json()) as Process[]; - - expect(processList.length).toBeGreaterThanOrEqual(2); - const ids = processList.map((p) => p.id); - expect(ids).toContain(process1.id); - expect(ids).toContain(process2.id); - - // Kill all processes - const killAllResponse = await fetch(`${workerUrl}/api/process/kill-all`, { - method: 'POST', - headers, - body: JSON.stringify({}) - }); - - expect(killAllResponse.status).toBe(200); - - // Poll until no running processes remain (up to 5 seconds) - let running: Process[] = []; - for (let i = 0; i < 10; i++) { - await new Promise((resolve) => setTimeout(resolve, 500)); - const listAfterResponse = await fetch(`${workerUrl}/api/process/list`, { + // Stream a command that outputs multiple lines with timestamps + const streamResponse = await fetch(`${workerUrl}/api/execStream`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: + 'for i in 1 2 3; do echo "[$PROJECT_NAME] Step $i at $(date +%s)"; sleep 0.3; done' + }) + }); + + expect(streamResponse.status).toBe(200); + expect(streamResponse.headers.get('content-type')).toBe( + 'text/event-stream' + ); + + const events = await collectSSEEvents(streamResponse); + + // Verify event types + const eventTypes = new Set(events.map((e) => e.type)); + expect(eventTypes.has('start')).toBe(true); + expect(eventTypes.has('stdout')).toBe(true); + expect(eventTypes.has('complete')).toBe(true); + + // Verify output includes env var from earlier phase + const output = events + .filter((e) => e.type === 'stdout') + .map((e) => e.data) + .join(''); + expect(output).toContain('[hello-world]'); + expect(output).toContain('Step 1'); + expect(output).toContain('Step 3'); + + // Verify successful completion + const completeEvent = events.find((e) => e.type === 'complete'); + expect(completeEvent?.exitCode).toBe(0); + }, 60000); + + /** + * Test 3: Per-command env and cwd without mutating session + * + * Verifies that per-command options work correctly and + * don't affect the session state. + */ + test('should support per-command env and cwd without affecting session', async () => { + // Execute with per-command env + const cmdEnvResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "TEMP=$TEMP_VAR, PROJECT=$PROJECT_NAME"', + env: { TEMP_VAR: 'temporary-value' } + }) + }); + + expect(cmdEnvResponse.status).toBe(200); + const cmdEnvData = (await cmdEnvResponse.json()) as ExecResult; + // Should have both per-command env AND session env + expect(cmdEnvData.stdout.trim()).toBe( + 'TEMP=temporary-value, PROJECT=hello-world' + ); + + // Verify TEMP_VAR didn't persist to session + const verifyEnvResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "TEMP=$TEMP_VAR"' + }) + }); + + const verifyEnvData = (await verifyEnvResponse.json()) as ExecResult; + expect(verifyEnvData.stdout.trim()).toBe('TEMP='); + + // Execute with per-command cwd + const cmdCwdResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'pwd', + cwd: '/tmp' + }) + }); + + expect(cmdCwdResponse.status).toBe(200); + const cmdCwdData = (await cmdCwdResponse.json()) as ExecResult; + expect(cmdCwdData.stdout.trim()).toBe('/tmp'); + + // Verify session cwd wasn't changed + const verifyCwdResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'pwd' + }) + }); + + const verifyCwdData = (await verifyCwdResponse.json()) as ExecResult; + expect(verifyCwdData.stdout.trim()).toBe('/workspace'); + }, 60000); + + /** + * Test 4: Binary file handling + * + * Tests reading and writing binary files. + */ + test('should handle binary file operations', async () => { + // Create a binary file using base64 + const pngBase64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jYlkKQAAAABJRU5ErkJggg=='; + + await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `echo '${pngBase64}' | base64 -d > /workspace/test-image.png` + }) + }); + + // Read the binary file + const readBinaryResponse = await fetch(`${workerUrl}/api/file/read`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/test-image.png' + }) + }); + + expect(readBinaryResponse.status).toBe(200); + const binaryData = (await readBinaryResponse.json()) as ReadFileResult; + + expect(binaryData.isBinary).toBe(true); + expect(binaryData.encoding).toBe('base64'); + expect(binaryData.mimeType).toMatch(/image\/png/); + expect(binaryData.content).toBeTruthy(); + + // Clean up + await fetch(`${workerUrl}/api/file/delete`, { + method: 'DELETE', + headers, + body: JSON.stringify({ path: '/workspace/test-image.png' }) + }); + }, 60000); + + /** + * Test 5: Process list and management + * + * Tests starting multiple processes and listing them. + */ + test('should manage multiple background processes', async () => { + // Start two background processes + const process1Response = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ command: 'sleep 30' }) + }); + const process1 = (await process1Response.json()) as Process; + + const process2Response = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ command: 'sleep 30' }) + }); + const process2 = (await process2Response.json()) as Process; + + // Wait for processes to be registered + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // List all processes + const listResponse = await fetch(`${workerUrl}/api/process/list`, { method: 'GET', headers }); - const processesAfter = (await listAfterResponse.json()) as Process[]; - running = processesAfter.filter((p) => p.status === 'running'); - if (running.length === 0) break; - } - expect(running.length).toBe(0); - }, 60000); -}); + + expect(listResponse.status).toBe(200); + const processList = (await listResponse.json()) as Process[]; + + expect(processList.length).toBeGreaterThanOrEqual(2); + const ids = processList.map((p) => p.id); + expect(ids).toContain(process1.id); + expect(ids).toContain(process2.id); + + // Kill all processes + const killAllResponse = await fetch(`${workerUrl}/api/process/kill-all`, { + method: 'POST', + headers, + body: JSON.stringify({}) + }); + + expect(killAllResponse.status).toBe(200); + + // Poll until no running processes remain (up to 5 seconds) + let running: Process[] = []; + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => setTimeout(resolve, 500)); + const listAfterResponse = await fetch(`${workerUrl}/api/process/list`, { + method: 'GET', + headers + }); + const processesAfter = (await listAfterResponse.json()) as Process[]; + running = processesAfter.filter((p) => p.status === 'running'); + if (running.length === 0) break; + } + expect(running.length).toBe(0); + }, 60000); + } +); diff --git a/tests/e2e/environment-workflow.test.ts b/tests/e2e/environment-workflow.test.ts index 1d1dee26..745892b4 100644 --- a/tests/e2e/environment-workflow.test.ts +++ b/tests/e2e/environment-workflow.test.ts @@ -20,221 +20,234 @@ import { parseSSEStream } from '../../packages/sandbox/src/sse-parser'; * 2. Session-level setEnvVars * 3. Dockerfile ENV */ -describe('Environment Variables', () => { - let workerUrl: string; - let headers: Record; - - beforeAll(async () => { - const sandbox = await getSharedSandbox(); - workerUrl = sandbox.workerUrl; - headers = sandbox.createHeaders(createUniqueSession()); - }, 120000); - - test('should have Dockerfile ENV vars available', async () => { - // SANDBOX_VERSION is set in the Dockerfile - const response = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ command: 'echo $SANDBOX_VERSION' }) - }); - - expect(response.status).toBe(200); - const data = (await response.json()) as ExecResult; - expect(data.success).toBe(true); - // Should have some version value (not empty) - expect(data.stdout.trim()).toBeTruthy(); - expect(data.stdout.trim()).not.toBe('$SANDBOX_VERSION'); - }, 30000); - - test('should set and persist session-level env vars via setEnvVars', async () => { - // Set env vars at session level - const setResponse = await fetch(`${workerUrl}/api/env/set`, { - method: 'POST', - headers, - body: JSON.stringify({ - envVars: { - MY_SESSION_VAR: 'session-value', - ANOTHER_VAR: 'another-value' - } - }) - }); - - expect(setResponse.status).toBe(200); - - // Verify they persist across commands - const readResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "$MY_SESSION_VAR:$ANOTHER_VAR"' - }) - }); - - expect(readResponse.status).toBe(200); - const readData = (await readResponse.json()) as ExecResult; - expect(readData.stdout.trim()).toBe('session-value:another-value'); - }, 30000); - - test('should support per-command env in exec()', async () => { - const response = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "$CMD_VAR"', - env: { CMD_VAR: 'command-specific-value' } - }) - }); - - expect(response.status).toBe(200); - const data = (await response.json()) as ExecResult; - expect(data.stdout.trim()).toBe('command-specific-value'); - }, 30000); - - test('should support per-command env in execStream()', async () => { - const response = await fetch(`${workerUrl}/api/execStream`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "$STREAM_VAR"', - env: { STREAM_VAR: 'stream-env-value' } - }) - }); - - expect(response.status).toBe(200); - - // Collect streamed output - const events: ExecEvent[] = []; - const abortController = new AbortController(); - for await (const event of parseSSEStream( - response.body!, - abortController.signal - )) { - events.push(event); - if (event.type === 'complete' || event.type === 'error') break; - } - - const stdout = events - .filter((e) => e.type === 'stdout') - .map((e) => e.data) - .join(''); - expect(stdout.trim()).toBe('stream-env-value'); - }, 30000); - - test('should override session env with per-command env', async () => { - // First set a session-level var - await fetch(`${workerUrl}/api/env/set`, { - method: 'POST', - headers, - body: JSON.stringify({ - envVars: { OVERRIDE_TEST: 'session-level' } - }) - }); - - // Verify session value - const sessionResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ command: 'echo "$OVERRIDE_TEST"' }) - }); - const sessionData = (await sessionResponse.json()) as ExecResult; - expect(sessionData.stdout.trim()).toBe('session-level'); - - // Override with per-command env - const overrideResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "$OVERRIDE_TEST"', - env: { OVERRIDE_TEST: 'command-level' } - }) - }); - const overrideData = (await overrideResponse.json()) as ExecResult; - expect(overrideData.stdout.trim()).toBe('command-level'); - - // Session value should still be intact - const afterResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ command: 'echo "$OVERRIDE_TEST"' }) - }); - const afterData = (await afterResponse.json()) as ExecResult; - expect(afterData.stdout.trim()).toBe('session-level'); - }, 30000); - - test('should override Dockerfile ENV with session setEnvVars', async () => { - // Create a fresh session to test clean override - const sandbox = await getSharedSandbox(); - const freshHeaders = sandbox.createHeaders(createUniqueSession()); - - // First read Dockerfile value - const beforeResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: freshHeaders, - body: JSON.stringify({ command: 'echo "$SANDBOX_VERSION"' }) - }); - const beforeData = (await beforeResponse.json()) as ExecResult; - const dockerValue = beforeData.stdout.trim(); - expect(dockerValue).toBeTruthy(); - - // Override with session setEnvVars - await fetch(`${workerUrl}/api/env/set`, { - method: 'POST', - headers: freshHeaders, - body: JSON.stringify({ - envVars: { SANDBOX_VERSION: 'overridden-version' } - }) - }); - - // Verify override - const afterResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: freshHeaders, - body: JSON.stringify({ command: 'echo "$SANDBOX_VERSION"' }) - }); - const afterData = (await afterResponse.json()) as ExecResult; - expect(afterData.stdout.trim()).toBe('overridden-version'); - }, 30000); - - test('should handle commands that read stdin without hanging', async () => { - // Test 1: cat with no arguments should exit immediately with EOF - const catResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'cat' - }) - }); - - expect(catResponse.status).toBe(200); - const catData = (await catResponse.json()) as ExecResult; - expect(catData.success).toBe(true); - expect(catData.stdout).toBe(''); - - // Test 2: bash read command should return immediately - const readResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'read -t 1 INPUT_VAR || echo "read returned"' - }) - }); - - expect(readResponse.status).toBe(200); - const readData = (await readResponse.json()) as ExecResult; - expect(readData.success).toBe(true); - expect(readData.stdout).toContain('read returned'); - - // Test 3: grep with no file should exit immediately - const grepResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'grep "test" || true' - }) - }); - - expect(grepResponse.status).toBe(200); - const grepData = (await grepResponse.json()) as ExecResult; - expect(grepData.success).toBe(true); - }, 90000); -}); + +// Transport modes to test +const transportModes = [ + { name: 'HTTP', useWebSocket: false }, + { name: 'WebSocket', useWebSocket: true } +]; + +describe.each(transportModes)( + 'Environment Variables ($name transport)', + ({ useWebSocket }) => { + let workerUrl: string; + let headers: Record; + + beforeAll(async () => { + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + const baseHeaders = sandbox.createHeaders(createUniqueSession()); + headers = useWebSocket + ? { ...baseHeaders, 'X-Use-WebSocket': 'true' } + : baseHeaders; + }, 120000); + + test('should have Dockerfile ENV vars available', async () => { + // SANDBOX_VERSION is set in the Dockerfile + const response = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ command: 'echo $SANDBOX_VERSION' }) + }); + + expect(response.status).toBe(200); + const data = (await response.json()) as ExecResult; + expect(data.success).toBe(true); + // Should have some version value (not empty) + expect(data.stdout.trim()).toBeTruthy(); + expect(data.stdout.trim()).not.toBe('$SANDBOX_VERSION'); + }, 30000); + + test('should set and persist session-level env vars via setEnvVars', async () => { + // Set env vars at session level + const setResponse = await fetch(`${workerUrl}/api/env/set`, { + method: 'POST', + headers, + body: JSON.stringify({ + envVars: { + MY_SESSION_VAR: 'session-value', + ANOTHER_VAR: 'another-value' + } + }) + }); + + expect(setResponse.status).toBe(200); + + // Verify they persist across commands + const readResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "$MY_SESSION_VAR:$ANOTHER_VAR"' + }) + }); + + expect(readResponse.status).toBe(200); + const readData = (await readResponse.json()) as ExecResult; + expect(readData.stdout.trim()).toBe('session-value:another-value'); + }, 30000); + + test('should support per-command env in exec()', async () => { + const response = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "$CMD_VAR"', + env: { CMD_VAR: 'command-specific-value' } + }) + }); + + expect(response.status).toBe(200); + const data = (await response.json()) as ExecResult; + expect(data.stdout.trim()).toBe('command-specific-value'); + }, 30000); + + test('should support per-command env in execStream()', async () => { + const response = await fetch(`${workerUrl}/api/execStream`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "$STREAM_VAR"', + env: { STREAM_VAR: 'stream-env-value' } + }) + }); + + expect(response.status).toBe(200); + + // Collect streamed output + const events: ExecEvent[] = []; + const abortController = new AbortController(); + for await (const event of parseSSEStream( + response.body!, + abortController.signal + )) { + events.push(event); + if (event.type === 'complete' || event.type === 'error') break; + } + + const stdout = events + .filter((e) => e.type === 'stdout') + .map((e) => e.data) + .join(''); + expect(stdout.trim()).toBe('stream-env-value'); + }, 30000); + + test('should override session env with per-command env', async () => { + // First set a session-level var + await fetch(`${workerUrl}/api/env/set`, { + method: 'POST', + headers, + body: JSON.stringify({ + envVars: { OVERRIDE_TEST: 'session-level' } + }) + }); + + // Verify session value + const sessionResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ command: 'echo "$OVERRIDE_TEST"' }) + }); + const sessionData = (await sessionResponse.json()) as ExecResult; + expect(sessionData.stdout.trim()).toBe('session-level'); + + // Override with per-command env + const overrideResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "$OVERRIDE_TEST"', + env: { OVERRIDE_TEST: 'command-level' } + }) + }); + const overrideData = (await overrideResponse.json()) as ExecResult; + expect(overrideData.stdout.trim()).toBe('command-level'); + + // Session value should still be intact + const afterResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ command: 'echo "$OVERRIDE_TEST"' }) + }); + const afterData = (await afterResponse.json()) as ExecResult; + expect(afterData.stdout.trim()).toBe('session-level'); + }, 30000); + + test('should override Dockerfile ENV with session setEnvVars', async () => { + // Create a fresh session to test clean override + const sandbox = await getSharedSandbox(); + const freshHeaders = sandbox.createHeaders(createUniqueSession()); + + // First read Dockerfile value + const beforeResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: freshHeaders, + body: JSON.stringify({ command: 'echo "$SANDBOX_VERSION"' }) + }); + const beforeData = (await beforeResponse.json()) as ExecResult; + const dockerValue = beforeData.stdout.trim(); + expect(dockerValue).toBeTruthy(); + + // Override with session setEnvVars + await fetch(`${workerUrl}/api/env/set`, { + method: 'POST', + headers: freshHeaders, + body: JSON.stringify({ + envVars: { SANDBOX_VERSION: 'overridden-version' } + }) + }); + + // Verify override + const afterResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: freshHeaders, + body: JSON.stringify({ command: 'echo "$SANDBOX_VERSION"' }) + }); + const afterData = (await afterResponse.json()) as ExecResult; + expect(afterData.stdout.trim()).toBe('overridden-version'); + }, 30000); + + test('should handle commands that read stdin without hanging', async () => { + // Test 1: cat with no arguments should exit immediately with EOF + const catResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'cat' + }) + }); + + expect(catResponse.status).toBe(200); + const catData = (await catResponse.json()) as ExecResult; + expect(catData.success).toBe(true); + expect(catData.stdout).toBe(''); + + // Test 2: bash read command should return immediately + const readResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'read -t 1 INPUT_VAR || echo "read returned"' + }) + }); + + expect(readResponse.status).toBe(200); + const readData = (await readResponse.json()) as ExecResult; + expect(readData.success).toBe(true); + expect(readData.stdout).toContain('read returned'); + + // Test 3: grep with no file should exit immediately + const grepResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'grep "test" || true' + }) + }); + + expect(grepResponse.status).toBe(200); + const grepData = (await grepResponse.json()) as ExecResult; + expect(grepData.success).toBe(true); + }, 90000); + } +); diff --git a/tests/e2e/file-operations-workflow.test.ts b/tests/e2e/file-operations-workflow.test.ts index 1577b518..07808a91 100644 --- a/tests/e2e/file-operations-workflow.test.ts +++ b/tests/e2e/file-operations-workflow.test.ts @@ -20,243 +20,258 @@ import { uniqueTestPath } from './helpers/global-sandbox'; -describe('File Operations Error Handling', () => { - let workerUrl: string; - let headers: Record; - let testDir: string; - - beforeAll(async () => { - const sandbox = await getSharedSandbox(); - workerUrl = sandbox.workerUrl; - headers = sandbox.createHeaders(createUniqueSession()); - }, 120000); - - // Use unique directory for each test to avoid conflicts - beforeEach(() => { - testDir = uniqueTestPath('file-ops'); - }); - - test('should reject deleting directories with deleteFile', async () => { - const dirPath = `${testDir}/test-dir`; - - // Create a directory - await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: dirPath, - recursive: true - }) +// Transport modes to test +const transportModes = [ + { name: 'HTTP', useWebSocket: false }, + { name: 'WebSocket', useWebSocket: true } +]; + +describe.each(transportModes)( + 'File Operations Error Handling ($name transport)', + ({ useWebSocket }) => { + let workerUrl: string; + let headers: Record; + let testDir: string; + + beforeAll(async () => { + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + const baseHeaders = sandbox.createHeaders(createUniqueSession()); + headers = useWebSocket + ? { ...baseHeaders, 'X-Use-WebSocket': 'true' } + : baseHeaders; + }, 120000); + + // Use unique directory for each test to avoid conflicts + beforeEach(() => { + testDir = uniqueTestPath('file-ops'); }); - // Try to delete directory with deleteFile - should fail - const deleteResponse = await fetch(`${workerUrl}/api/file/delete`, { - method: 'DELETE', - headers, - body: JSON.stringify({ - path: dirPath - }) - }); - - expect(deleteResponse.status).toBe(500); - const deleteData = (await deleteResponse.json()) as ErrorResponse; - expect(deleteData.error).toContain('Cannot delete directory'); - expect(deleteData.error).toContain('deleteFile()'); - - // Verify directory still exists - const lsResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: `ls -d ${dirPath}` - }) - }); - - const lsData = (await lsResponse.json()) as ReadFileResult; - expect(lsResponse.status).toBe(200); - expect(lsData.success).toBe(true); - }, 90000); - - test('should return error when deleting nonexistent file', async () => { - const deleteResponse = await fetch(`${workerUrl}/api/file/delete`, { - method: 'DELETE', - headers, - body: JSON.stringify({ - path: `${testDir}/this-file-does-not-exist.txt` - }) - }); - - expect(deleteResponse.status).toBe(500); - const errorData = (await deleteResponse.json()) as ErrorResponse; - expect(errorData.error).toBeTruthy(); - expect(errorData.error).toMatch(/not found|does not exist|no such file/i); - }, 90000); - - test('should handle listFiles errors appropriately', async () => { - // Test non-existent directory - const notFoundResponse = await fetch(`${workerUrl}/api/list-files`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: `${testDir}/does-not-exist` - }) - }); - - expect(notFoundResponse.status).toBe(500); - const notFoundData = (await notFoundResponse.json()) as ErrorResponse; - expect(notFoundData.error).toBeTruthy(); - expect(notFoundData.error).toMatch(/not found|does not exist/i); - - // Test listing a file instead of directory - const filePath = `${testDir}/file.txt`; - await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ path: testDir, recursive: true }) - }); - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: filePath, - content: 'Not a directory' - }) - }); - - const wrongTypeResponse = await fetch(`${workerUrl}/api/list-files`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: filePath - }) - }); - - expect(wrongTypeResponse.status).toBe(500); - const wrongTypeData = (await wrongTypeResponse.json()) as ErrorResponse; - expect(wrongTypeData.error).toMatch(/not a directory/i); - }, 90000); - - // Regression test for #196: hidden files in hidden directories - test('should list files in hidden directories with includeHidden flag', async () => { - const hiddenDir = `${testDir}/.hidden/foo`; - - // Create hidden directory structure - await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ path: `${hiddenDir}/bar`, recursive: true }) - }); - - // Write visible files in hidden directory - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: `${hiddenDir}/visible1.txt`, - content: 'Visible 1' - }) - }); - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: `${hiddenDir}/visible2.txt`, - content: 'Visible 2' - }) - }); - - // Write hidden file in hidden directory - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: `${hiddenDir}/.hiddenfile.txt`, - content: 'Hidden' - }) - }); - - // List WITHOUT includeHidden - should NOT show .hiddenfile.txt - const listResponse = await fetch(`${workerUrl}/api/list-files`, { - method: 'POST', - headers, - body: JSON.stringify({ path: hiddenDir }) - }); - - expect(listResponse.status).toBe(200); - const listData = (await listResponse.json()) as ListFilesResult; - expect(listData.success).toBe(true); - - const visibleFiles = listData.files.filter( - (f: FileInfo) => !f.name.startsWith('.') - ); - expect(visibleFiles.length).toBe(3); // visible1.txt, visible2.txt, bar/ - - const hiddenFile = listData.files.find( - (f: FileInfo) => f.name === '.hiddenfile.txt' - ); - expect(hiddenFile).toBeUndefined(); - - // List WITH includeHidden - should show all files - const listWithHiddenResponse = await fetch(`${workerUrl}/api/list-files`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: hiddenDir, - options: { includeHidden: true } - }) - }); - - expect(listWithHiddenResponse.status).toBe(200); - const listWithHiddenData = - (await listWithHiddenResponse.json()) as ListFilesResult; - - expect(listWithHiddenData.success).toBe(true); - expect(listWithHiddenData.files.length).toBe(4); // +.hiddenfile.txt - - const hiddenFileWithFlag = listWithHiddenData.files.find( - (f: FileInfo) => f.name === '.hiddenfile.txt' - ); - expect(hiddenFileWithFlag).toBeDefined(); - }, 90000); - - test('should read binary files with base64 encoding', async () => { - // 1x1 PNG - smallest valid PNG - const pngBase64 = - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jYlkKQAAAABJRU5ErkJggg=='; - - // Create binary file via base64 decode - await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ path: testDir, recursive: true }) - }); - - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: `echo '${pngBase64}' | base64 -d > ${testDir}/test.png` - }) - }); - - // Read the binary file - const readResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers, - body: JSON.stringify({ path: `${testDir}/test.png` }) - }); - - expect(readResponse.status).toBe(200); - const readData = (await readResponse.json()) as ReadFileResult; - - expect(readData.success).toBe(true); - expect(readData.encoding).toBe('base64'); - expect(readData.isBinary).toBe(true); - expect(readData.mimeType).toMatch(/image\/png/); - expect(readData.content).toBeTruthy(); - expect(readData.size).toBeGreaterThan(0); - - // Verify the content is valid base64 - expect(readData.content).toMatch(/^[A-Za-z0-9+/=]+$/); - }, 90000); -}); + test('should reject deleting directories with deleteFile', async () => { + const dirPath = `${testDir}/test-dir`; + + // Create a directory + await fetch(`${workerUrl}/api/file/mkdir`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: dirPath, + recursive: true + }) + }); + + // Try to delete directory with deleteFile - should fail + const deleteResponse = await fetch(`${workerUrl}/api/file/delete`, { + method: 'DELETE', + headers, + body: JSON.stringify({ + path: dirPath + }) + }); + + expect(deleteResponse.status).toBe(500); + const deleteData = (await deleteResponse.json()) as ErrorResponse; + expect(deleteData.error).toContain('Cannot delete directory'); + expect(deleteData.error).toContain('deleteFile()'); + + // Verify directory still exists + const lsResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `ls -d ${dirPath}` + }) + }); + + const lsData = (await lsResponse.json()) as ReadFileResult; + expect(lsResponse.status).toBe(200); + expect(lsData.success).toBe(true); + }, 90000); + + test('should return error when deleting nonexistent file', async () => { + const deleteResponse = await fetch(`${workerUrl}/api/file/delete`, { + method: 'DELETE', + headers, + body: JSON.stringify({ + path: `${testDir}/this-file-does-not-exist.txt` + }) + }); + + expect(deleteResponse.status).toBe(500); + const errorData = (await deleteResponse.json()) as ErrorResponse; + expect(errorData.error).toBeTruthy(); + expect(errorData.error).toMatch(/not found|does not exist|no such file/i); + }, 90000); + + test('should handle listFiles errors appropriately', async () => { + // Test non-existent directory + const notFoundResponse = await fetch(`${workerUrl}/api/list-files`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: `${testDir}/does-not-exist` + }) + }); + + expect(notFoundResponse.status).toBe(500); + const notFoundData = (await notFoundResponse.json()) as ErrorResponse; + expect(notFoundData.error).toBeTruthy(); + expect(notFoundData.error).toMatch(/not found|does not exist/i); + + // Test listing a file instead of directory + const filePath = `${testDir}/file.txt`; + await fetch(`${workerUrl}/api/file/mkdir`, { + method: 'POST', + headers, + body: JSON.stringify({ path: testDir, recursive: true }) + }); + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: filePath, + content: 'Not a directory' + }) + }); + + const wrongTypeResponse = await fetch(`${workerUrl}/api/list-files`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: filePath + }) + }); + + expect(wrongTypeResponse.status).toBe(500); + const wrongTypeData = (await wrongTypeResponse.json()) as ErrorResponse; + expect(wrongTypeData.error).toMatch(/not a directory/i); + }, 90000); + + // Regression test for #196: hidden files in hidden directories + test('should list files in hidden directories with includeHidden flag', async () => { + const hiddenDir = `${testDir}/.hidden/foo`; + + // Create hidden directory structure + await fetch(`${workerUrl}/api/file/mkdir`, { + method: 'POST', + headers, + body: JSON.stringify({ path: `${hiddenDir}/bar`, recursive: true }) + }); + + // Write visible files in hidden directory + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: `${hiddenDir}/visible1.txt`, + content: 'Visible 1' + }) + }); + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: `${hiddenDir}/visible2.txt`, + content: 'Visible 2' + }) + }); + + // Write hidden file in hidden directory + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: `${hiddenDir}/.hiddenfile.txt`, + content: 'Hidden' + }) + }); + + // List WITHOUT includeHidden - should NOT show .hiddenfile.txt + const listResponse = await fetch(`${workerUrl}/api/list-files`, { + method: 'POST', + headers, + body: JSON.stringify({ path: hiddenDir }) + }); + + expect(listResponse.status).toBe(200); + const listData = (await listResponse.json()) as ListFilesResult; + expect(listData.success).toBe(true); + + const visibleFiles = listData.files.filter( + (f: FileInfo) => !f.name.startsWith('.') + ); + expect(visibleFiles.length).toBe(3); // visible1.txt, visible2.txt, bar/ + + const hiddenFile = listData.files.find( + (f: FileInfo) => f.name === '.hiddenfile.txt' + ); + expect(hiddenFile).toBeUndefined(); + + // List WITH includeHidden - should show all files + const listWithHiddenResponse = await fetch( + `${workerUrl}/api/list-files`, + { + method: 'POST', + headers, + body: JSON.stringify({ + path: hiddenDir, + options: { includeHidden: true } + }) + } + ); + + expect(listWithHiddenResponse.status).toBe(200); + const listWithHiddenData = + (await listWithHiddenResponse.json()) as ListFilesResult; + + expect(listWithHiddenData.success).toBe(true); + expect(listWithHiddenData.files.length).toBe(4); // +.hiddenfile.txt + + const hiddenFileWithFlag = listWithHiddenData.files.find( + (f: FileInfo) => f.name === '.hiddenfile.txt' + ); + expect(hiddenFileWithFlag).toBeDefined(); + }, 90000); + + test('should read binary files with base64 encoding', async () => { + // 1x1 PNG - smallest valid PNG + const pngBase64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jYlkKQAAAABJRU5ErkJggg=='; + + // Create binary file via base64 decode + await fetch(`${workerUrl}/api/file/mkdir`, { + method: 'POST', + headers, + body: JSON.stringify({ path: testDir, recursive: true }) + }); + + await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `echo '${pngBase64}' | base64 -d > ${testDir}/test.png` + }) + }); + + // Read the binary file + const readResponse = await fetch(`${workerUrl}/api/file/read`, { + method: 'POST', + headers, + body: JSON.stringify({ path: `${testDir}/test.png` }) + }); + + expect(readResponse.status).toBe(200); + const readData = (await readResponse.json()) as ReadFileResult; + + expect(readData.success).toBe(true); + expect(readData.encoding).toBe('base64'); + expect(readData.isBinary).toBe(true); + expect(readData.mimeType).toMatch(/image\/png/); + expect(readData.content).toBeTruthy(); + expect(readData.size).toBeGreaterThan(0); + + // Verify the content is valid base64 + expect(readData.content).toMatch(/^[A-Za-z0-9+/=]+$/); + }, 90000); + } +); diff --git a/tests/e2e/git-clone-workflow.test.ts b/tests/e2e/git-clone-workflow.test.ts index 54e958f1..83189ae2 100644 --- a/tests/e2e/git-clone-workflow.test.ts +++ b/tests/e2e/git-clone-workflow.test.ts @@ -16,49 +16,62 @@ import type { ErrorResponse } from './test-worker/types'; * - Nonexistent repository handling * - Private repository without auth */ -describe('Git Clone Error Handling', () => { - let workerUrl: string; - let headers: Record; - beforeAll(async () => { - const sandbox = await getSharedSandbox(); - workerUrl = sandbox.workerUrl; - headers = sandbox.createHeaders(createUniqueSession()); - }, 120000); +// Transport modes to test +const transportModes = [ + { name: 'HTTP', useWebSocket: false }, + { name: 'WebSocket', useWebSocket: true } +]; - test('should handle git clone errors for nonexistent repository', async () => { - const cloneResponse = await fetch(`${workerUrl}/api/git/clone`, { - method: 'POST', - headers, - body: JSON.stringify({ - repoUrl: - 'https://github.com/nonexistent/repository-that-does-not-exist-12345' - }) - }); +describe.each(transportModes)( + 'Git Clone Error Handling ($name transport)', + ({ useWebSocket }) => { + let workerUrl: string; + let headers: Record; - expect(cloneResponse.status).toBe(500); - const errorData = (await cloneResponse.json()) as ErrorResponse; - expect(errorData.error).toBeTruthy(); - expect(errorData.error).toMatch( - /not found|does not exist|repository|fatal/i - ); - }, 90000); + beforeAll(async () => { + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + const baseHeaders = sandbox.createHeaders(createUniqueSession()); + headers = useWebSocket + ? { ...baseHeaders, 'X-Use-WebSocket': 'true' } + : baseHeaders; + }, 120000); - test('should handle git clone errors for private repository without auth', async () => { - const cloneResponse = await fetch(`${workerUrl}/api/git/clone`, { - method: 'POST', - headers, - body: JSON.stringify({ - repoUrl: - 'https://github.com/cloudflare/private-test-repo-that-requires-auth' - }) - }); + test('should handle git clone errors for nonexistent repository', async () => { + const cloneResponse = await fetch(`${workerUrl}/api/git/clone`, { + method: 'POST', + headers, + body: JSON.stringify({ + repoUrl: + 'https://github.com/nonexistent/repository-that-does-not-exist-12345' + }) + }); - expect(cloneResponse.status).toBe(500); - const errorData = (await cloneResponse.json()) as ErrorResponse; - expect(errorData.error).toBeTruthy(); - expect(errorData.error).toMatch( - /authentication|permission|access|denied|fatal|not found/i - ); - }, 90000); -}); + expect(cloneResponse.status).toBe(500); + const errorData = (await cloneResponse.json()) as ErrorResponse; + expect(errorData.error).toBeTruthy(); + expect(errorData.error).toMatch( + /not found|does not exist|repository|fatal/i + ); + }, 90000); + + test('should handle git clone errors for private repository without auth', async () => { + const cloneResponse = await fetch(`${workerUrl}/api/git/clone`, { + method: 'POST', + headers, + body: JSON.stringify({ + repoUrl: + 'https://github.com/cloudflare/private-test-repo-that-requires-auth' + }) + }); + + expect(cloneResponse.status).toBe(500); + const errorData = (await cloneResponse.json()) as ErrorResponse; + expect(errorData.error).toBeTruthy(); + expect(errorData.error).toMatch( + /authentication|permission|access|denied|fatal|not found/i + ); + }, 90000); + } +); diff --git a/tests/e2e/helpers/test-fixtures.ts b/tests/e2e/helpers/test-fixtures.ts index 876a5d10..b533f4d6 100644 --- a/tests/e2e/helpers/test-fixtures.ts +++ b/tests/e2e/helpers/test-fixtures.ts @@ -27,11 +27,23 @@ export function createSessionId(): string { return `session-${randomUUID()}`; } +/** + * Options for creating test headers + */ +export interface TestHeaderOptions { + /** Session ID for session isolation tests */ + sessionId?: string; + /** Enable WebSocket transport instead of HTTP */ + useWebSocket?: boolean; + /** Enable keepAlive mode */ + keepAlive?: boolean; +} + /** * Create headers for sandbox/session identification * * @param sandboxId - Which container instance to use - * @param sessionId - (Optional) Which session within that container (SDK defaults to auto-managed session) + * @param options - Optional configuration (sessionId, useWebSocket, keepAlive) * * @example * // Most tests: unique sandbox, default session @@ -40,20 +52,36 @@ export function createSessionId(): string { * @example * // Session isolation tests: one sandbox, multiple sessions * const sandboxId = createSandboxId(); - * const headers1 = createTestHeaders(sandboxId, createSessionId()); - * const headers2 = createTestHeaders(sandboxId, createSessionId()); + * const headers1 = createTestHeaders(sandboxId, { sessionId: createSessionId() }); + * const headers2 = createTestHeaders(sandboxId, { sessionId: createSessionId() }); + * + * @example + * // WebSocket transport tests + * const headers = createTestHeaders(createSandboxId(), { useWebSocket: true }); */ export function createTestHeaders( sandboxId: string, - sessionId?: string + options?: TestHeaderOptions | string ): Record { + // Support legacy signature: createTestHeaders(sandboxId, sessionId) + const opts: TestHeaderOptions = + typeof options === 'string' ? { sessionId: options } : options || {}; + const headers: Record = { 'Content-Type': 'application/json', 'X-Sandbox-Id': sandboxId }; - if (sessionId) { - headers['X-Session-Id'] = sessionId; + if (opts.sessionId) { + headers['X-Session-Id'] = opts.sessionId; + } + + if (opts.useWebSocket) { + headers['X-Use-WebSocket'] = 'true'; + } + + if (opts.keepAlive) { + headers['X-Sandbox-KeepAlive'] = 'true'; } return headers; diff --git a/tests/e2e/keepalive-workflow.test.ts b/tests/e2e/keepalive-workflow.test.ts index 04b830a1..119e4056 100644 --- a/tests/e2e/keepalive-workflow.test.ts +++ b/tests/e2e/keepalive-workflow.test.ts @@ -17,99 +17,112 @@ import type { Process, ExecResult, ReadFileResult } from '@repo/shared'; * 3. File and process operations work with keepAlive * 4. Explicit destroy works (cleanup endpoint) */ -describe('KeepAlive Feature', () => { - let workerUrl: string; - let headers: Record; - beforeAll(async () => { - const sandbox = await getSharedSandbox(); - workerUrl = sandbox.workerUrl; - headers = sandbox.createHeaders(createUniqueSession()); - }, 120000); +// Transport modes to test +const transportModes = [ + { name: 'HTTP', useWebSocket: false }, + { name: 'WebSocket', useWebSocket: true } +]; - test('should accept keepAlive header and execute commands', async () => { - const keepAliveHeaders = { ...headers, 'X-Sandbox-KeepAlive': 'true' }; +describe.each(transportModes)( + 'KeepAlive Feature ($name transport)', + ({ useWebSocket }) => { + let workerUrl: string; + let headers: Record; - // First command with keepAlive - const response1 = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: keepAliveHeaders, - body: JSON.stringify({ command: 'echo "keepAlive command 1"' }) - }); - expect(response1.status).toBe(200); - const data1 = (await response1.json()) as ExecResult; - expect(data1.stdout).toContain('keepAlive command 1'); + beforeAll(async () => { + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + const baseHeaders = sandbox.createHeaders(createUniqueSession()); + headers = useWebSocket + ? { ...baseHeaders, 'X-Use-WebSocket': 'true' } + : baseHeaders; + }, 120000); - // Second command immediately after - const response2 = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: keepAliveHeaders, - body: JSON.stringify({ command: 'echo "keepAlive command 2"' }) - }); - expect(response2.status).toBe(200); - const data2 = (await response2.json()) as ExecResult; - expect(data2.stdout).toContain('keepAlive command 2'); - }, 30000); + test('should accept keepAlive header and execute commands', async () => { + const keepAliveHeaders = { ...headers, 'X-Sandbox-KeepAlive': 'true' }; - test('should support background processes with keepAlive', async () => { - const keepAliveHeaders = { ...headers, 'X-Sandbox-KeepAlive': 'true' }; + // First command with keepAlive + const response1 = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ command: 'echo "keepAlive command 1"' }) + }); + expect(response1.status).toBe(200); + const data1 = (await response1.json()) as ExecResult; + expect(data1.stdout).toContain('keepAlive command 1'); - // Start a background process - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers: keepAliveHeaders, - body: JSON.stringify({ command: 'sleep 10' }) - }); - expect(startResponse.status).toBe(200); - const processData = (await startResponse.json()) as Process; - expect(processData.id).toBeTruthy(); + // Second command immediately after + const response2 = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ command: 'echo "keepAlive command 2"' }) + }); + expect(response2.status).toBe(200); + const data2 = (await response2.json()) as ExecResult; + expect(data2.stdout).toContain('keepAlive command 2'); + }, 30000); - // Verify process is running - const statusResponse = await fetch( - `${workerUrl}/api/process/${processData.id}`, - { method: 'GET', headers: keepAliveHeaders } - ); - expect(statusResponse.status).toBe(200); - const statusData = (await statusResponse.json()) as Process; - expect(statusData.status).toBe('running'); + test('should support background processes with keepAlive', async () => { + const keepAliveHeaders = { ...headers, 'X-Sandbox-KeepAlive': 'true' }; - // Cleanup - await fetch(`${workerUrl}/api/process/${processData.id}`, { - method: 'DELETE', - headers: keepAliveHeaders - }); - }, 30000); + // Start a background process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ command: 'sleep 10' }) + }); + expect(startResponse.status).toBe(200); + const processData = (await startResponse.json()) as Process; + expect(processData.id).toBeTruthy(); - test('should work with file operations and keepAlive', async () => { - const keepAliveHeaders = { ...headers, 'X-Sandbox-KeepAlive': 'true' }; - const testPath = `/workspace/keepalive-test-${Date.now()}.txt`; + // Verify process is running + const statusResponse = await fetch( + `${workerUrl}/api/process/${processData.id}`, + { method: 'GET', headers: keepAliveHeaders } + ); + expect(statusResponse.status).toBe(200); + const statusData = (await statusResponse.json()) as Process; + expect(statusData.status).toBe('running'); - // Write file with keepAlive - const writeResponse = await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers: keepAliveHeaders, - body: JSON.stringify({ - path: testPath, - content: 'keepAlive file content' - }) - }); - expect(writeResponse.status).toBe(200); + // Cleanup + await fetch(`${workerUrl}/api/process/${processData.id}`, { + method: 'DELETE', + headers: keepAliveHeaders + }); + }, 30000); - // Read file with keepAlive - const readResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers: keepAliveHeaders, - body: JSON.stringify({ path: testPath }) - }); - expect(readResponse.status).toBe(200); - const readData = (await readResponse.json()) as ReadFileResult; - expect(readData.content).toBe('keepAlive file content'); + test('should work with file operations and keepAlive', async () => { + const keepAliveHeaders = { ...headers, 'X-Sandbox-KeepAlive': 'true' }; + const testPath = `/workspace/keepalive-test-${Date.now()}.txt`; - // Cleanup - await fetch(`${workerUrl}/api/file/delete`, { - method: 'DELETE', - headers: keepAliveHeaders, - body: JSON.stringify({ path: testPath }) - }); - }, 30000); -}); + // Write file with keepAlive + const writeResponse = await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ + path: testPath, + content: 'keepAlive file content' + }) + }); + expect(writeResponse.status).toBe(200); + + // Read file with keepAlive + const readResponse = await fetch(`${workerUrl}/api/file/read`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ path: testPath }) + }); + expect(readResponse.status).toBe(200); + const readData = (await readResponse.json()) as ReadFileResult; + expect(readData.content).toBe('keepAlive file content'); + + // Cleanup + await fetch(`${workerUrl}/api/file/delete`, { + method: 'DELETE', + headers: keepAliveHeaders, + body: JSON.stringify({ path: testPath }) + }); + }, 30000); + } +); diff --git a/tests/e2e/process-lifecycle-workflow.test.ts b/tests/e2e/process-lifecycle-workflow.test.ts index d4f70767..549c991e 100644 --- a/tests/e2e/process-lifecycle-workflow.test.ts +++ b/tests/e2e/process-lifecycle-workflow.test.ts @@ -22,73 +22,91 @@ const skipPortExposureTests = * - Unexposing non-exposed ports * - Foreground operations not blocking on background processes */ -describe('Process Lifecycle Error Handling', () => { - let workerUrl: string; - let headers: Record; - let portHeaders: Record; - - beforeAll(async () => { - const sandbox = await getSharedSandbox(); - workerUrl = sandbox.workerUrl; - headers = sandbox.createHeaders(createUniqueSession()); - // Port exposure requires sandbox headers (not session headers) - portHeaders = { - 'X-Sandbox-Id': sandbox.sandboxId, - 'Content-Type': 'application/json' - }; - }, 120000); - - test('should return error when killing nonexistent process', async () => { - const killResponse = await fetch( - `${workerUrl}/api/process/fake-process-id-12345`, - { - method: 'DELETE', - headers - } - ); - expect(killResponse.status).toBe(500); - const errorData = (await killResponse.json()) as { error: string }; - expect(errorData.error).toBeTruthy(); - expect(errorData.error).toMatch( - /not found|does not exist|invalid|unknown/i - ); - }, 90000); - - test('should capture PID and logs immediately for fast commands', async () => { - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "Hello from process"' - }) - }); - - expect(startResponse.status).toBe(200); - const startData = (await startResponse.json()) as Process; - const processId = startData.id; - - // PID should be available immediately - expect(startData.pid).toBeDefined(); - expect(typeof startData.pid).toBe('number'); - - // Logs should be available immediately for fast commands - const logsResponse = await fetch( - `${workerUrl}/api/process/${processId}/logs`, - { - method: 'GET', - headers - } - ); +// Transport modes to test +const transportModes = [ + { name: 'HTTP', useWebSocket: false }, + { name: 'WebSocket', useWebSocket: true } +]; + +describe.each(transportModes)( + 'Process Lifecycle Error Handling ($name transport)', + ({ useWebSocket }) => { + let workerUrl: string; + let headers: Record; + let portHeaders: Record; + + beforeAll(async () => { + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + const baseHeaders = sandbox.createHeaders(createUniqueSession()); + headers = useWebSocket + ? { ...baseHeaders, 'X-Use-WebSocket': 'true' } + : baseHeaders; + // Port exposure requires sandbox headers (not session headers) + portHeaders = useWebSocket + ? { + 'X-Sandbox-Id': sandbox.sandboxId, + 'Content-Type': 'application/json', + 'X-Use-WebSocket': 'true' + } + : { + 'X-Sandbox-Id': sandbox.sandboxId, + 'Content-Type': 'application/json' + }; + }, 120000); + + test('should return error when killing nonexistent process', async () => { + const killResponse = await fetch( + `${workerUrl}/api/process/fake-process-id-12345`, + { + method: 'DELETE', + headers + } + ); + + expect(killResponse.status).toBe(500); + const errorData = (await killResponse.json()) as { error: string }; + expect(errorData.error).toBeTruthy(); + expect(errorData.error).toMatch( + /not found|does not exist|invalid|unknown/i + ); + }, 90000); - expect(logsResponse.status).toBe(200); - const logsData = (await logsResponse.json()) as ProcessLogsResult; - expect(logsData.stdout).toContain('Hello from process'); - }, 90000); + test('should capture PID and logs immediately for fast commands', async () => { + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "Hello from process"' + }) + }); + + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; + + // PID should be available immediately + expect(startData.pid).toBeDefined(); + expect(typeof startData.pid).toBe('number'); + + // Logs should be available immediately for fast commands + const logsResponse = await fetch( + `${workerUrl}/api/process/${processId}/logs`, + { + method: 'GET', + headers + } + ); - test('should stream process logs in real-time', async () => { - // Write a script that outputs multiple lines - const scriptCode = ` + expect(logsResponse.status).toBe(200); + const logsData = (await logsResponse.json()) as ProcessLogsResult; + expect(logsData.stdout).toContain('Hello from process'); + }, 90000); + + test('should stream process logs in real-time', async () => { + // Write a script that outputs multiple lines + const scriptCode = ` console.log("Line 1"); await Bun.sleep(100); console.log("Line 2"); @@ -96,151 +114,154 @@ await Bun.sleep(100); console.log("Line 3"); `.trim(); - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/script.js', - content: scriptCode - }) - }); - - // Start the script - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'bun run /workspace/script.js' - }) - }); - - const startData = (await startResponse.json()) as Process; - const processId = startData.id; - - // Stream logs (SSE) - const streamResponse = await fetch( - `${workerUrl}/api/process/${processId}/stream`, - { - method: 'GET', - headers - } - ); + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/script.js', + content: scriptCode + }) + }); - expect(streamResponse.status).toBe(200); - expect(streamResponse.headers.get('content-type')).toBe( - 'text/event-stream' - ); + // Start the script + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'bun run /workspace/script.js' + }) + }); + + const startData = (await startResponse.json()) as Process; + const processId = startData.id; + + // Stream logs (SSE) + const streamResponse = await fetch( + `${workerUrl}/api/process/${processId}/stream`, + { + method: 'GET', + headers + } + ); + + expect(streamResponse.status).toBe(200); + expect(streamResponse.headers.get('content-type')).toBe( + 'text/event-stream' + ); - // Collect events from the stream - const reader = streamResponse.body?.getReader(); - const decoder = new TextDecoder(); - const events: any[] = []; - - if (reader) { - let done = false; - let timeout = Date.now() + 10000; // 10s timeout - - while (!done && Date.now() < timeout) { - const { value, done: streamDone } = await reader.read(); - done = streamDone; - - if (value) { - const chunk = decoder.decode(value); - const lines = chunk - .split('\n\n') - .filter((line) => line.startsWith('data: ')); - - for (const line of lines) { - const eventData = line.replace('data: ', ''); - try { - events.push(JSON.parse(eventData)); - } catch (e) { - // Skip malformed events + // Collect events from the stream + const reader = streamResponse.body?.getReader(); + const decoder = new TextDecoder(); + const events: any[] = []; + + if (reader) { + let done = false; + let timeout = Date.now() + 10000; // 10s timeout + + while (!done && Date.now() < timeout) { + const { value, done: streamDone } = await reader.read(); + done = streamDone; + + if (value) { + const chunk = decoder.decode(value); + const lines = chunk + .split('\n\n') + .filter((line) => line.startsWith('data: ')); + + for (const line of lines) { + const eventData = line.replace('data: ', ''); + try { + events.push(JSON.parse(eventData)); + } catch (e) { + // Skip malformed events + } } } - } - // Stop after collecting some events - if (events.length >= 3) { - reader.cancel(); - break; + // Stop after collecting some events + if (events.length >= 3) { + reader.cancel(); + break; + } } } - } - }, 90000); + }, 90000); + + test.skipIf(skipPortExposureTests)( + 'should reject exposing reserved ports', + async () => { + const exposeResponse = await fetch(`${workerUrl}/api/port/expose`, { + method: 'POST', + headers: portHeaders, + body: JSON.stringify({ + port: 22, + name: 'ssh-server' + }) + }); + + expect(exposeResponse.status).toBeGreaterThanOrEqual(400); + const errorData = (await exposeResponse.json()) as { error: string }; + expect(errorData.error).toBeTruthy(); + expect(errorData.error).toMatch( + /reserved|not allowed|forbidden|invalid port|already exposed/i + ); + }, + 90000 + ); + + test.skipIf(skipPortExposureTests)( + 'should return error when unexposing non-exposed port', + async () => { + const unexposeResponse = await fetch( + `${workerUrl}/api/exposed-ports/${PORT_LIFECYCLE_TEST_PORT}`, + { + method: 'DELETE', + headers: portHeaders + } + ); + + expect(unexposeResponse.status).toBe(500); + const errorData = (await unexposeResponse.json()) as { error: string }; + expect(errorData.error).toBeTruthy(); + expect(errorData.error).toMatch( + /not found|not exposed|does not exist/i + ); + }, + 90000 + ); - test.skipIf(skipPortExposureTests)( - 'should reject exposing reserved ports', - async () => { - const exposeResponse = await fetch(`${workerUrl}/api/port/expose`, { + test('should not block foreground operations when background processes are running', async () => { + // Start a long-running background process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { method: 'POST', - headers: portHeaders, + headers, body: JSON.stringify({ - port: 22, - name: 'ssh-server' + command: 'sleep 60' }) }); - expect(exposeResponse.status).toBeGreaterThanOrEqual(400); - const errorData = (await exposeResponse.json()) as { error: string }; - expect(errorData.error).toBeTruthy(); - expect(errorData.error).toMatch( - /reserved|not allowed|forbidden|invalid port/i - ); - }, - 90000 - ); - - test.skipIf(skipPortExposureTests)( - 'should return error when unexposing non-exposed port', - async () => { - const unexposeResponse = await fetch( - `${workerUrl}/api/exposed-ports/${PORT_LIFECYCLE_TEST_PORT}`, - { - method: 'DELETE', - headers: portHeaders - } - ); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; - expect(unexposeResponse.status).toBe(500); - const errorData = (await unexposeResponse.json()) as { error: string }; - expect(errorData.error).toBeTruthy(); - expect(errorData.error).toMatch(/not found|not exposed|does not exist/i); - }, - 90000 - ); - - test('should not block foreground operations when background processes are running', async () => { - // Start a long-running background process - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'sleep 60' - }) - }); - - const startData = (await startResponse.json()) as Process; - const processId = startData.id; - - // Immediately run a foreground command - should complete quickly - const execStart = Date.now(); - const execResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "test"' - }) - }); - const execDuration = Date.now() - execStart; - - expect(execResponse.status).toBe(200); - expect(execDuration).toBeLessThan(2000); // Should complete in <2s - - // Cleanup - await fetch(`${workerUrl}/api/process/${processId}`, { - method: 'DELETE', - headers - }); - }, 90000); -}); + // Immediately run a foreground command - should complete quickly + const execStart = Date.now(); + const execResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "test"' + }) + }); + const execDuration = Date.now() - execStart; + + expect(execResponse.status).toBe(200); + expect(execDuration).toBeLessThan(2000); // Should complete in <2s + + // Cleanup + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, 90000); + } +); diff --git a/tests/e2e/process-readiness-workflow.test.ts b/tests/e2e/process-readiness-workflow.test.ts index bbb6e2a8..7673ed97 100644 --- a/tests/e2e/process-readiness-workflow.test.ts +++ b/tests/e2e/process-readiness-workflow.test.ts @@ -16,80 +16,98 @@ const skipPortExposureTests = * - waitForLog() method with string and regex patterns * - waitForPort() method for port checking */ -describe('Process Readiness Workflow', () => { - let workerUrl: string; - let headers: Record; - let portHeaders: Record; - - beforeAll(async () => { - const sandbox = await getSharedSandbox(); - workerUrl = sandbox.workerUrl; - headers = sandbox.createHeaders(createUniqueSession()); - // Port exposure requires sandbox headers (not session headers) - portHeaders = { - 'X-Sandbox-Id': sandbox.sandboxId, - 'Content-Type': 'application/json' - }; - }, 120000); - - test('should wait for string pattern in process output', async () => { - // Write a script that outputs a specific message after a delay - const scriptCode = ` + +// Transport modes to test +const transportModes = [ + { name: 'HTTP', useWebSocket: false }, + { name: 'WebSocket', useWebSocket: true } +]; + +describe.each(transportModes)( + 'Process Readiness Workflow ($name transport)', + ({ useWebSocket }) => { + let workerUrl: string; + let headers: Record; + let portHeaders: Record; + + beforeAll(async () => { + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + const baseHeaders = sandbox.createHeaders(createUniqueSession()); + headers = useWebSocket + ? { ...baseHeaders, 'X-Use-WebSocket': 'true' } + : baseHeaders; + // Port exposure requires sandbox headers (not session headers) + portHeaders = useWebSocket + ? { + 'X-Sandbox-Id': sandbox.sandboxId, + 'Content-Type': 'application/json', + 'X-Use-WebSocket': 'true' + } + : { + 'X-Sandbox-Id': sandbox.sandboxId, + 'Content-Type': 'application/json' + }; + }, 120000); + + test('should wait for string pattern in process output', async () => { + // Write a script that outputs a specific message after a delay + const scriptCode = ` console.log("Starting up..."); await Bun.sleep(500); console.log("Server ready on port 8080"); await Bun.sleep(60000); // Keep running `.trim(); - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/server.js', - content: scriptCode - }) - }); - - // Start the process - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'bun run /workspace/server.js' - }) - }); - - expect(startResponse.status).toBe(200); - const startData = (await startResponse.json()) as Process; - const processId = startData.id; + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/server.js', + content: scriptCode + }) + }); - // Wait for the log pattern - const waitResponse = await fetch( - `${workerUrl}/api/process/${processId}/waitForLog`, - { + // Start the process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { method: 'POST', headers, body: JSON.stringify({ - pattern: 'Server ready on port 8080', - timeout: 10000 + command: 'bun run /workspace/server.js' }) - } - ); + }); - expect(waitResponse.status).toBe(200); - const waitData = (await waitResponse.json()) as WaitForLogResult; - expect(waitData.line).toContain('Server ready on port 8080'); + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; - // Cleanup - await fetch(`${workerUrl}/api/process/${processId}`, { - method: 'DELETE', - headers - }); - }, 60000); + // Wait for the log pattern + const waitResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForLog`, + { + method: 'POST', + headers, + body: JSON.stringify({ + pattern: 'Server ready on port 8080', + timeout: 10000 + }) + } + ); + + expect(waitResponse.status).toBe(200); + const waitData = (await waitResponse.json()) as WaitForLogResult; + expect(waitData.line).toContain('Server ready on port 8080'); + + // Cleanup + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, 60000); - test('should wait for port to become available', async () => { - // Write a Bun server that listens on a port - const serverCode = ` + test('should wait for port to become available', async () => { + // Write a Bun server that listens on a port + const serverCode = ` const server = Bun.serve({ hostname: "0.0.0.0", port: 9090, @@ -102,65 +120,65 @@ console.log("Server started on " + server.hostname + ":" + server.port); await Bun.sleep(60000); `.trim(); - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/portserver.js', - content: serverCode - }) - }); - - // Start the process - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'bun run /workspace/portserver.js' - }) - }); - - expect(startResponse.status).toBe(200); - const startData = (await startResponse.json()) as Process; - const processId = startData.id; + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/portserver.js', + content: serverCode + }) + }); - // Wait for port 9090 to be available - const waitResponse = await fetch( - `${workerUrl}/api/process/${processId}/waitForPort`, - { + // Start the process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { method: 'POST', headers, body: JSON.stringify({ - port: 9090, - timeout: 15000 + command: 'bun run /workspace/portserver.js' }) - } - ); + }); - expect(waitResponse.status).toBe(200); + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; - // Verify the port is actually listening by trying to curl it - const verifyResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'curl -s http://localhost:9090' - }) - }); + // Wait for port 9090 to be available + const waitResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForPort`, + { + method: 'POST', + headers, + body: JSON.stringify({ + port: 9090, + timeout: 15000 + }) + } + ); - const verifyData = (await verifyResponse.json()) as { stdout: string }; - expect(verifyData.stdout).toBe('OK'); + expect(waitResponse.status).toBe(200); - // Cleanup - await fetch(`${workerUrl}/api/process/${processId}`, { - method: 'DELETE', - headers - }); - }, 60000); + // Verify the port is actually listening by trying to curl it + const verifyResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'curl -s http://localhost:9090' + }) + }); + + const verifyData = (await verifyResponse.json()) as { stdout: string }; + expect(verifyData.stdout).toBe('OK'); + + // Cleanup + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, 60000); - test('should chain waitForLog and waitForPort for multiple conditions', async () => { - // Write a script with delayed ready message and a server - const scriptCode = ` + test('should chain waitForLog and waitForPort for multiple conditions', async () => { + // Write a script with delayed ready message and a server + const scriptCode = ` console.log("Initializing..."); await Bun.sleep(500); console.log("Database connected"); @@ -174,230 +192,168 @@ console.log("Ready to serve requests"); await Bun.sleep(60000); `.trim(); - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/app.js', - content: scriptCode - }) - }); - - // Start the process - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'bun run /workspace/app.js' - }) - }); - - expect(startResponse.status).toBe(200); - const startData = (await startResponse.json()) as Process; - const processId = startData.id; - - // Wait for log pattern first - const waitLogResponse = await fetch( - `${workerUrl}/api/process/${processId}/waitForLog`, - { + await fetch(`${workerUrl}/api/file/write`, { method: 'POST', headers, body: JSON.stringify({ - pattern: 'Database connected', - timeout: 10000 + path: '/workspace/app.js', + content: scriptCode }) - } - ); - expect(waitLogResponse.status).toBe(200); + }); - // Then wait for port - const waitPortResponse = await fetch( - `${workerUrl}/api/process/${processId}/waitForPort`, - { + // Start the process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { method: 'POST', headers, body: JSON.stringify({ - port: 9091, - timeout: 10000 + command: 'bun run /workspace/app.js' }) - } - ); - expect(waitPortResponse.status).toBe(200); + }); - // Cleanup - await fetch(`${workerUrl}/api/process/${processId}`, { - method: 'DELETE', - headers - }); - }, 60000); + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; + + // Wait for log pattern first + const waitLogResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForLog`, + { + method: 'POST', + headers, + body: JSON.stringify({ + pattern: 'Database connected', + timeout: 10000 + }) + } + ); + expect(waitLogResponse.status).toBe(200); + + // Then wait for port + const waitPortResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForPort`, + { + method: 'POST', + headers, + body: JSON.stringify({ + port: 9091, + timeout: 10000 + }) + } + ); + expect(waitPortResponse.status).toBe(200); + + // Cleanup + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, 60000); - test('should fail with timeout error if pattern never appears', async () => { - // Write a script that never outputs the expected pattern - const scriptCode = ` + test('should fail with timeout error if pattern never appears', async () => { + // Write a script that never outputs the expected pattern + const scriptCode = ` console.log("Starting..."); console.log("Still starting..."); await Bun.sleep(60000); `.trim(); - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/slow.js', - content: scriptCode - }) - }); - - // Start the process - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'bun run /workspace/slow.js' - }) - }); - - expect(startResponse.status).toBe(200); - const startData = (await startResponse.json()) as Process; - const processId = startData.id; - - // Wait for pattern with short timeout - should fail - const waitResponse = await fetch( - `${workerUrl}/api/process/${processId}/waitForLog`, - { + await fetch(`${workerUrl}/api/file/write`, { method: 'POST', headers, body: JSON.stringify({ - pattern: 'Server ready', - timeout: 2000 + path: '/workspace/slow.js', + content: scriptCode }) - } - ); - - // Should fail with timeout - expect(waitResponse.status).toBe(500); - const errorData = (await waitResponse.json()) as { error: string }; - expect(errorData.error).toMatch(/timeout|did not become ready/i); - - // Cleanup - await fetch(`${workerUrl}/api/process/${processId}`, { - method: 'DELETE', - headers - }); - }, 60000); - - test('should fail with error if process exits before pattern appears', async () => { - // Start a process that exits immediately - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "quick exit"' - }) - }); - - expect(startResponse.status).toBe(200); - const startData = (await startResponse.json()) as Process; - const processId = startData.id; + }); - // Wait for pattern - should fail because process exits - const waitResponse = await fetch( - `${workerUrl}/api/process/${processId}/waitForLog`, - { + // Start the process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { method: 'POST', headers, body: JSON.stringify({ - pattern: 'Server ready', - timeout: 10000 + command: 'bun run /workspace/slow.js' }) - } - ); - - // Should fail because process exits before pattern appears - expect(waitResponse.status).toBe(500); - const errorData = (await waitResponse.json()) as { error: string }; - expect(errorData.error).toMatch( - /exited|exit|timeout|did not become ready/i - ); - }, 60000); + }); - test('should detect pattern in stderr as well as stdout', async () => { - // Write a script that outputs to stderr - const scriptCode = ` -console.error("Starting up in stderr..."); -await Bun.sleep(300); -console.error("Ready (stderr)"); -await Bun.sleep(60000); - `.trim(); + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/stderr.js', - content: scriptCode - }) - }); + // Wait for pattern with short timeout - should fail + const waitResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForLog`, + { + method: 'POST', + headers, + body: JSON.stringify({ + pattern: 'Server ready', + timeout: 2000 + }) + } + ); - // Start the process - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'bun run /workspace/stderr.js' - }) - }); + // Should fail with timeout + expect(waitResponse.status).toBe(500); + const errorData = (await waitResponse.json()) as { error: string }; + expect(errorData.error).toMatch(/timeout|did not become ready/i); - expect(startResponse.status).toBe(200); - const startData = (await startResponse.json()) as Process; - const processId = startData.id; + // Cleanup + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, 60000); - // Wait for the pattern (which appears in stderr) - const waitResponse = await fetch( - `${workerUrl}/api/process/${processId}/waitForLog`, - { + test('should fail with error if process exits before pattern appears', async () => { + // Start a process that exits immediately + const startResponse = await fetch(`${workerUrl}/api/process/start`, { method: 'POST', headers, body: JSON.stringify({ - pattern: 'Ready (stderr)', - timeout: 10000 + command: 'echo "quick exit"' }) - } - ); + }); - expect(waitResponse.status).toBe(200); - const waitData = (await waitResponse.json()) as WaitForLogResult; - expect(waitData.line).toContain('Ready (stderr)'); + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; - // Cleanup - await fetch(`${workerUrl}/api/process/${processId}`, { - method: 'DELETE', - headers - }); - }, 60000); + // Wait for pattern - should fail because process exits + const waitResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForLog`, + { + method: 'POST', + headers, + body: JSON.stringify({ + pattern: 'Server ready', + timeout: 10000 + }) + } + ); - test.skipIf(skipPortExposureTests)( - 'should start server, wait for port, and expose it', - async () => { - // Write a simple HTTP server - const serverCode = ` -const server = Bun.serve({ - port: 9092, - fetch(req) { - return new Response(JSON.stringify({ message: "Hello!" }), { - headers: { "Content-Type": "application/json" } - }); - }, -}); -console.log("Server listening on port 9092"); - `.trim(); + // Should fail because process exits before pattern appears + expect(waitResponse.status).toBe(500); + const errorData = (await waitResponse.json()) as { error: string }; + expect(errorData.error).toMatch( + /exited|exit|timeout|did not become ready/i + ); + }, 60000); + + test('should detect pattern in stderr as well as stdout', async () => { + // Write a script that outputs to stderr + const scriptCode = ` +console.error("Starting up in stderr..."); +await Bun.sleep(300); +console.error("Ready (stderr)"); +await Bun.sleep(60000); + `.trim(); await fetch(`${workerUrl}/api/file/write`, { method: 'POST', headers, body: JSON.stringify({ - path: '/workspace/http-server.js', - content: serverCode + path: '/workspace/stderr.js', + content: scriptCode }) }); @@ -406,7 +362,7 @@ console.log("Server listening on port 9092"); method: 'POST', headers, body: JSON.stringify({ - command: 'bun run /workspace/http-server.js' + command: 'bun run /workspace/stderr.js' }) }); @@ -414,49 +370,112 @@ console.log("Server listening on port 9092"); const startData = (await startResponse.json()) as Process; const processId = startData.id; - // Wait for port - const waitPortResponse = await fetch( - `${workerUrl}/api/process/${processId}/waitForPort`, + // Wait for the pattern (which appears in stderr) + const waitResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForLog`, { method: 'POST', headers, body: JSON.stringify({ - port: 9092, - timeout: 30000 + pattern: 'Ready (stderr)', + timeout: 10000 }) } ); - expect(waitPortResponse.status).toBe(200); - - // Expose the port - const exposeResponse = await fetch(`${workerUrl}/api/port/expose`, { - method: 'POST', - headers: portHeaders, - body: JSON.stringify({ - port: 9092 - }) - }); - - expect(exposeResponse.status).toBe(200); - const exposeData = (await exposeResponse.json()) as PortExposeResult; - expect(exposeData.url).toBeTruthy(); - // Make a request to the exposed URL - const apiResponse = await fetch(exposeData.url); - expect(apiResponse.status).toBe(200); - const apiData = (await apiResponse.json()) as { message: string }; - expect(apiData.message).toBe('Hello!'); + expect(waitResponse.status).toBe(200); + const waitData = (await waitResponse.json()) as WaitForLogResult; + expect(waitData.line).toContain('Ready (stderr)'); // Cleanup - await fetch(`${workerUrl}/api/exposed-ports/9092`, { - method: 'DELETE', - headers: portHeaders - }); await fetch(`${workerUrl}/api/process/${processId}`, { method: 'DELETE', headers }); - }, - 90000 - ); + }, 60000); + + test.skipIf(skipPortExposureTests)( + 'should start server, wait for port, and expose it', + async () => { + // Write a simple HTTP server + const serverCode = ` +const server = Bun.serve({ + port: 9092, + fetch(req) { + return new Response(JSON.stringify({ message: "Hello!" }), { + headers: { "Content-Type": "application/json" } + }); + }, }); +console.log("Server listening on port 9092"); + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/http-server.js', + content: serverCode + }) + }); + + // Start the process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'bun run /workspace/http-server.js' + }) + }); + + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; + + // Wait for port + const waitPortResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForPort`, + { + method: 'POST', + headers, + body: JSON.stringify({ + port: 9092, + timeout: 30000 + }) + } + ); + expect(waitPortResponse.status).toBe(200); + + // Expose the port + const exposeResponse = await fetch(`${workerUrl}/api/port/expose`, { + method: 'POST', + headers: portHeaders, + body: JSON.stringify({ + port: 9092 + }) + }); + + expect(exposeResponse.status).toBe(200); + const exposeData = (await exposeResponse.json()) as PortExposeResult; + expect(exposeData.url).toBeTruthy(); + + // Make a request to the exposed URL + const apiResponse = await fetch(exposeData.url); + expect(apiResponse.status).toBe(200); + const apiData = (await apiResponse.json()) as { message: string }; + expect(apiData.message).toBe('Hello!'); + + // Cleanup + await fetch(`${workerUrl}/api/exposed-ports/9092`, { + method: 'DELETE', + headers: portHeaders + }); + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, + 90000 + ); + } +); diff --git a/tests/e2e/streaming-operations-workflow.test.ts b/tests/e2e/streaming-operations-workflow.test.ts index edbab45c..0cfcb81b 100644 --- a/tests/e2e/streaming-operations-workflow.test.ts +++ b/tests/e2e/streaming-operations-workflow.test.ts @@ -18,181 +18,199 @@ import type { ExecEvent } from '@repo/shared'; * - Chunked output delivery over time * - File content streaming */ -describe('Streaming Operations Edge Cases', () => { - let workerUrl: string; - let headers: Record; - - beforeAll(async () => { - const sandbox = await getSharedSandbox(); - workerUrl = sandbox.workerUrl; - headers = sandbox.createHeaders(createUniqueSession()); - }, 120000); - - async function collectSSEEvents( - response: Response, - maxEvents: number = 50 - ): Promise { - if (!response.body) { - throw new Error('No readable stream in response'); - } - const events: ExecEvent[] = []; - const abortController = new AbortController(); - - try { - for await (const event of parseSSEStream( - response.body, - abortController.signal - )) { - events.push(event); - if (event.type === 'complete' || event.type === 'error') { - abortController.abort(); - break; +// Transport modes to test +const transportModes = [ + { name: 'HTTP', useWebSocket: false }, + { name: 'WebSocket', useWebSocket: true } +]; + +describe.each(transportModes)( + 'Streaming Operations Edge Cases ($name transport)', + ({ useWebSocket }) => { + let workerUrl: string; + let headers: Record; + + beforeAll(async () => { + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + const baseHeaders = sandbox.createHeaders(createUniqueSession()); + headers = useWebSocket + ? { ...baseHeaders, 'X-Use-WebSocket': 'true' } + : baseHeaders; + }, 120000); + + async function collectSSEEvents( + response: Response, + maxEvents: number = 50 + ): Promise { + if (!response.body) { + throw new Error('No readable stream in response'); + } + + const events: ExecEvent[] = []; + const abortController = new AbortController(); + + try { + for await (const event of parseSSEStream( + response.body, + abortController.signal + )) { + events.push(event); + if (event.type === 'complete' || event.type === 'error') { + abortController.abort(); + break; + } + if (events.length >= maxEvents) { + abortController.abort(); + break; + } } - if (events.length >= maxEvents) { - abortController.abort(); - break; + } catch (error) { + if ( + error instanceof Error && + error.message !== 'Operation was aborted' + ) { + throw error; } } - } catch (error) { - if (error instanceof Error && error.message !== 'Operation was aborted') { - throw error; - } - } - return events; - } - - test('should handle command failures with non-zero exit code', async () => { - const streamResponse = await fetch(`${workerUrl}/api/execStream`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'false' // Always fails with exit code 1 - }) - }); - - const events = await collectSSEEvents(streamResponse); - - const completeEvent = events.find((e) => e.type === 'complete'); - expect(completeEvent).toBeDefined(); - expect(completeEvent?.exitCode).not.toBe(0); - }, 90000); - - test('should handle nonexistent commands with proper exit code', async () => { - const streamResponse = await fetch(`${workerUrl}/api/execStream`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'nonexistentcommand123' - }) - }); - - expect(streamResponse.status).toBe(200); - - const events = await collectSSEEvents(streamResponse); - - const completeEvent = events.find((e) => e.type === 'complete'); - expect(completeEvent).toBeDefined(); - expect(completeEvent?.exitCode).toBe(127); // Command not found - - const stderrEvents = events.filter((e) => e.type === 'stderr'); - expect(stderrEvents.length).toBeGreaterThan(0); - const stderrData = stderrEvents.map((e) => e.data).join(''); - expect(stderrData.toLowerCase()).toMatch(/command not found|not found/); - }, 90000); - - test('should handle streaming with multiple output chunks over time', async () => { - // Tests that streaming correctly delivers output over ~2 seconds - const streamResponse = await fetch(`${workerUrl}/api/execStream`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: - 'bash -c \'for i in 1 2 3; do echo "Chunk $i"; sleep 0.5; done; echo "DONE"\'' - }) - }); - - expect(streamResponse.status).toBe(200); - - const startTime = Date.now(); - const events = await collectSSEEvents(streamResponse, 20); - const duration = Date.now() - startTime; - - // Should take ~1.5s (3 × 0.5s sleeps) - expect(duration).toBeGreaterThan(1000); - expect(duration).toBeLessThan(10000); - - const stdoutEvents = events.filter((e) => e.type === 'stdout'); - const output = stdoutEvents.map((e) => e.data).join(''); - - expect(output).toContain('Chunk 1'); - expect(output).toContain('Chunk 2'); - expect(output).toContain('Chunk 3'); - expect(output).toContain('DONE'); - - const completeEvent = events.find((e) => e.type === 'complete'); - expect(completeEvent).toBeDefined(); - expect(completeEvent?.exitCode).toBe(0); - }, 15000); - - test('should stream file contents', async () => { - // Create a test file first - const testPath = `/workspace/stream-test-${Date.now()}.txt`; - const testContent = - 'Line 1\nLine 2\nLine 3\nThis is streaming file content.'; - - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ path: testPath, content: testContent }) - }); - - // Stream the file back - const streamResponse = await fetch(`${workerUrl}/api/read/stream`, { - method: 'POST', - headers, - body: JSON.stringify({ path: testPath }) - }); - - expect(streamResponse.status).toBe(200); - expect(streamResponse.headers.get('Content-Type')).toBe( - 'text/event-stream' - ); - - // Collect streamed content - const reader = streamResponse.body?.getReader(); - expect(reader).toBeDefined(); - - const decoder = new TextDecoder(); - let rawContent = ''; - while (true) { - const { done, value } = await reader!.read(); - if (done) break; - rawContent += decoder.decode(value, { stream: true }); + return events; } - // Parse SSE JSON events - const lines = rawContent.split('\n').filter((l) => l.startsWith('data: ')); - const events = lines.map((l) => JSON.parse(l.slice(6))); - - // Should have metadata, chunk(s), and complete events - const metadata = events.find((e) => e.type === 'metadata'); - const chunk = events.find((e) => e.type === 'chunk'); - const complete = events.find((e) => e.type === 'complete'); - - expect(metadata).toBeDefined(); - expect(metadata.mimeType).toBe('text/plain'); - expect(chunk).toBeDefined(); - expect(chunk.data).toBe(testContent); - expect(complete).toBeDefined(); - expect(complete.bytesRead).toBe(testContent.length); - - // Cleanup - await fetch(`${workerUrl}/api/file/delete`, { - method: 'POST', - headers, - body: JSON.stringify({ path: testPath }) - }); - }, 30000); -}); + test('should handle command failures with non-zero exit code', async () => { + const streamResponse = await fetch(`${workerUrl}/api/execStream`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'false' // Always fails with exit code 1 + }) + }); + + const events = await collectSSEEvents(streamResponse); + + const completeEvent = events.find((e) => e.type === 'complete'); + expect(completeEvent).toBeDefined(); + expect(completeEvent?.exitCode).not.toBe(0); + }, 90000); + + test('should handle nonexistent commands with proper exit code', async () => { + const streamResponse = await fetch(`${workerUrl}/api/execStream`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'nonexistentcommand123' + }) + }); + + expect(streamResponse.status).toBe(200); + + const events = await collectSSEEvents(streamResponse); + + const completeEvent = events.find((e) => e.type === 'complete'); + expect(completeEvent).toBeDefined(); + expect(completeEvent?.exitCode).toBe(127); // Command not found + + const stderrEvents = events.filter((e) => e.type === 'stderr'); + expect(stderrEvents.length).toBeGreaterThan(0); + const stderrData = stderrEvents.map((e) => e.data).join(''); + expect(stderrData.toLowerCase()).toMatch(/command not found|not found/); + }, 90000); + + test('should handle streaming with multiple output chunks over time', async () => { + // Tests that streaming correctly delivers output over ~2 seconds + const streamResponse = await fetch(`${workerUrl}/api/execStream`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: + 'bash -c \'for i in 1 2 3; do echo "Chunk $i"; sleep 0.5; done; echo "DONE"\'' + }) + }); + + expect(streamResponse.status).toBe(200); + + const startTime = Date.now(); + const events = await collectSSEEvents(streamResponse, 20); + const duration = Date.now() - startTime; + + // Should take ~1.5s (3 × 0.5s sleeps) + expect(duration).toBeGreaterThan(1000); + expect(duration).toBeLessThan(10000); + + const stdoutEvents = events.filter((e) => e.type === 'stdout'); + const output = stdoutEvents.map((e) => e.data).join(''); + + expect(output).toContain('Chunk 1'); + expect(output).toContain('Chunk 2'); + expect(output).toContain('Chunk 3'); + expect(output).toContain('DONE'); + + const completeEvent = events.find((e) => e.type === 'complete'); + expect(completeEvent).toBeDefined(); + expect(completeEvent?.exitCode).toBe(0); + }, 15000); + + test('should stream file contents', async () => { + // Create a test file first + const testPath = `/workspace/stream-test-${Date.now()}.txt`; + const testContent = + 'Line 1\nLine 2\nLine 3\nThis is streaming file content.'; + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ path: testPath, content: testContent }) + }); + + // Stream the file back + const streamResponse = await fetch(`${workerUrl}/api/read/stream`, { + method: 'POST', + headers, + body: JSON.stringify({ path: testPath }) + }); + + expect(streamResponse.status).toBe(200); + expect(streamResponse.headers.get('Content-Type')).toBe( + 'text/event-stream' + ); + + // Collect streamed content + const reader = streamResponse.body?.getReader(); + expect(reader).toBeDefined(); + + const decoder = new TextDecoder(); + let rawContent = ''; + while (true) { + const { done, value } = await reader!.read(); + if (done) break; + rawContent += decoder.decode(value, { stream: true }); + } + + // Parse SSE JSON events + const lines = rawContent + .split('\n') + .filter((l) => l.startsWith('data: ')); + const events = lines.map((l) => JSON.parse(l.slice(6))); + + // Should have metadata, chunk(s), and complete events + const metadata = events.find((e) => e.type === 'metadata'); + const chunk = events.find((e) => e.type === 'chunk'); + const complete = events.find((e) => e.type === 'complete'); + + expect(metadata).toBeDefined(); + expect(metadata.mimeType).toBe('text/plain'); + expect(chunk).toBeDefined(); + expect(chunk.data).toBe(testContent); + expect(complete).toBeDefined(); + expect(complete.bytesRead).toBe(testContent.length); + + // Cleanup + await fetch(`${workerUrl}/api/file/delete`, { + method: 'POST', + headers, + body: JSON.stringify({ path: testPath }) + }); + }, 30000); + } +); diff --git a/tests/e2e/test-worker/index.ts b/tests/e2e/test-worker/index.ts index d8f5b776..3ce4cb42 100644 --- a/tests/e2e/test-worker/index.ts +++ b/tests/e2e/test-worker/index.ts @@ -70,8 +70,13 @@ export default { const sandboxType = request.headers.get('X-Sandbox-Type'); const sandboxNamespace = sandboxType === 'python' ? env.SandboxPython : env.Sandbox; + + // Check if WebSocket transport is requested + const useWebSocket = request.headers.get('X-Use-WebSocket') === 'true'; + const sandbox = getSandbox(sandboxNamespace, sandboxId, { - keepAlive + keepAlive, + useWebSocket }); // Get session ID from header (optional)