diff --git a/packages/sandbox-container/src/core/container.ts b/packages/sandbox-container/src/core/container.ts index 7ab93ee13..1144de958 100644 --- a/packages/sandbox-container/src/core/container.ts +++ b/packages/sandbox-container/src/core/container.ts @@ -1,19 +1,6 @@ import type { Logger } from '@repo/shared'; import { createLogger, GitLogger } from '@repo/shared'; -import { BackupHandler } from '../handlers/backup-handler'; -import { DesktopHandler } from '../handlers/desktop-handler'; -import { ExecuteHandler } from '../handlers/execute-handler'; -import { FileHandler } from '../handlers/file-handler'; -import { GitHandler } from '../handlers/git-handler'; -import { InterpreterHandler } from '../handlers/interpreter-handler'; -import { MiscHandler } from '../handlers/misc-handler'; -import { PortHandler } from '../handlers/port-handler'; -import { ProcessHandler } from '../handlers/process-handler'; import { PtyWebSocketHandler } from '../handlers/pty-ws-handler'; -import { SessionHandler } from '../handlers/session-handler'; -import { WatchHandler } from '../handlers/watch-handler'; -import { CorsMiddleware } from '../middleware/cors'; -import { LoggingMiddleware } from '../middleware/logging'; import { SecurityServiceAdapter } from '../security/security-adapter'; import { SecurityService } from '../security/security-service'; import { BackupService } from '../services/backup-service'; @@ -37,29 +24,14 @@ export interface Dependencies { backupService: BackupService; desktopService: DesktopService; watchService: WatchService; + sessionManager: SessionManager; // Infrastructure logger: Logger; security: SecurityService; - sessionManager: SessionManager; - // Handlers - backupHandler: BackupHandler; - executeHandler: ExecuteHandler; - fileHandler: FileHandler; - processHandler: ProcessHandler; - portHandler: PortHandler; - gitHandler: GitHandler; - interpreterHandler: InterpreterHandler; - sessionHandler: SessionHandler; - miscHandler: MiscHandler; - desktopHandler: DesktopHandler; + // PTY handler (WebSocket-based, not part of the RPC layer) ptyWsHandler: PtyWebSocketHandler; - watchHandler: WatchHandler; - - // Middleware - corsMiddleware: CorsMiddleware; - loggingMiddleware: LoggingMiddleware; } export class Container { @@ -78,7 +50,6 @@ export class Container { ); } - // Safe cast because we know the container is initialized and dependency exists return dependency as Dependencies[T]; } @@ -94,22 +65,15 @@ export class Container { return; } - // Initialize infrastructure const logger = createLogger({ component: 'container' }); const security = new SecurityService(logger); const securityAdapter = new SecurityServiceAdapter(security); - // Initialize stores const processStore = new ProcessStore(logger); const portStore = new InMemoryPortStore(); - - // Initialize SessionManager const sessionManager = new SessionManager(logger); - - // Create git-specific logger that automatically sanitizes credentials const gitLogger = new GitLogger(logger); - // Initialize services const processService = new ProcessService( processStore, logger, @@ -130,31 +94,9 @@ export class Container { const backupService = new BackupService(logger, sessionManager); const desktopService = new DesktopService(logger); const watchService = new WatchService(logger); - - // Initialize handlers - const backupHandler = new BackupHandler(backupService, logger); - const sessionHandler = new SessionHandler(sessionManager, logger); - const executeHandler = new ExecuteHandler(processService, logger); - const fileHandler = new FileHandler(fileService, logger); - const processHandler = new ProcessHandler(processService, logger); - const portHandler = new PortHandler(portService, processService, logger); - const gitHandler = new GitHandler(gitService, gitLogger); - const interpreterHandler = new InterpreterHandler( - interpreterService, - logger - ); - const miscHandler = new MiscHandler(logger); - const desktopHandler = new DesktopHandler(desktopService, logger); const ptyWsHandler = new PtyWebSocketHandler(sessionManager, logger); - const watchHandler = new WatchHandler(watchService, logger); - - // Initialize middleware - const corsMiddleware = new CorsMiddleware(); - const loggingMiddleware = new LoggingMiddleware(logger); - // Store all dependencies this.dependencies = { - // Services processService, fileService, portService, @@ -163,29 +105,10 @@ export class Container { backupService, desktopService, watchService, - - // Infrastructure + sessionManager, logger, security, - sessionManager, - - // Handlers - backupHandler, - executeHandler, - fileHandler, - processHandler, - portHandler, - gitHandler, - interpreterHandler, - sessionHandler, - miscHandler, - desktopHandler, - ptyWsHandler, - watchHandler, - - // Middleware - corsMiddleware, - loggingMiddleware + ptyWsHandler }; this.initialized = true; diff --git a/packages/sandbox-container/src/core/router.ts b/packages/sandbox-container/src/core/router.ts deleted file mode 100644 index c5ea747a6..000000000 --- a/packages/sandbox-container/src/core/router.ts +++ /dev/null @@ -1,264 +0,0 @@ -// Centralized Router for handling HTTP requests - -import type { Logger } from '@repo/shared'; -import type { ErrorResponse } from '@repo/shared/errors'; -import { ErrorCode } from '@repo/shared/errors'; -import type { - HttpMethod, - Middleware, - NextFunction, - RequestContext, - RequestHandler, - RouteDefinition -} from './types'; - -export class Router { - private routes: RouteDefinition[] = []; - private globalMiddleware: Middleware[] = []; - private logger: Logger; - - constructor(logger: Logger) { - this.logger = logger; - } - - /** - * Register a route with optional middleware - */ - register(definition: RouteDefinition): void { - this.routes.push(definition); - } - - /** - * Add global middleware that runs for all routes - */ - use(middleware: Middleware): void { - this.globalMiddleware.push(middleware); - } - - private validateHttpMethod(method: string): HttpMethod { - const validMethods: HttpMethod[] = [ - 'GET', - 'POST', - 'PUT', - 'DELETE', - 'OPTIONS' - ]; - if (validMethods.includes(method as HttpMethod)) { - return method as HttpMethod; - } - throw new Error(`Unsupported HTTP method: ${method}`); - } - - /** - * Route an incoming request to the appropriate handler - */ - async route(request: Request): Promise { - const method = this.validateHttpMethod(request.method); - const pathname = new URL(request.url).pathname; - - // Find matching route - const route = this.matchRoute(method, pathname); - - if (!route) { - this.logger.debug('No route found', { method, pathname }); - return this.createNotFoundResponse(); - } - - // Create request context - const context: RequestContext = { - sessionId: this.extractSessionId(request), - sandboxId: request.headers.get('X-Sandbox-Id') ?? undefined, - corsHeaders: this.getCorsHeaders(), - requestId: this.generateRequestId(), - timestamp: new Date() - }; - - try { - // Build middleware chain (global + route-specific) - const middlewareChain = [ - ...this.globalMiddleware, - ...(route.middleware || []) - ]; - - // Execute middleware chain - return await this.executeMiddlewareChain( - middlewareChain, - request, - context, - route.handler - ); - } catch (error) { - this.logger.error('Error handling request', error as Error, { - method, - pathname, - requestId: context.requestId - }); - return this.createErrorResponse( - error instanceof Error ? error : new Error('Unknown error') - ); - } - } - - /** - * Match a route based on method and path - */ - private matchRoute(method: HttpMethod, path: string): RouteDefinition | null { - for (const route of this.routes) { - if (route.method === method && this.pathMatches(route.path, path)) { - return route; - } - } - return null; - } - - /** - * Check if a route path matches the request path - * Supports basic dynamic routes like /api/process/{id} - */ - private pathMatches(routePath: string, requestPath: string): boolean { - // Exact match - if (routePath === requestPath) { - return true; - } - - // Dynamic route matching - const routeSegments = routePath.split('/'); - const requestSegments = requestPath.split('/'); - - if (routeSegments.length !== requestSegments.length) { - return false; - } - - return routeSegments.every((segment, index) => { - // Dynamic segment (starts with {) - if (segment.startsWith('{') && segment.endsWith('}')) { - return true; - } - // Exact match required - return segment === requestSegments[index]; - }); - } - - /** - * Execute middleware chain with proper next() handling - */ - private async executeMiddlewareChain( - middlewareChain: Middleware[], - request: Request, - context: RequestContext, - finalHandler: RequestHandler - ): Promise { - let currentIndex = 0; - - const next: NextFunction = async (): Promise => { - // If we've reached the end of middleware, call the final handler - if (currentIndex >= middlewareChain.length) { - return await finalHandler(request, context); - } - - // Get the current middleware and increment index - const middleware = middlewareChain[currentIndex]; - currentIndex++; - - // Execute middleware with next function - return await middleware.handle(request, context, next); - }; - - return await next(); - } - - /** - * Extract session ID from request headers or body - */ - private extractSessionId(request: Request): string | undefined { - // Try to get from Authorization header - const authHeader = request.headers.get('Authorization'); - if (authHeader?.startsWith('Bearer ')) { - return authHeader.substring(7); - } - - // Try to get from X-Session-Id header - const sessionHeader = request.headers.get('X-Session-Id'); - if (sessionHeader) { - return sessionHeader; - } - - // Try query params ?session= or ?sessionId= - try { - const url = new URL(request.url); - const qp = - url.searchParams.get('session') || url.searchParams.get('sessionId'); - if (qp) { - return qp; - } - } catch { - // ignore URL parsing errors - } - - // Will be extracted from request body in individual handlers if needed - return undefined; - } - - /** - * Get CORS headers - */ - private getCorsHeaders(): Record { - return { - 'Access-Control-Allow-Headers': - 'Content-Type, Authorization, X-Session-Id, X-Sandbox-Id, X-Trace-Id', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Origin': '*' - }; - } - - /** - * Generate a unique request ID for tracing - */ - private generateRequestId(): string { - return `req_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; - } - - /** - * Create a 404 Not Found response - */ - private createNotFoundResponse(): Response { - const errorResponse: ErrorResponse = { - code: ErrorCode.UNKNOWN_ERROR, - message: 'The requested endpoint was not found', - context: {}, - httpStatus: 404, - timestamp: new Date().toISOString() - }; - - return new Response(JSON.stringify(errorResponse), { - status: 404, - headers: { - 'Content-Type': 'application/json', - ...this.getCorsHeaders() - } - }); - } - - /** - * Create an error response - */ - private createErrorResponse(error: Error): Response { - const errorResponse: ErrorResponse = { - code: ErrorCode.INTERNAL_ERROR, - message: error.message, - context: { - stack: error.stack - }, - httpStatus: 500, - timestamp: new Date().toISOString() - }; - - return new Response(JSON.stringify(errorResponse), { - status: 500, - headers: { - 'Content-Type': 'application/json', - ...this.getCorsHeaders() - } - }); - } -} diff --git a/packages/sandbox-container/src/handlers/backup-handler.ts b/packages/sandbox-container/src/handlers/backup-handler.ts deleted file mode 100644 index 214f099d1..000000000 --- a/packages/sandbox-container/src/handlers/backup-handler.ts +++ /dev/null @@ -1,224 +0,0 @@ -import type { - CreateBackupRequest, - CreateBackupResponse, - Logger, - RestoreBackupRequest, - RestoreBackupResponse -} from '@repo/shared'; -import { ErrorCode, Operation } from '@repo/shared/errors'; - -import type { RequestContext } from '../core/types'; -import type { BackupService } from '../services/backup-service'; -import { BACKUP_WORK_DIR } from '../services/backup-service'; -import { BaseHandler } from './base-handler'; - -type CreateBackupRequestBody = CreateBackupRequest; - -export class BackupHandler extends BaseHandler { - constructor( - private backupService: BackupService, - logger: Logger - ) { - super(logger); - } - - async handle(request: Request, context: RequestContext): Promise { - const url = new URL(request.url); - const pathname = url.pathname; - - switch (pathname) { - case '/api/backup/create': - return await this.handleCreate(request, context); - case '/api/backup/restore': - return await this.handleRestore(request, context); - default: - return this.createErrorResponse( - { - message: 'Invalid backup endpoint', - code: ErrorCode.UNKNOWN_ERROR - }, - context - ); - } - } - - /** Maximum path length (matches Linux PATH_MAX) to prevent DoS via oversized strings */ - private static readonly MAX_PATH_LENGTH = 4096; - - /** - * Validate directory path for safety (defense-in-depth). - * Returns error message if invalid, undefined if valid. - */ - private static validateDirPath(dir: string): string | undefined { - if (!dir || typeof dir !== 'string') { - return 'Missing or invalid field: dir'; - } - if (dir.length > BackupHandler.MAX_PATH_LENGTH) { - return 'dir path exceeds maximum length'; - } - if (!dir.startsWith('/')) { - return 'dir must be an absolute path'; - } - if (dir.includes('..')) { - return 'dir must not contain path traversal sequences'; - } - if (dir.includes('\0')) { - return 'dir must not contain null bytes'; - } - return undefined; - } - - /** - * Validate archive path for safety. - * Archives must be in the designated backup directory and contain no traversal. - */ - private static validateArchivePath(archivePath: string): string | undefined { - if (!archivePath || typeof archivePath !== 'string') { - return 'Missing or invalid field: archivePath'; - } - if (archivePath.length > BackupHandler.MAX_PATH_LENGTH) { - return 'archivePath exceeds maximum length'; - } - if (archivePath.includes('..')) { - return 'archivePath must not contain path traversal sequences'; - } - if (!archivePath.startsWith(`${BACKUP_WORK_DIR}/`)) { - return 'Invalid archivePath: must use designated backup directory'; - } - return undefined; - } - - private async handleCreate( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - - const dirError = BackupHandler.validateDirPath(body.dir); - if (dirError) { - return this.createErrorResponse( - { - message: dirError, - code: ErrorCode.INVALID_BACKUP_CONFIG - }, - context, - Operation.BACKUP_CREATE - ); - } - const archiveError = BackupHandler.validateArchivePath(body.archivePath); - if (archiveError) { - return this.createErrorResponse( - { - message: archiveError, - code: ErrorCode.INVALID_BACKUP_CONFIG - }, - context, - Operation.BACKUP_CREATE - ); - } - - if (body.gitignore !== undefined && typeof body.gitignore !== 'boolean') { - return this.createErrorResponse( - { - message: 'gitignore must be a boolean', - code: ErrorCode.INVALID_BACKUP_CONFIG - }, - context, - Operation.BACKUP_CREATE - ); - } - - if (body.excludes !== undefined) { - if ( - !Array.isArray(body.excludes) || - !body.excludes.every((e) => typeof e === 'string') - ) { - return this.createErrorResponse( - { - message: 'excludes must be an array of strings', - code: ErrorCode.INVALID_BACKUP_CONFIG - }, - context, - Operation.BACKUP_CREATE - ); - } - } - - const sessionId = body.sessionId ?? context.sessionId ?? 'default'; - - const result = await this.backupService.createArchive( - body.dir, - body.archivePath, - sessionId, - body.gitignore ?? false, - body.excludes ?? [] - ); - - if (result.success) { - const response: CreateBackupResponse = { - success: true, - sizeBytes: result.data.sizeBytes, - archivePath: result.data.archivePath - }; - return this.createTypedResponse(response, context); - } - - return this.createErrorResponse( - result.error, - context, - Operation.BACKUP_CREATE - ); - } - - private async handleRestore( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - - const dirError = BackupHandler.validateDirPath(body.dir); - if (dirError) { - return this.createErrorResponse( - { - message: dirError, - code: ErrorCode.INVALID_BACKUP_CONFIG - }, - context, - Operation.BACKUP_RESTORE - ); - } - const archiveError = BackupHandler.validateArchivePath(body.archivePath); - if (archiveError) { - return this.createErrorResponse( - { - message: archiveError, - code: ErrorCode.INVALID_BACKUP_CONFIG - }, - context, - Operation.BACKUP_RESTORE - ); - } - - const sessionId = body.sessionId ?? context.sessionId ?? 'default'; - - const result = await this.backupService.restoreArchive( - body.dir, - body.archivePath, - sessionId - ); - - if (result.success) { - const response: RestoreBackupResponse = { - success: true, - dir: result.data.dir - }; - return this.createTypedResponse(response, context); - } - - return this.createErrorResponse( - result.error, - context, - Operation.BACKUP_RESTORE - ); - } -} diff --git a/packages/sandbox-container/src/handlers/base-handler.ts b/packages/sandbox-container/src/handlers/base-handler.ts deleted file mode 100644 index fa394e563..000000000 --- a/packages/sandbox-container/src/handlers/base-handler.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { Logger } from '@repo/shared'; -import { TraceContext } from '@repo/shared'; -import { - type ErrorCode, - type ErrorResponse, - getHttpStatus, - getSuggestion, - type OperationType -} from '@repo/shared/errors'; -import type { Handler, RequestContext, ServiceError } from '../core/types'; - -export abstract class BaseHandler - implements Handler -{ - constructor(protected logger: Logger) {} - - abstract handle( - request: TRequest, - context: RequestContext - ): Promise; - - /** - * Create HTTP response from typed response data - * Type parameter ensures response matches a valid result interface from @repo/shared - */ - protected createTypedResponse( - responseData: T, - context: RequestContext, - statusCode: number = 200 - ): Response { - return new Response(JSON.stringify(responseData), { - status: statusCode, - headers: { - 'Content-Type': 'application/json', - ...context.corsHeaders - } - }); - } - - /** - * Create HTTP response from ServiceError - * Enriches error with HTTP status, suggestions, etc. - */ - protected createErrorResponse( - serviceError: ServiceError, - context: RequestContext, - operation?: OperationType - ): Response { - const errorResponse = this.enrichServiceError(serviceError, operation); - return new Response(JSON.stringify(errorResponse), { - status: errorResponse.httpStatus, - headers: { - 'Content-Type': 'application/json', - ...context.corsHeaders - } - }); - } - - /** - * Enrich lightweight ServiceError into full ErrorResponse - * Adds HTTP status, timestamp, suggestions, operation, etc. - */ - protected enrichServiceError( - serviceError: ServiceError, - operation?: OperationType - ): ErrorResponse { - const errorCode = serviceError.code as ErrorCode; - return { - code: errorCode, - message: serviceError.message, - context: serviceError.details || {}, - operation: - operation || - (serviceError.details?.operation as OperationType | undefined), - httpStatus: getHttpStatus(errorCode), - timestamp: new Date().toISOString(), - suggestion: getSuggestion(errorCode, serviceError.details || {}) - }; - } - - /** - * Parse and return request body as JSON - * Throws if body is missing or invalid JSON - */ - protected async parseRequestBody(request: Request): Promise { - try { - const body = await request.json(); - return body as T; - } catch (error) { - throw new Error( - `Failed to parse request body: ${ - error instanceof Error ? error.message : 'Invalid JSON' - }` - ); - } - } - - protected extractPathParam(pathname: string, position: number): string { - const segments = pathname.split('/'); - return segments[position] || ''; - } - - protected extractQueryParam(request: Request, param: string): string | null { - const url = new URL(request.url); - return url.searchParams.get(param); - } - - /** - * Extract traceId from request headers - * Returns the traceId if present, null otherwise - */ - protected extractTraceId(request: Request): string | null { - return TraceContext.fromHeaders(request.headers); - } - - /** - * Create a child logger with trace context for this request - * Includes traceId if available in request headers - */ - protected createRequestLogger(request: Request, operation?: string): Logger { - const traceId = this.extractTraceId(request); - const context: Record = {}; - - if (traceId) { - context.traceId = traceId; - } - if (operation) { - context.operation = operation; - } - - return context.traceId || context.operation - ? this.logger.child(context) - : this.logger; - } -} diff --git a/packages/sandbox-container/src/handlers/desktop-handler.ts b/packages/sandbox-container/src/handlers/desktop-handler.ts deleted file mode 100644 index c9af82e60..000000000 --- a/packages/sandbox-container/src/handlers/desktop-handler.ts +++ /dev/null @@ -1,265 +0,0 @@ -import type { - DesktopKeyPressRequest, - DesktopMouseClickRequest, - DesktopMouseDownRequest, - DesktopMouseDragRequest, - DesktopMouseMoveRequest, - DesktopMouseScrollRequest, - DesktopMouseUpRequest, - DesktopScreenshotRegionRequest, - DesktopScreenshotRequest, - DesktopTypeRequest, - Logger -} from '@repo/shared'; -import { ErrorCode } from '@repo/shared/errors'; -import type { RequestContext } from '../core/types'; -import type { DesktopService } from '../services/desktop-service'; -import { BaseHandler } from './base-handler'; - -export class DesktopHandler extends BaseHandler { - constructor( - private desktopService: DesktopService, - logger: Logger - ) { - super(logger); - } - - async handle(request: Request, context: RequestContext): Promise { - const url = new URL(request.url); - const pathname = url.pathname; - - switch (pathname) { - case '/api/desktop/start': - return this.handleStart(request, context); - case '/api/desktop/stop': - return this.handleStop(request, context); - case '/api/desktop/status': - return this.handleStatus(request, context); - case '/api/desktop/screenshot': - return this.handleScreenshot(request, context); - case '/api/desktop/screenshot/region': - return this.handleScreenshotRegion(request, context); - case '/api/desktop/mouse/click': - return this.handleMouseClick(request, context); - case '/api/desktop/mouse/move': - return this.handleMouseMove(request, context); - case '/api/desktop/mouse/down': - return this.handleMouseDown(request, context); - case '/api/desktop/mouse/up': - return this.handleMouseUp(request, context); - case '/api/desktop/mouse/drag': - return this.handleMouseDrag(request, context); - case '/api/desktop/mouse/scroll': - return this.handleMouseScroll(request, context); - case '/api/desktop/mouse/position': - return this.handleCursorPosition(request, context); - case '/api/desktop/keyboard/type': - return this.handleKeyboardType(request, context); - case '/api/desktop/keyboard/press': - return this.handleKeyboardPress(request, context); - case '/api/desktop/keyboard/down': - return this.handleKeyboardDown(request, context); - case '/api/desktop/keyboard/up': - return this.handleKeyboardUp(request, context); - case '/api/desktop/screen/size': - return this.handleScreenSize(request, context); - default: - if ( - pathname.startsWith('/api/desktop/process/') && - pathname.endsWith('/status') - ) { - return this.handleProcessStatus(request, context); - } - return this.createErrorResponse( - { - message: 'Invalid desktop endpoint', - code: ErrorCode.UNKNOWN_ERROR - }, - context - ); - } - } - - private async handleStart( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody<{ - resolution?: [number, number]; - dpi?: number; - }>(request); - const result = await this.desktopService.start(body); - if (!result.success) return this.createErrorResponse(result.error, context); - return this.createTypedResponse(result.data, context); - } - - private async handleStop( - _request: Request, - context: RequestContext - ): Promise { - const result = await this.desktopService.stop(); - if (!result.success) return this.createErrorResponse(result.error, context); - return this.createTypedResponse(result.data, context); - } - - private async handleStatus( - _request: Request, - context: RequestContext - ): Promise { - const result = await this.desktopService.status(); - if (!result.success) return this.createErrorResponse(result.error, context); - return this.createTypedResponse({ success: true, ...result.data }, context); - } - - private async handleScreenshot( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - const result = await this.desktopService.screenshot(body); - if (!result.success) return this.createErrorResponse(result.error, context); - return this.createTypedResponse({ success: true, ...result.data }, context); - } - - private async handleScreenshotRegion( - request: Request, - context: RequestContext - ): Promise { - const body = - await this.parseRequestBody(request); - const result = await this.desktopService.screenshotRegion(body); - if (!result.success) return this.createErrorResponse(result.error, context); - return this.createTypedResponse({ success: true, ...result.data }, context); - } - - private async handleMouseClick( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - const result = await this.desktopService.click(body); - if (!result.success) return this.createErrorResponse(result.error, context); - return this.createTypedResponse({ success: true }, context); - } - - private async handleMouseMove( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - const result = await this.desktopService.moveMouse(body); - if (!result.success) return this.createErrorResponse(result.error, context); - return this.createTypedResponse({ success: true }, context); - } - - private async handleMouseDown( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - const result = await this.desktopService.mouseDown(body); - if (!result.success) return this.createErrorResponse(result.error, context); - return this.createTypedResponse({ success: true }, context); - } - - private async handleMouseUp( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - const result = await this.desktopService.mouseUp(body); - if (!result.success) return this.createErrorResponse(result.error, context); - return this.createTypedResponse({ success: true }, context); - } - - private async handleMouseDrag( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - const result = await this.desktopService.drag(body); - if (!result.success) return this.createErrorResponse(result.error, context); - return this.createTypedResponse({ success: true }, context); - } - - private async handleMouseScroll( - request: Request, - context: RequestContext - ): Promise { - const body = - await this.parseRequestBody(request); - const result = await this.desktopService.scroll(body); - if (!result.success) return this.createErrorResponse(result.error, context); - return this.createTypedResponse({ success: true }, context); - } - - private async handleCursorPosition( - _request: Request, - context: RequestContext - ): Promise { - const result = await this.desktopService.getCursorPosition(); - if (!result.success) return this.createErrorResponse(result.error, context); - return this.createTypedResponse({ success: true, ...result.data }, context); - } - - private async handleKeyboardType( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - const result = await this.desktopService.typeText(body); - if (!result.success) return this.createErrorResponse(result.error, context); - return this.createTypedResponse({ success: true }, context); - } - - private async handleKeyboardPress( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - const result = await this.desktopService.keyPress(body); - if (!result.success) return this.createErrorResponse(result.error, context); - return this.createTypedResponse({ success: true }, context); - } - - private async handleKeyboardDown( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - const result = await this.desktopService.keyDown(body); - if (!result.success) return this.createErrorResponse(result.error, context); - return this.createTypedResponse({ success: true }, context); - } - - private async handleKeyboardUp( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - const result = await this.desktopService.keyUp(body); - if (!result.success) return this.createErrorResponse(result.error, context); - return this.createTypedResponse({ success: true }, context); - } - - private async handleScreenSize( - _request: Request, - context: RequestContext - ): Promise { - const result = await this.desktopService.getScreenSize(); - if (!result.success) return this.createErrorResponse(result.error, context); - return this.createTypedResponse({ success: true, ...result.data }, context); - } - - private async handleProcessStatus( - request: Request, - context: RequestContext - ): Promise { - const url = new URL(request.url); - const parts = url.pathname.split('/'); - const processName = decodeURIComponent(parts[4]); - const result = await this.desktopService.getProcessStatus(processName); - if (!result.success) return this.createErrorResponse(result.error, context); - return this.createTypedResponse({ success: true, ...result.data }, context); - } -} diff --git a/packages/sandbox-container/src/handlers/execute-handler.ts b/packages/sandbox-container/src/handlers/execute-handler.ts deleted file mode 100644 index 4564a2c43..000000000 --- a/packages/sandbox-container/src/handlers/execute-handler.ts +++ /dev/null @@ -1,219 +0,0 @@ -// Execute Handler -import type { - ExecResult, - ExecuteRequest, - Logger, - ProcessStartResult -} from '@repo/shared'; -import { ErrorCode } from '@repo/shared/errors'; - -import type { RequestContext } from '../core/types'; -import type { ProcessService } from '../services/process-service'; -import { BaseHandler } from './base-handler'; - -export class ExecuteHandler extends BaseHandler { - constructor( - private processService: ProcessService, - logger: Logger - ) { - super(logger); - } - - async handle(request: Request, context: RequestContext): Promise { - const url = new URL(request.url); - const pathname = url.pathname; - - switch (pathname) { - case '/api/execute': - return await this.handleExecute(request, context); - case '/api/execute/stream': - return await this.handleStreamingExecute(request, context); - default: - return this.createErrorResponse( - { - message: 'Invalid execute endpoint', - code: ErrorCode.UNKNOWN_ERROR - }, - context - ); - } - } - - private async handleExecute( - request: Request, - context: RequestContext - ): Promise { - // Parse request body directly - const body = await this.parseRequestBody(request); - const sessionId = body.sessionId || context.sessionId; - - // If this is a background process, start it as a process - if (body.background) { - const processResult = await this.processService.startProcess( - body.command, - { - sessionId, - timeoutMs: body.timeoutMs, - env: body.env, - cwd: body.cwd, - origin: body.origin - } - ); - - if (!processResult.success) { - return this.createErrorResponse(processResult.error, context); - } - - const processData = processResult.data; - - const response: ProcessStartResult = { - success: true, - processId: processData.id, - pid: processData.pid, - command: body.command, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } - - // For non-background commands, execute and return result - const result = await this.processService.executeCommand(body.command, { - sessionId, - timeoutMs: body.timeoutMs, - env: body.env, - cwd: body.cwd, - origin: body.origin - }); - - if (!result.success) { - return this.createErrorResponse(result.error, context); - } - - const commandResult = result.data; - - const response: ExecResult = { - success: commandResult.success, - exitCode: commandResult.exitCode, - stdout: commandResult.stdout, - stderr: commandResult.stderr, - command: body.command, - duration: 0, // Duration not tracked at service level yet - timestamp: new Date().toISOString(), - sessionId: sessionId - }; - - return this.createTypedResponse(response, context); - } - - private async handleStreamingExecute( - request: Request, - context: RequestContext - ): Promise { - // Parse request body directly - const body = await this.parseRequestBody(request); - const sessionId = body.sessionId || context.sessionId; - - // Start the process for streaming - const processResult = await this.processService.startProcess(body.command, { - sessionId, - env: body.env, - cwd: body.cwd, - origin: body.origin - }); - - if (!processResult.success) { - return this.createErrorResponse(processResult.error, context); - } - - const process = processResult.data; - - // Create SSE stream - const stream = new ReadableStream({ - start(controller) { - // Send initial process info - const initialData = `data: ${JSON.stringify({ - type: 'start', - command: process.command, - timestamp: new Date().toISOString() - })}\n\n`; - controller.enqueue(new TextEncoder().encode(initialData)); - - // Send any already-buffered stdout/stderr (for fast-completing processes) - if (process.stdout) { - const stdoutData = `data: ${JSON.stringify({ - type: 'stdout', - data: process.stdout, - timestamp: new Date().toISOString() - })}\n\n`; - controller.enqueue(new TextEncoder().encode(stdoutData)); - } - - if (process.stderr) { - const stderrData = `data: ${JSON.stringify({ - type: 'stderr', - data: process.stderr, - timestamp: new Date().toISOString() - })}\n\n`; - controller.enqueue(new TextEncoder().encode(stderrData)); - } - - // Set up output listeners for future output - const outputListener = (stream: 'stdout' | 'stderr', data: string) => { - const eventData = `data: ${JSON.stringify({ - type: stream, // 'stdout' or 'stderr' directly - data, - timestamp: new Date().toISOString() - })}\n\n`; - controller.enqueue(new TextEncoder().encode(eventData)); - }; - - const statusListener = (status: string) => { - // Close stream when process completes - if (['completed', 'failed', 'killed', 'error'].includes(status)) { - const finalData = `data: ${JSON.stringify({ - type: 'complete', - exitCode: process.exitCode, - timestamp: new Date().toISOString() - })}\n\n`; - controller.enqueue(new TextEncoder().encode(finalData)); - controller.close(); - } - }; - - // Add listeners - process.outputListeners.add(outputListener); - process.statusListeners.add(statusListener); - - // If process already completed, send complete event immediately - if ( - ['completed', 'failed', 'killed', 'error'].includes(process.status) - ) { - const finalData = `data: ${JSON.stringify({ - type: 'complete', - exitCode: process.exitCode, - timestamp: new Date().toISOString() - })}\n\n`; - controller.enqueue(new TextEncoder().encode(finalData)); - controller.close(); - } - - // Cleanup when stream is cancelled - return () => { - process.outputListeners.delete(outputListener); - process.statusListeners.delete(statusListener); - }; - } - }); - - return new Response(stream, { - status: 200, - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - ...context.corsHeaders - } - }); - } -} diff --git a/packages/sandbox-container/src/handlers/file-handler.ts b/packages/sandbox-container/src/handlers/file-handler.ts deleted file mode 100644 index 340d79163..000000000 --- a/packages/sandbox-container/src/handlers/file-handler.ts +++ /dev/null @@ -1,330 +0,0 @@ -import type { - DeleteFileRequest, - DeleteFileResult, - FileExistsRequest, - FileExistsResult, - FileStreamEvent, - ListFilesRequest, - ListFilesResult, - Logger, - MkdirRequest, - MkdirResult, - MoveFileRequest, - MoveFileResult, - ReadFileRequest, - ReadFileResult, - RenameFileRequest, - RenameFileResult, - WriteFileRequest, - WriteFileResult -} from '@repo/shared'; -import { ErrorCode } from '@repo/shared/errors'; - -import type { RequestContext } from '../core/types'; -import type { FileService } from '../services/file-service'; -import { BaseHandler } from './base-handler'; - -export class FileHandler extends BaseHandler { - constructor( - private fileService: FileService, - logger: Logger - ) { - super(logger); - } - - async handle(request: Request, context: RequestContext): Promise { - const url = new URL(request.url); - const pathname = url.pathname; - - switch (pathname) { - case '/api/read': - return await this.handleRead(request, context); - case '/api/read/stream': - return await this.handleReadStream(request, context); - case '/api/write': - return await this.handleWrite(request, context); - case '/api/delete': - return await this.handleDelete(request, context); - case '/api/rename': - return await this.handleRename(request, context); - case '/api/move': - return await this.handleMove(request, context); - case '/api/mkdir': - return await this.handleMkdir(request, context); - case '/api/list-files': - return await this.handleListFiles(request, context); - case '/api/exists': - return await this.handleExists(request, context); - default: - return this.createErrorResponse( - { - message: 'Invalid file endpoint', - code: ErrorCode.UNKNOWN_ERROR - }, - context - ); - } - } - - private async handleRead( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - - const result = await this.fileService.readFile( - body.path, - { - encoding: body.encoding - }, - body.sessionId - ); - - if (result.success) { - const response: ReadFileResult = { - success: true, - path: body.path, - content: result.data, - timestamp: new Date().toISOString(), - encoding: result.metadata?.encoding, - isBinary: result.metadata?.isBinary, - mimeType: result.metadata?.mimeType, - size: result.metadata?.size - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private async handleReadStream( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - - try { - // Create SSE stream (handles metadata fetching and errors internally) - const stream = await this.fileService.readFileStreamOperation( - body.path, - body.sessionId - ); - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - ...context.corsHeaders - } - }); - } catch (error) { - this.logger.error( - 'File streaming failed', - error instanceof Error ? error : undefined, - { - requestId: context.requestId, - path: body.path - } - ); - - // Return error as SSE event - const encoder = new TextEncoder(); - const errorEvent: FileStreamEvent = { - type: 'error', - error: error instanceof Error ? error.message : 'Unknown error' - }; - const stream = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode(`data: ${JSON.stringify(errorEvent)}\n\n`) - ); - controller.close(); - } - }); - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - ...context.corsHeaders - } - }); - } - } - - private async handleWrite( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - - const options = - body.encoding !== undefined ? { encoding: body.encoding } : {}; - - const result = await this.fileService.writeFile( - body.path, - body.content, - options, - body.sessionId - ); - - if (result.success) { - const response: WriteFileResult = { - success: true, - path: body.path, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private async handleDelete( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - - const result = await this.fileService.deleteFile(body.path); - - if (result.success) { - const response: DeleteFileResult = { - success: true, - path: body.path, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private async handleRename( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - - const result = await this.fileService.renameFile( - body.oldPath, - body.newPath - ); - - if (result.success) { - const response: RenameFileResult = { - success: true, - path: body.oldPath, - newPath: body.newPath, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private async handleMove( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - - const result = await this.fileService.moveFile( - body.sourcePath, - body.destinationPath - ); - - if (result.success) { - const response: MoveFileResult = { - success: true, - path: body.sourcePath, - newPath: body.destinationPath, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private async handleMkdir( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - - const result = await this.fileService.createDirectory(body.path, { - recursive: body.recursive - }); - - if (result.success) { - const response: MkdirResult = { - success: true, - path: body.path, - recursive: body.recursive ?? false, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private async handleListFiles( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - - const result = await this.fileService.listFiles( - body.path, - body.options || {}, - body.sessionId - ); - - if (result.success) { - const response: ListFilesResult = { - success: true, - path: body.path, - files: result.data, - count: result.data.length, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private async handleExists( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - - const result = await this.fileService.exists(body.path, body.sessionId); - - if (result.success) { - const response: FileExistsResult = { - success: true, - path: body.path, - exists: result.data, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } -} diff --git a/packages/sandbox-container/src/handlers/git-handler.ts b/packages/sandbox-container/src/handlers/git-handler.ts deleted file mode 100644 index 2e4162e85..000000000 --- a/packages/sandbox-container/src/handlers/git-handler.ts +++ /dev/null @@ -1,67 +0,0 @@ -// Git Handler -import type { - GitCheckoutRequest, - GitCheckoutResult, - Logger -} from '@repo/shared'; -import { ErrorCode } from '@repo/shared/errors'; - -import type { RequestContext } from '../core/types'; -import type { GitService } from '../services/git-service'; -import { BaseHandler } from './base-handler'; - -export class GitHandler extends BaseHandler { - constructor( - private gitService: GitService, - logger: Logger - ) { - super(logger); - } - - async handle(request: Request, context: RequestContext): Promise { - const url = new URL(request.url); - const pathname = url.pathname; - - switch (pathname) { - case '/api/git/checkout': - return await this.handleCheckout(request, context); - default: - return this.createErrorResponse( - { - message: 'Invalid git endpoint', - code: ErrorCode.UNKNOWN_ERROR - }, - context - ); - } - } - - private async handleCheckout( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - const sessionId = body.sessionId || context.sessionId; - - const result = await this.gitService.cloneRepository(body.repoUrl, { - branch: body.branch, - targetDir: body.targetDir, - sessionId, - depth: body.depth - }); - - if (result.success) { - const response: GitCheckoutResult = { - success: true, - repoUrl: body.repoUrl, - branch: result.data.branch, - targetDir: result.data.path, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } -} diff --git a/packages/sandbox-container/src/handlers/interpreter-handler.ts b/packages/sandbox-container/src/handlers/interpreter-handler.ts deleted file mode 100644 index cb9d4151b..000000000 --- a/packages/sandbox-container/src/handlers/interpreter-handler.ts +++ /dev/null @@ -1,182 +0,0 @@ -// Interpreter Handler -import type { - ContextCreateResult, - ContextDeleteResult, - ContextListResult, - InterpreterHealthResult, - Logger -} from '@repo/shared'; -import { ErrorCode } from '@repo/shared/errors'; - -import type { RequestContext } from '../core/types'; -import type { - CreateContextRequest, - InterpreterService -} from '../services/interpreter-service'; -import { BaseHandler } from './base-handler'; - -export class InterpreterHandler extends BaseHandler { - constructor( - private interpreterService: InterpreterService, - logger: Logger - ) { - super(logger); - } - - async handle(request: Request, context: RequestContext): Promise { - const url = new URL(request.url); - const pathname = url.pathname; - - // Health check - if (pathname === '/api/interpreter/health' && request.method === 'GET') { - return await this.handleHealth(request, context); - } - - // Context management - if (pathname === '/api/contexts' && request.method === 'POST') { - return await this.handleCreateContext(request, context); - } else if (pathname === '/api/contexts' && request.method === 'GET') { - return await this.handleListContexts(request, context); - } else if (pathname.startsWith('/api/contexts/')) { - const contextId = pathname.split('/')[3]; - - if (request.method === 'DELETE') { - return await this.handleDeleteContext(request, context, contextId); - } - } - - // Code execution - if (pathname === '/api/execute/code' && request.method === 'POST') { - return await this.handleExecuteCode(request, context); - } - - return this.createErrorResponse( - { - message: 'Invalid interpreter endpoint', - code: ErrorCode.UNKNOWN_ERROR - }, - context - ); - } - - private async handleHealth( - request: Request, - context: RequestContext - ): Promise { - const result = await this.interpreterService.getHealthStatus(); - - if (result.success) { - const response: InterpreterHealthResult = { - success: true, - status: result.data.ready ? 'healthy' : 'unhealthy', - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private async handleCreateContext( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - - const result = await this.interpreterService.createContext(body); - - if (result.success) { - const contextData = result.data; - - const response: ContextCreateResult = { - success: true, - contextId: contextData.id, - language: contextData.language, - cwd: contextData.cwd, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - // Special handling for interpreter not ready - return 503 with Retry-After header - if (result.error.code === 'INTERPRETER_NOT_READY') { - const errorResponse = this.enrichServiceError(result.error); - return new Response(JSON.stringify(errorResponse), { - status: 503, - headers: { - 'Content-Type': 'application/json', - 'Retry-After': String(result.error.details?.retryAfter || 5), - ...context.corsHeaders - } - }); - } - - return this.createErrorResponse(result.error, context); - } - } - - private async handleListContexts( - request: Request, - context: RequestContext - ): Promise { - const result = await this.interpreterService.listContexts(); - - if (result.success) { - const response: ContextListResult = { - success: true, - contexts: result.data.map((ctx) => ({ - id: ctx.id, - language: ctx.language, - cwd: ctx.cwd - })), - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private async handleDeleteContext( - request: Request, - context: RequestContext, - contextId: string - ): Promise { - const result = await this.interpreterService.deleteContext(contextId); - - if (result.success) { - const response: ContextDeleteResult = { - success: true, - contextId: contextId, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private async handleExecuteCode( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody<{ - context_id: string; - code: string; - language?: string; - }>(request); - - // The service returns a Response directly for streaming - // No need to wrap with ServiceResult as it's already handled - const response = await this.interpreterService.executeCode( - body.context_id, - body.code, - body.language - ); - - return response; - } -} diff --git a/packages/sandbox-container/src/handlers/misc-handler.ts b/packages/sandbox-container/src/handlers/misc-handler.ts deleted file mode 100644 index aa9a72383..000000000 --- a/packages/sandbox-container/src/handlers/misc-handler.ts +++ /dev/null @@ -1,93 +0,0 @@ -// Miscellaneous Handler for ping, commands, etc. -import type { HealthCheckResult, Logger, ShutdownResult } from '@repo/shared'; -import { ErrorCode } from '@repo/shared/errors'; - -import type { RequestContext } from '../core/types'; -import { BaseHandler } from './base-handler'; - -export interface VersionResult { - success: boolean; - version: string; - timestamp: string; -} - -export class MiscHandler extends BaseHandler { - async handle(request: Request, context: RequestContext): Promise { - const url = new URL(request.url); - const pathname = url.pathname; - - switch (pathname) { - case '/': - return await this.handleRoot(request, context); - case '/api/health': - return await this.handleHealth(request, context); - case '/api/ping': - return await this.handleHealth(request, context); - case '/api/shutdown': - return await this.handleShutdown(request, context); - case '/api/version': - return await this.handleVersion(request, context); - default: - return this.createErrorResponse( - { - message: 'Invalid endpoint', - code: ErrorCode.UNKNOWN_ERROR - }, - context - ); - } - } - - private async handleRoot( - request: Request, - context: RequestContext - ): Promise { - return new Response('Hello from Bun server! 🚀', { - headers: { - 'Content-Type': 'text/plain; charset=utf-8', - ...context.corsHeaders - } - }); - } - - private async handleHealth( - request: Request, - context: RequestContext - ): Promise { - const response: HealthCheckResult = { - success: true, - status: 'healthy', - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } - - private async handleShutdown( - request: Request, - context: RequestContext - ): Promise { - const response: ShutdownResult = { - success: true, - message: 'Container shutdown initiated', - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } - - private async handleVersion( - request: Request, - context: RequestContext - ): Promise { - const version = process.env.SANDBOX_VERSION || 'unknown'; - - const response: VersionResult = { - success: true, - version, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } -} diff --git a/packages/sandbox-container/src/handlers/port-handler.ts b/packages/sandbox-container/src/handlers/port-handler.ts deleted file mode 100644 index 65263988b..000000000 --- a/packages/sandbox-container/src/handlers/port-handler.ts +++ /dev/null @@ -1,285 +0,0 @@ -// Port Handler -import type { - ExposePortRequest, - Logger, - PortCloseResult, - PortExposeResult, - PortListResult, - PortWatchEvent, - PortWatchRequest -} from '@repo/shared'; -import { ErrorCode } from '@repo/shared/errors'; - -import type { RequestContext } from '../core/types'; -import type { PortService } from '../services/port-service'; -import type { ProcessService } from '../services/process-service'; -import { BaseHandler } from './base-handler'; - -export class PortHandler extends BaseHandler { - constructor( - private portService: PortService, - private processService: ProcessService, - logger: Logger - ) { - super(logger); - } - - async handle(request: Request, context: RequestContext): Promise { - const url = new URL(request.url); - const pathname = url.pathname; - - if (pathname === '/api/expose-port') { - return await this.handleExpose(request, context); - } else if (pathname === '/api/port-watch') { - return await this.handlePortWatch(request, context); - } else if (pathname === '/api/exposed-ports') { - return await this.handleList(request, context); - } else if (pathname.startsWith('/api/exposed-ports/')) { - // Handle dynamic routes for individual ports - const segments = pathname.split('/'); - if (segments.length >= 4) { - const portStr = segments[3]; - const port = parseInt(portStr, 10); - - if (!Number.isNaN(port) && request.method === 'DELETE') { - return await this.handleUnexpose(request, context, port); - } - } - } else if (pathname.startsWith('/proxy/')) { - return await this.handleProxy(request, context); - } - - return this.createErrorResponse( - { - message: 'Invalid port endpoint', - code: ErrorCode.UNKNOWN_ERROR - }, - context - ); - } - - private async handlePortWatch( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - const { - port, - mode, - path, - statusMin, - statusMax, - processId, - interval = 500 - } = body; - - const portService = this.portService; - const processService = this.processService; - let cancelled = false; - - // Clamp interval between 100ms and 10s to prevent abuse - const clampedInterval = Math.max(100, Math.min(interval, 10000)); - - const stream = new ReadableStream({ - async start(controller) { - const emit = (event: PortWatchEvent) => { - const data = `data: ${JSON.stringify(event)}\n\n`; - controller.enqueue(new TextEncoder().encode(data)); - }; - - // Send initial event - emit({ type: 'watching', port }); - - try { - // Polling loop - while (!cancelled) { - // Check process status if processId provided - if (processId) { - const processResult = await processService.getProcess(processId); - if (!processResult.success) { - emit({ type: 'error', port, error: 'Process not found' }); - return; - } - const proc = processResult.data; - if ( - ['completed', 'failed', 'killed', 'error'].includes(proc.status) - ) { - emit({ - type: 'process_exited', - port, - exitCode: proc.exitCode ?? undefined - }); - return; - } - } - - // Check port readiness - const result = await portService.checkPortReady({ - port, - mode, - path, - statusMin, - statusMax - }); - - if (result.ready) { - emit({ type: 'ready', port, statusCode: result.statusCode }); - return; - } - - // Wait before next check - await new Promise((resolve) => - setTimeout(resolve, clampedInterval) - ); - } - } catch (error) { - emit({ - type: 'error', - port, - error: error instanceof Error ? error.message : 'Unknown error' - }); - } finally { - controller.close(); - } - }, - cancel() { - cancelled = true; - } - }); - - return new Response(stream, { - status: 200, - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - ...context.corsHeaders - } - }); - } - - private async handleExpose( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - - const result = await this.portService.exposePort(body.port, body.name); - - if (result.success) { - const portInfo = result.data!; - - const response: PortExposeResult = { - success: true, - port: portInfo.port, - url: `http://localhost:${portInfo.port}`, // Generate URL from port - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private async handleUnexpose( - request: Request, - context: RequestContext, - port: number - ): Promise { - const result = await this.portService.unexposePort(port); - - if (result.success) { - const response: PortCloseResult = { - success: true, - port, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private async handleList( - request: Request, - context: RequestContext - ): Promise { - const result = await this.portService.getExposedPorts(); - - if (result.success) { - const ports = result.data!.map((portInfo) => ({ - port: portInfo.port, - url: `http://localhost:${portInfo.port}`, - status: portInfo.status - })); - - const response: PortListResult = { - success: true, - ports, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private async handleProxy( - request: Request, - context: RequestContext - ): Promise { - try { - // Extract port from URL path: /proxy/{port}/... - const url = new URL(request.url); - const pathSegments = url.pathname.split('/'); - - if (pathSegments.length < 3) { - return this.createErrorResponse( - { - message: 'Invalid proxy URL format', - code: ErrorCode.UNKNOWN_ERROR - }, - context - ); - } - - const portStr = pathSegments[2]; - const port = parseInt(portStr, 10); - - if (Number.isNaN(port)) { - return this.createErrorResponse( - { - message: 'Invalid port number in proxy URL', - code: ErrorCode.UNKNOWN_ERROR - }, - context - ); - } - - // Use the port service to proxy the request - const response = await this.portService.proxyRequest(port, request); - - return response; - } catch (error) { - this.logger.error( - 'Proxy request failed', - error instanceof Error ? error : undefined, - { - requestId: context.requestId - } - ); - - return this.createErrorResponse( - { - message: - error instanceof Error ? error.message : 'Proxy request failed', - code: ErrorCode.UNKNOWN_ERROR - }, - context - ); - } - } -} diff --git a/packages/sandbox-container/src/handlers/process-handler.ts b/packages/sandbox-container/src/handlers/process-handler.ts deleted file mode 100644 index 7ca1b6a56..000000000 --- a/packages/sandbox-container/src/handlers/process-handler.ts +++ /dev/null @@ -1,368 +0,0 @@ -import type { - Logger, - ProcessCleanupResult, - ProcessInfoResult, - ProcessKillResult, - ProcessListResult, - ProcessLogsResult, - ProcessStartResult, - ProcessStatus, - StartProcessRequest -} from '@repo/shared'; -import { ErrorCode } from '@repo/shared/errors'; - -import type { RequestContext } from '../core/types'; -import type { ProcessService } from '../services/process-service'; -import { BaseHandler } from './base-handler'; - -export class ProcessHandler extends BaseHandler { - constructor( - private processService: ProcessService, - logger: Logger - ) { - super(logger); - } - - async handle(request: Request, context: RequestContext): Promise { - const url = new URL(request.url); - const pathname = url.pathname; - - if (pathname === '/api/process/start') { - return await this.handleStart(request, context); - } else if (pathname === '/api/process/list') { - return await this.handleList(request, context); - } else if (pathname === '/api/process/kill-all') { - return await this.handleKillAll(request, context); - } else if (pathname.startsWith('/api/process/')) { - // Handle dynamic routes for individual processes - const segments = pathname.split('/'); - if (segments.length >= 4) { - const processId = segments[3]; - const action = segments[4]; // Optional: logs, stream, etc. - - if (!action && request.method === 'GET') { - return await this.handleGet(request, context, processId); - } else if (!action && request.method === 'DELETE') { - return await this.handleKill(request, context, processId); - } else if (action === 'logs' && request.method === 'GET') { - return await this.handleLogs(request, context, processId); - } else if (action === 'stream' && request.method === 'GET') { - return await this.handleStream(request, context, processId); - } - } - } - - return this.createErrorResponse( - { - message: 'Invalid process endpoint', - code: ErrorCode.UNKNOWN_ERROR - }, - context - ); - } - - private async handleStart( - request: Request, - context: RequestContext - ): Promise { - const body = await this.parseRequestBody(request); - - // Extract command and pass remaining fields as options (flat structure) - const { command, ...options } = body; - - const result = await this.processService.startProcess(command, options); - - if (result.success) { - const process = result.data; - - const response: ProcessStartResult = { - success: true, - processId: process.id, - pid: process.pid, - command: process.command, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private async handleList( - request: Request, - context: RequestContext - ): Promise { - // Extract query parameters for filtering - const url = new URL(request.url); - const status = url.searchParams.get('status'); - - // Processes are sandbox-scoped, not session-scoped - // All sessions in a sandbox can see all processes (like terminals in Linux) - const filters: { status?: ProcessStatus } = {}; - if (status) filters.status = status as ProcessStatus; - - const result = await this.processService.listProcesses(filters); - - if (result.success) { - const response: ProcessListResult = { - success: true, - processes: result.data.map((process) => ({ - id: process.id, - pid: process.pid, - command: process.command, - status: process.status, - startTime: process.startTime.toISOString(), - exitCode: process.exitCode - })), - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private async handleGet( - request: Request, - context: RequestContext, - processId: string - ): Promise { - const result = await this.processService.getProcess(processId); - - if (result.success) { - const process = result.data; - - const response: ProcessInfoResult = { - success: true, - process: { - id: process.id, - pid: process.pid, - command: process.command, - status: process.status, - startTime: process.startTime.toISOString(), - endTime: process.endTime?.toISOString(), - exitCode: process.exitCode - }, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private async handleKill( - request: Request, - context: RequestContext, - processId: string - ): Promise { - const result = await this.processService.killProcess(processId); - - if (result.success) { - const response: ProcessKillResult = { - success: true, - processId, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private async handleKillAll( - request: Request, - context: RequestContext - ): Promise { - const result = await this.processService.killAllProcesses(); - - if (result.success) { - const response: ProcessCleanupResult = { - success: true, - cleanedCount: result.data, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private async handleLogs( - request: Request, - context: RequestContext, - processId: string - ): Promise { - const result = await this.processService.getProcess(processId); - - if (result.success) { - const process = result.data; - - const response: ProcessLogsResult = { - success: true, - processId, - stdout: process.stdout, - stderr: process.stderr, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private async handleStream( - request: Request, - context: RequestContext, - processId: string - ): Promise { - const processResult = await this.processService.getProcess(processId); - if (!processResult.success) { - return this.createErrorResponse(processResult.error, context); - } - - const process = processResult.data; - - // Hoist listener references so cancel() can access them - let outputListener: - | ((stream: 'stdout' | 'stderr', data: string) => void) - | undefined; - let statusListener: ((status: string) => void) | undefined; - const logger = this.logger; - - const removeListeners = () => { - if (outputListener) process.outputListeners.delete(outputListener); - if (statusListener) process.statusListeners.delete(statusListener); - }; - - const stream = new ReadableStream({ - start(controller) { - const encoder = new TextEncoder(); - - const enqueueSSE = (payload: Record) => { - controller.enqueue( - encoder.encode(`data: ${JSON.stringify(payload)}\n\n`) - ); - }; - - enqueueSSE({ - type: 'process_info', - processId: process.id, - command: process.command, - status: process.status, - timestamp: new Date().toISOString() - }); - - // Register listeners BEFORE replaying buffered output so no - // chunk emitted between the snapshot read and registration is - // lost. Duplicates from replay + listener overlap are harmless; - // missing data is not. - outputListener = (stream: 'stdout' | 'stderr', data: string) => { - try { - enqueueSSE({ - type: stream, - data, - processId: process.id, - timestamp: new Date().toISOString() - }); - } catch (err) { - if (err instanceof TypeError) { - // Stream was closed or cancelled — remove self to stop further writes - removeListeners(); - } else { - logger.error( - 'Unexpected error in output listener', - err instanceof Error ? err : new Error(String(err)) - ); - controller.error(err); - removeListeners(); - } - } - }; - - statusListener = (status: string) => { - if (['completed', 'failed', 'killed', 'error'].includes(status)) { - try { - enqueueSSE({ - type: 'exit', - processId: process.id, - exitCode: process.exitCode, - data: `Process ${status} with exit code ${process.exitCode}`, - timestamp: new Date().toISOString() - }); - controller.close(); - removeListeners(); - } catch (err) { - if (err instanceof TypeError) { - // Stream already closed — just clean up listeners - removeListeners(); - } else { - logger.error( - 'Unexpected error in status listener', - err instanceof Error ? err : new Error(String(err)) - ); - controller.error(err); - removeListeners(); - } - } - } - }; - - process.outputListeners.add(outputListener); - process.statusListeners.add(statusListener); - - // Replay buffered output collected before the listener was added - if (process.stdout) { - enqueueSSE({ - type: 'stdout', - data: process.stdout, - processId: process.id, - timestamp: new Date().toISOString() - }); - } - - if (process.stderr) { - enqueueSSE({ - type: 'stderr', - data: process.stderr, - processId: process.id, - timestamp: new Date().toISOString() - }); - } - - if ( - ['completed', 'failed', 'killed', 'error'].includes(process.status) - ) { - enqueueSSE({ - type: 'exit', - processId: process.id, - exitCode: process.exitCode, - data: `Process ${process.status} with exit code ${process.exitCode}`, - timestamp: new Date().toISOString() - }); - controller.close(); - removeListeners(); - } - }, - cancel() { - removeListeners(); - } - }); - - return new Response(stream, { - status: 200, - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - ...context.corsHeaders - } - }); - } -} diff --git a/packages/sandbox-container/src/handlers/session-handler.ts b/packages/sandbox-container/src/handlers/session-handler.ts deleted file mode 100644 index 4f50b78b8..000000000 --- a/packages/sandbox-container/src/handlers/session-handler.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { randomBytes } from 'node:crypto'; -import type { Logger, SessionDeleteRequest } from '@repo/shared'; -import { ErrorCode } from '@repo/shared/errors'; - -import type { RequestContext } from '../core/types'; -import type { SessionManager } from '../services/session-manager'; -import { BaseHandler } from './base-handler'; - -// SessionListResult type - matches actual handler return format -interface SessionListResult { - success: boolean; - data: string[]; - timestamp: string; -} - -export class SessionHandler extends BaseHandler { - constructor( - private sessionManager: SessionManager, - logger: Logger - ) { - super(logger); - } - - async handle(request: Request, context: RequestContext): Promise { - const url = new URL(request.url); - const pathname = url.pathname; - - switch (pathname) { - case '/api/session/create': - return await this.handleCreate(request, context); - case '/api/session/list': - return await this.handleList(request, context); - case '/api/session/delete': - return await this.handleDelete(request, context); - default: - return this.createErrorResponse( - { - message: 'Invalid session endpoint', - code: ErrorCode.UNKNOWN_ERROR - }, - context - ); - } - } - - private async handleCreate( - request: Request, - context: RequestContext - ): Promise { - // Parse request body for session options - let sessionId: string; - let env: Record; - let cwd: string; - let commandTimeoutMs: number | undefined; - - try { - const body = (await request.json()) as any; - sessionId = body.id || this.generateSessionId(); - env = body.env || {}; - cwd = body.cwd || '/workspace'; - commandTimeoutMs = body.commandTimeoutMs; - } catch { - // If no body or invalid JSON, use defaults - sessionId = this.generateSessionId(); - env = {}; - cwd = '/workspace'; - } - - const result = await this.sessionManager.createSession({ - id: sessionId, - env, - cwd, - commandTimeoutMs - }); - - if (result.success) { - // Note: Returning the Session object directly for now - // This matches current test expectations - const response = { - success: true, - data: result.data, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private async handleList( - request: Request, - context: RequestContext - ): Promise { - const result = await this.sessionManager.listSessions(); - - if (result.success) { - const response: SessionListResult = { - success: true, - data: result.data, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private async handleDelete( - request: Request, - context: RequestContext - ): Promise { - let body: SessionDeleteRequest; - - try { - body = (await request.json()) as SessionDeleteRequest; - - if (!body.sessionId) { - return this.createErrorResponse( - { - message: 'sessionId is required', - code: ErrorCode.VALIDATION_FAILED - }, - context - ); - } - } catch { - return this.createErrorResponse( - { - message: 'Invalid request body', - code: ErrorCode.VALIDATION_FAILED - }, - context - ); - } - - const sessionId = body.sessionId; - - const result = await this.sessionManager.deleteSession(sessionId); - - if (result.success) { - const response = { - success: true, - sessionId, - timestamp: new Date().toISOString() - }; - - return this.createTypedResponse(response, context); - } else { - return this.createErrorResponse(result.error, context); - } - } - - private generateSessionId(): string { - return `session_${Date.now()}_${randomBytes(6).toString('hex')}`; - } -} diff --git a/packages/sandbox-container/src/handlers/watch-handler.ts b/packages/sandbox-container/src/handlers/watch-handler.ts deleted file mode 100644 index db8355c19..000000000 --- a/packages/sandbox-container/src/handlers/watch-handler.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { posix as pathPosix } from 'node:path'; -import type { Logger, WatchRequest } from '@repo/shared'; -import { ErrorCode } from '@repo/shared/errors'; -import { CONFIG } from '../config'; -import type { RequestContext } from '../core/types'; -import type { WatchService } from '../services/watch-service'; -import { BaseHandler } from './base-handler'; - -const WORKSPACE_ROOT = CONFIG.DEFAULT_CWD; - -/** - * Handler for file watch operations - */ -export class WatchHandler extends BaseHandler { - constructor( - private watchService: WatchService, - logger: Logger - ) { - super(logger); - } - - async handle(request: Request, context: RequestContext): Promise { - const pathname = new URL(request.url).pathname; - - if (pathname === '/api/watch') { - return this.handleWatch(request, context); - } - - return this.createErrorResponse( - { - message: 'Invalid watch endpoint', - code: ErrorCode.VALIDATION_FAILED, - details: { pathname } - }, - context - ); - } - - /** - * Start watching a directory. - * Returns an SSE stream of file change events. - */ - private async handleWatch( - request: Request, - context: RequestContext - ): Promise { - let body: WatchRequest; - try { - body = await this.parseRequestBody(request); - } catch (error) { - return this.createErrorResponse( - { - message: - error instanceof Error ? error.message : 'Invalid request body', - code: ErrorCode.VALIDATION_FAILED - }, - context - ); - } - - const validationError = this.validateWatchBody(body); - if (validationError) { - return this.createErrorResponse(validationError, context); - } - - const pathResult = this.normalizeWatchPath(body.path); - if (!pathResult.success) { - return this.createErrorResponse(pathResult.error, context); - } - - const result = await this.watchService.watchDirectory(pathResult.path, { - ...body, - path: pathResult.path - }); - - if (result.success) { - return new Response(result.data, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - ...context.corsHeaders - } - }); - } - - return this.createErrorResponse(result.error, context); - } - - private validateWatchBody(body: WatchRequest): { - message: string; - code: string; - details?: Record; - } | null { - if (!body || typeof body !== 'object') { - return { - message: 'Request body must be a JSON object', - code: ErrorCode.VALIDATION_FAILED - }; - } - - if (typeof body.path !== 'string' || body.path.trim() === '') { - return { - message: 'path is required and must be a non-empty string', - code: ErrorCode.VALIDATION_FAILED, - details: { path: body.path } - }; - } - - if (body.include?.length && body.exclude?.length) { - return { - message: - 'include and exclude cannot be used together. Use include to whitelist patterns, or exclude to blacklist patterns.', - code: ErrorCode.VALIDATION_FAILED, - details: { include: body.include, exclude: body.exclude } - }; - } - - if (!this.isStringArrayOrUndefined(body.include)) { - return { - message: 'include must be an array of strings', - code: ErrorCode.VALIDATION_FAILED, - details: { include: body.include } - }; - } - - const invalidInclude = this.findUnsupportedPattern(body.include); - if (invalidInclude) { - return { - message: - 'include contains unsupported glob syntax. Supported tokens: *, **, ? and path separators', - code: ErrorCode.VALIDATION_FAILED, - details: { pattern: invalidInclude, include: body.include } - }; - } - - if (!this.isStringArrayOrUndefined(body.exclude)) { - return { - message: 'exclude must be an array of strings', - code: ErrorCode.VALIDATION_FAILED, - details: { exclude: body.exclude } - }; - } - - const invalidExclude = this.findUnsupportedPattern(body.exclude); - if (invalidExclude) { - return { - message: - 'exclude contains unsupported glob syntax. Supported tokens: *, **, ? and path separators', - code: ErrorCode.VALIDATION_FAILED, - details: { pattern: invalidExclude, exclude: body.exclude } - }; - } - - if (body.recursive !== undefined && typeof body.recursive !== 'boolean') { - return { - message: 'recursive must be a boolean when provided', - code: ErrorCode.VALIDATION_FAILED, - details: { recursive: body.recursive } - }; - } - - return null; - } - - private isStringArrayOrUndefined( - value: unknown - ): value is string[] | undefined { - return ( - value === undefined || - (Array.isArray(value) && value.every((item) => typeof item === 'string')) - ); - } - - private findUnsupportedPattern(patterns?: string[]): string | null { - if (!patterns) { - return null; - } - - // Supported syntax is intentionally narrow: *, **, ? and path separators. - // Character classes and brace expansion are rejected so behavior is explicit. - const unsupportedTokens = /[[\]{}]/; - - for (const pattern of patterns) { - if (pattern.trim() === '' || pattern.includes('\0')) { - return pattern; - } - if (unsupportedTokens.test(pattern)) { - return pattern; - } - } - - return null; - } - - private normalizeWatchPath(path: string): - | { success: true; path: string } - | { - success: false; - error: { - message: string; - code: string; - details?: Record; - }; - } { - const input = path.trim(); - - if (input.includes('\0')) { - return { - success: false, - error: { - message: 'path contains invalid null bytes', - code: ErrorCode.VALIDATION_FAILED, - details: { path } - } - }; - } - - const resolved = input.startsWith('/') - ? pathPosix.resolve(input) - : pathPosix.resolve(WORKSPACE_ROOT, input); - - if ( - resolved !== WORKSPACE_ROOT && - !resolved.startsWith(`${WORKSPACE_ROOT}/`) - ) { - return { - success: false, - error: { - message: 'path must be inside /workspace', - code: ErrorCode.PERMISSION_DENIED, - details: { - path, - resolvedPath: resolved, - workspaceRoot: WORKSPACE_ROOT - } - } - }; - } - - return { success: true, path: resolved }; - } -} diff --git a/packages/sandbox-container/src/handlers/ws-adapter.ts b/packages/sandbox-container/src/handlers/ws-adapter.ts deleted file mode 100644 index f0f747734..000000000 --- a/packages/sandbox-container/src/handlers/ws-adapter.ts +++ /dev/null @@ -1,443 +0,0 @@ -/** - * WebSocket Protocol Adapter for Container - * - * Adapts WebSocket messages to HTTP requests for routing through existing 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, - parseSSEFrames, - type SSEPartialEvent, - type WSError, - type WSRequest, - type WSResponse, - type WSServerMessage, - type WSStreamChunk -} from '@repo/shared'; -import type { ServerWebSocket } from 'bun'; -import type { Router } from '../core/router'; - -/** Container server port - must match SERVER_PORT in server.ts */ -const SERVER_PORT = 3000; - -/** - * WebSocket data attached to each connection - */ -export interface WSData { - /** Connection ID for logging */ - connectionId: string; -} - -function isCancelMessage( - value: unknown -): value is { type: 'cancel'; id: string } { - if (typeof value !== 'object' || value === null) { - return false; - } - - const candidate = value as Record; - return candidate.type === 'cancel' && typeof candidate.id === 'string'; -} - -/** - * WebSocket protocol adapter that bridges WebSocket messages to HTTP handlers - * - * Converts incoming WebSocket requests to HTTP Request objects and routes them - * through the standard router. Supports both regular responses and SSE streaming. - */ -export class WebSocketAdapter { - private router: Router; - private logger: Logger; - /** Track active streaming responses for explicit client-side cancellation */ - private activeStreams: Map< - string, - { - connectionId: string; - cancel: () => Promise; - } - > = new Map(); - - constructor(router: Router, logger: Logger) { - this.router = router; - this.logger = logger.child({ component: 'container' }); - } - - /** - * Handle WebSocket connection open - */ - onOpen(_ws: ServerWebSocket): void { - // Lifecycle captured in onClose canonical log line - } - - /** - * Handle WebSocket connection close — canonical log line for connection lifecycle - */ - onClose(ws: ServerWebSocket, code: number, reason: string): void { - const connectionId = ws.data.connectionId; - this.logger.debug('ws.connection', { - connectionId, - code, - reason, - outcome: 'closed' - }); - - for (const [requestId, stream] of this.activeStreams) { - if (stream.connectionId !== connectionId) { - continue; - } - - void stream.cancel().catch((error) => { - this.logger.debug('Failed to cancel stream on socket close', { - requestId, - error: error instanceof Error ? error.message : String(error) - }); - }); - this.activeStreams.delete(requestId); - } - } - - /** - * 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 (isCancelMessage(parsed)) { - await this.handleCancel(parsed.id, ws.data.connectionId); - return; - } - - if (!isWSRequest(parsed)) { - this.sendError( - ws, - undefined, - 'INVALID_REQUEST', - 'Message must be a valid WSRequest', - 400 - ); - return; - } - - const request = parsed as WSRequest; - - try { - await this.handleRequest(ws, request); - } catch (error) { - this.logger.error( - 'ws.request', - error instanceof Error ? error : new Error(String(error)), - { - connectionId: ws.data.connectionId, - requestId: request.id, - method: request.method, - path: request.path - } - ); - this.sendError( - ws, - request.id, - 'INTERNAL_ERROR', - error instanceof Error ? error.message : 'Unknown error', - 500 - ); - } - } - - /** - * Handle explicit cancellation for an in-flight streaming request. - */ - private async handleCancel( - requestId: string, - connectionId: string - ): Promise { - const stream = this.activeStreams.get(requestId); - if (!stream || stream.connectionId !== connectionId) { - this.logger.debug('Cancel received for unknown stream request', { - requestId, - connectionId - }); - return; - } - - this.activeStreams.delete(requestId); - try { - await stream.cancel(); - this.logger.debug('Cancelled active stream request', { requestId }); - } catch (error) { - this.logger.debug('Failed to cancel active stream request', { - requestId, - error: error instanceof Error ? error.message : String(error) - }); - } - } - - /** - * 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:${SERVER_PORT}${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 - // CRITICAL: We must capture the Response body reader BEFORE the promise starts executing - // asynchronously. If we call getReader() inside handleStreamingResponse after an await, - // Bun's WebSocket handler may GC or invalidate the Response body when onMessage returns. - // By getting the reader synchronously here, we ensure the stream remains valid. - const reader = - httpResponse.body.getReader() as ReadableStreamDefaultReader; - - // Register cancellation immediately after reader acquisition so socket-close - // cleanup can always find and cancel this stream. - this.activeStreams.set(request.id, { - connectionId: ws.data.connectionId, - cancel: async () => { - try { - await reader.cancel(); - } catch { - // Reader may already be closed/cancelled. - } - } - }); - - void this.handleStreamingResponseWithReader( - ws, - request.id, - httpResponse.status, - reader - ) - .catch((error: unknown) => { - this.logger.error( - 'Error in streaming response', - error instanceof Error ? error : new Error(String(error)), - { requestId: request.id } - ); - }) - .finally(() => { - this.activeStreams.delete(request.id); - }); - } 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 with a pre-acquired reader - * - * This variant receives the reader instead of the Response, allowing the caller - * to acquire the reader synchronously before any await points. This is critical - * for WebSocket streaming because Bun's message handler may invalidate the - * Response body if the reader is acquired after the handler returns. - */ - private async handleStreamingResponseWithReader( - ws: ServerWebSocket, - requestId: string, - status: number, - reader: ReadableStreamDefaultReader - ): Promise { - const decoder = new TextDecoder(); - let buffer = ''; - // Track partial event state across chunks - let currentEvent: SSEPartialEvent = { data: [] }; - let chunkCount = 0; - - try { - while (true) { - const { done, value } = await reader.read(); - - if (done) { - break; - } - - chunkCount++; - - // Decode chunk and add to buffer - const chunkText = decoder.decode(value, { stream: true }); - buffer += chunkText; - - // Parse SSE events from buffer, preserving partial event state - const result = parseSSEFrames(buffer, currentEvent); - buffer = result.remaining; - currentEvent = result.currentEvent; - - // Send each parsed event as a stream chunk - for (const event of result.events) { - const chunk: WSStreamChunk = { - type: 'stream', - id: requestId, - event: event.event, - data: event.data - }; - if (!this.send(ws, chunk)) { - return; // Connection dead, stop processing - } - } - } - - this.logger.debug('Completed streaming response handler', { - requestId, - chunkCount - }); - - // Send final response to close the stream - const wsResponse: WSResponse = { - type: 'response', - id: requestId, - status, - done: true - }; - this.send(ws, wsResponse); - } catch (error) { - // Cancellation removes the request from activeStreams before reader.cancel(). - if (!this.activeStreams.has(requestId)) { - this.logger.debug('Stream cancelled', { requestId }); - return; - } - - this.logger.error( - 'ws.stream', - error instanceof Error ? error : new Error(String(error)), - { connectionId: ws.data.connectionId, requestId } - ); - this.sendError( - ws, - requestId, - 'STREAM_ERROR', - error instanceof Error ? error.message : 'Stream read failed', - 500 - ); - } finally { - await reader.cancel().catch(() => { - // Reader may already be closed/cancelled. - }); - reader.releaseLock(); - } - } - - /** - * Send a message over WebSocket - * @returns true if send succeeded, false if it failed (connection will be closed) - */ - private send(ws: ServerWebSocket, message: WSServerMessage): boolean { - try { - ws.send(JSON.stringify(message)); - return true; - } catch (error) { - this.logger.error( - 'ws.send', - error instanceof Error ? error : new Error(String(error)), - { connectionId: ws.data.connectionId } - ); - try { - ws.close(1011, 'Send failed'); // 1011 = unexpected condition - } catch { - // Connection already closed - } - return false; - } - } - - /** - * 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/middleware/cors.ts b/packages/sandbox-container/src/middleware/cors.ts deleted file mode 100644 index 6bc85c002..000000000 --- a/packages/sandbox-container/src/middleware/cors.ts +++ /dev/null @@ -1,33 +0,0 @@ -// CORS Middleware -import type { Middleware, NextFunction, RequestContext } from '../core/types'; - -export class CorsMiddleware implements Middleware { - async handle( - request: Request, - context: RequestContext, - next: NextFunction - ): Promise { - // Handle CORS preflight requests - if (request.method === 'OPTIONS') { - return new Response(null, { - status: 200, - headers: context.corsHeaders - }); - } - - // For non-preflight requests, continue to next middleware/handler - const response = await next(); - - // Add CORS headers to the response - const corsResponse = new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers: { - ...Object.fromEntries(response.headers.entries()), - ...context.corsHeaders - } - }); - - return corsResponse; - } -} diff --git a/packages/sandbox-container/src/middleware/logging.ts b/packages/sandbox-container/src/middleware/logging.ts deleted file mode 100644 index 39fb0c355..000000000 --- a/packages/sandbox-container/src/middleware/logging.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Logging Middleware -import type { Logger } from '@repo/shared'; -import type { Middleware, NextFunction, RequestContext } from '../core/types'; - -export class LoggingMiddleware implements Middleware { - constructor(private logger: Logger) {} - - async handle( - request: Request, - context: RequestContext, - next: NextFunction - ): Promise { - const startTime = Date.now(); - const method = request.method; - const url = new URL(request.url); - const pathname = url.pathname; - - let response: Response | undefined; - let requestError: Error | undefined; - - try { - response = await next(); - return response; - } catch (error) { - requestError = - error instanceof Error ? error : new Error('Unknown request failure'); - throw error; - } finally { - const statusCode = response?.status ?? 500; - const durationMs = Date.now() - startTime; - const isError = statusCode >= 500 || Boolean(requestError); - - const wideEvent: Record = { - method, - pathname, - statusCode, - durationMs, - requestId: context.requestId, - sessionId: context.sessionId, - sandboxId: context.sandboxId - }; - - const msg = `${method} ${pathname} ${statusCode}`; - if (statusCode >= 500) { - this.logger.warn(msg, { ...wideEvent, error: requestError?.message }); - } else { - this.logger.debug(msg, wideEvent); - } - } - } -} diff --git a/packages/sandbox-container/src/routes/setup.ts b/packages/sandbox-container/src/routes/setup.ts deleted file mode 100644 index 391db093c..000000000 --- a/packages/sandbox-container/src/routes/setup.ts +++ /dev/null @@ -1,474 +0,0 @@ -// Route Setup - -import type { Container } from '../core/container'; -import type { Router } from '../core/router'; - -export function setupRoutes(router: Router, container: Container): void { - // Session routes - router.register({ - method: 'POST', - path: '/api/session/create', - handler: async (req, ctx) => - container.get('sessionHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'GET', - path: '/api/session/list', - handler: async (req, ctx) => - container.get('sessionHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/session/delete', - handler: async (req, ctx) => - container.get('sessionHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - // Execute routes - router.register({ - method: 'POST', - path: '/api/execute', - handler: async (req, ctx) => - container.get('executeHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/execute/stream', - handler: async (req, ctx) => - container.get('executeHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - // File operation routes - router.register({ - method: 'POST', - path: '/api/read', - handler: async (req, ctx) => container.get('fileHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/read/stream', - handler: async (req, ctx) => container.get('fileHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/write', - handler: async (req, ctx) => container.get('fileHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/delete', - handler: async (req, ctx) => container.get('fileHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/rename', - handler: async (req, ctx) => container.get('fileHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/move', - handler: async (req, ctx) => container.get('fileHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/mkdir', - handler: async (req, ctx) => container.get('fileHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/list-files', - handler: async (req, ctx) => container.get('fileHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/exists', - handler: async (req, ctx) => container.get('fileHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - // Port management routes - router.register({ - method: 'POST', - path: '/api/expose-port', - handler: async (req, ctx) => container.get('portHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/port-watch', - handler: async (req, ctx) => container.get('portHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'GET', - path: '/api/exposed-ports', - handler: async (req, ctx) => container.get('portHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'DELETE', - path: '/api/exposed-ports/{port}', - handler: async (req, ctx) => container.get('portHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - // Process management routes - router.register({ - method: 'POST', - path: '/api/process/start', - handler: async (req, ctx) => - container.get('processHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'GET', - path: '/api/process/list', - handler: async (req, ctx) => - container.get('processHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'DELETE', - path: '/api/process/kill-all', - handler: async (req, ctx) => - container.get('processHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'GET', - path: '/api/process/{id}', - handler: async (req, ctx) => - container.get('processHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'DELETE', - path: '/api/process/{id}', - handler: async (req, ctx) => - container.get('processHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'GET', - path: '/api/process/{id}/logs', - handler: async (req, ctx) => - container.get('processHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'GET', - path: '/api/process/{id}/stream', - handler: async (req, ctx) => - container.get('processHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - // Git operations - router.register({ - method: 'POST', - path: '/api/git/checkout', - handler: async (req, ctx) => container.get('gitHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - // Interpreter/Code execution routes - router.register({ - method: 'GET', - path: '/api/interpreter/health', - handler: async (req, ctx) => - container.get('interpreterHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/contexts', - handler: async (req, ctx) => - container.get('interpreterHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'GET', - path: '/api/contexts', - handler: async (req, ctx) => - container.get('interpreterHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'DELETE', - path: '/api/contexts/{id}', - handler: async (req, ctx) => - container.get('interpreterHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/execute/code', - handler: async (req, ctx) => - container.get('interpreterHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - // Proxy routes (catch-all for /proxy/*) - router.register({ - method: 'GET', - path: '/proxy/{port}', - handler: async (req, ctx) => container.get('portHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/proxy/{port}', - handler: async (req, ctx) => container.get('portHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'PUT', - path: '/proxy/{port}', - handler: async (req, ctx) => container.get('portHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'DELETE', - path: '/proxy/{port}', - handler: async (req, ctx) => container.get('portHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - // Backup routes - router.register({ - method: 'POST', - path: '/api/backup/create', - handler: async (req, ctx) => - container.get('backupHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/backup/restore', - handler: async (req, ctx) => - container.get('backupHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - // Desktop routes - router.register({ - method: 'POST', - path: '/api/desktop/start', - handler: async (req, ctx) => - container.get('desktopHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/desktop/stop', - handler: async (req, ctx) => - container.get('desktopHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'GET', - path: '/api/desktop/status', - handler: async (req, ctx) => - container.get('desktopHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/desktop/screenshot', - handler: async (req, ctx) => - container.get('desktopHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/desktop/screenshot/region', - handler: async (req, ctx) => - container.get('desktopHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/desktop/mouse/click', - handler: async (req, ctx) => - container.get('desktopHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/desktop/mouse/move', - handler: async (req, ctx) => - container.get('desktopHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/desktop/mouse/down', - handler: async (req, ctx) => - container.get('desktopHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/desktop/mouse/up', - handler: async (req, ctx) => - container.get('desktopHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/desktop/mouse/drag', - handler: async (req, ctx) => - container.get('desktopHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/desktop/mouse/scroll', - handler: async (req, ctx) => - container.get('desktopHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'GET', - path: '/api/desktop/mouse/position', - handler: async (req, ctx) => - container.get('desktopHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/desktop/keyboard/type', - handler: async (req, ctx) => - container.get('desktopHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/desktop/keyboard/press', - handler: async (req, ctx) => - container.get('desktopHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/desktop/keyboard/down', - handler: async (req, ctx) => - container.get('desktopHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'POST', - path: '/api/desktop/keyboard/up', - handler: async (req, ctx) => - container.get('desktopHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'GET', - path: '/api/desktop/screen/size', - handler: async (req, ctx) => - container.get('desktopHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'GET', - path: '/api/desktop/process/{name}/status', - handler: async (req, ctx) => - container.get('desktopHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - // File watch routes - router.register({ - method: 'POST', - path: '/api/watch', - handler: async (req, ctx) => container.get('watchHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - // Miscellaneous routes - router.register({ - method: 'GET', - path: '/', - handler: async (req, ctx) => container.get('miscHandler').handle(req, ctx) - }); - - router.register({ - method: 'GET', - path: '/api/ping', - handler: async (req, ctx) => container.get('miscHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'GET', - path: '/api/commands', - handler: async (req, ctx) => container.get('miscHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); - - router.register({ - method: 'GET', - path: '/api/version', - handler: async (req, ctx) => container.get('miscHandler').handle(req, ctx), - middleware: [container.get('loggingMiddleware')] - }); -} diff --git a/packages/sandbox-container/src/server.ts b/packages/sandbox-container/src/server.ts index 992c60972..2ff479e18 100644 --- a/packages/sandbox-container/src/server.ts +++ b/packages/sandbox-container/src/server.ts @@ -2,15 +2,8 @@ import { createLogger } from '@repo/shared'; import type { ServerWebSocket } from 'bun'; import { serve } from 'bun'; import { type BunWebSocketTransport, newBunWebSocketRpcSession } from 'capnweb'; -import { CONFIG } from './config'; import { Container } from './core/container'; -import { Router } from './core/router'; import type { PtyWSData } from './handlers/pty-ws-handler'; -import { - type WSData as ControlWSData, - generateConnectionId, - WebSocketAdapter -} from './handlers/ws-adapter'; import { SandboxRPCAPI } from './rpc/sandbox-api'; export type CapnwebWSData = { @@ -19,19 +12,17 @@ export type CapnwebWSData = { transport?: BunWebSocketTransport; }; -export type WSData = - | (ControlWSData & { type: 'control' }) - | PtyWSData - | CapnwebWSData; - -import { setupRoutes } from './routes/setup'; +export type WSData = PtyWSData | CapnwebWSData; const logger = createLogger({ component: 'container' }); const SERVER_PORT = 3000; +let connectionCounter = 0; +function generateConnectionId(): string { + return `conn-${++connectionCounter}-${Date.now().toString(36)}`; +} + // Global error handlers to prevent fragmented stack traces in logs -// Bun's default handler writes stack traces line-by-line to stderr, -// which Cloudflare captures as separate log entries process.on('uncaughtException', (error) => { logger.error('Uncaught exception', error); process.exit(1); @@ -54,20 +45,11 @@ async function createApplication(): Promise<{ server: ReturnType> ) => Promise; container: Container; - wsAdapter: WebSocketAdapter; rpcAPI: SandboxRPCAPI; }> { const container = new Container(); await container.initialize(); - const router = new Router(logger); - router.use(container.get('corsMiddleware')); - setupRoutes(router, container); - - // Create WebSocket adapter with the router for control plane multiplexing - const wsAdapter = new WebSocketAdapter(router, logger); - - // Create native RPC API that calls services directly (bypasses HTTP routing) const rpcAPI = new SandboxRPCAPI({ processService: container.get('processService'), fileService: container.get('fileService'), @@ -86,10 +68,10 @@ async function createApplication(): Promise<{ req: Request, server: ReturnType> ): Promise => { + const url = new URL(req.url); const upgradeHeader = req.headers.get('Upgrade'); - if (upgradeHeader?.toLowerCase() === 'websocket') { - const url = new URL(req.url); + if (upgradeHeader?.toLowerCase() === 'websocket') { if (url.pathname === '/ws/pty') { const sessionId = url.searchParams.get('sessionId'); if (!sessionId) { @@ -118,19 +100,6 @@ async function createApplication(): Promise<{ return new Response('WebSocket upgrade failed', { status: 500 }); } - if (url.pathname === '/ws' || url.pathname === '/api/ws') { - const upgraded = server.upgrade(req, { - data: { - type: 'control' as const, - connectionId: generateConnectionId() - } - }); - if (upgraded) { - return undefined as unknown as Response; - } - return new Response('WebSocket upgrade failed', { status: 500 }); - } - if (url.pathname === '/capnweb') { const upgraded = server.upgrade(req, { data: { @@ -145,18 +114,27 @@ async function createApplication(): Promise<{ } } - // Regular HTTP request - return router.route(req); + // Health check endpoint + if (url.pathname === '/health' || url.pathname === '/api/health') { + return new Response( + JSON.stringify({ + status: 'healthy', + timestamp: new Date().toISOString() + }), + { headers: { 'Content-Type': 'application/json' } } + ); + } + + return new Response('Not Found', { status: 404 }); }, container, - wsAdapter, rpcAPI }; } /** - * Start the HTTP API server on port 3000. - * Returns server info and a cleanup function for graceful shutdown. + * Start the container server on port 3000. + * Exposes a capnweb RPC endpoint at /capnweb and PTY WebSocket at /ws/pty. */ export async function startServer(): Promise { const app = await createApplication(); @@ -192,11 +170,6 @@ export async function startServer(): Promise { } else if (ws.data.type === 'capnweb') { const { transport } = newBunWebSocketRpcSession(ws, app.rpcAPI); ws.data.transport = transport; - logger.debug('capnweb session initialized', { - connectionId: ws.data.connectionId - }); - } else { - app.wsAdapter.onOpen(ws); } } catch (error) { logger.error( @@ -213,8 +186,6 @@ export async function startServer(): Promise { .onClose(ws as ServerWebSocket, code, reason); } else if (ws.data.type === 'capnweb') { ws.data.transport?.dispatchClose(code, reason); - } else { - app.wsAdapter.onClose(ws, code, reason); } } catch (error) { logger.error( @@ -231,8 +202,6 @@ export async function startServer(): Promise { .onMessage(ws as ServerWebSocket, message); } else if (ws.data.type === 'capnweb') { ws.data.transport?.dispatchMessage(message); - } else { - await app.wsAdapter.onMessage(ws, message); } } catch (error) { logger.error( @@ -256,9 +225,6 @@ export async function startServer(): Promise { return { port: SERVER_PORT, - // Cleanup handles application-level resources (processes, ports). - // WebSocket connections are closed automatically when the process exits - - // Bun's serve() handles transport cleanup on shutdown. cleanup: async () => { if (!app.container.isInitialized()) return; @@ -292,10 +258,6 @@ export async function startServer(): Promise { let shutdownRegistered = false; -/** - * Register graceful shutdown handlers for SIGTERM and SIGINT. - * Safe to call multiple times - handlers are only registered once. - */ export function registerShutdownHandlers(cleanup: () => Promise): void { if (shutdownRegistered) return; shutdownRegistered = true; diff --git a/packages/sandbox-container/tests/handlers/desktop-handler.test.ts b/packages/sandbox-container/tests/handlers/desktop-handler.test.ts deleted file mode 100644 index 2a8e7ede8..000000000 --- a/packages/sandbox-container/tests/handlers/desktop-handler.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { beforeEach, describe, expect, mock, test } from 'bun:test'; -import { createNoOpLogger } from '@repo/shared'; -import type { ErrorResponse } from '@repo/shared/errors'; -import type { RequestContext } from '@sandbox-container/core/types'; -import { DesktopHandler } from '@sandbox-container/handlers/desktop-handler'; - -const context: RequestContext = { - sessionId: 'test-session', - corsHeaders: { - 'Access-Control-Allow-Origin': '*' - }, - requestId: 'req-1', - timestamp: new Date() -}; - -describe('DesktopHandler', () => { - type RouteCase = { - method: 'GET' | 'POST'; - path: string; - body?: Record; - methodName: keyof typeof mockService; - }; - - let mockService: { - start: ReturnType; - stop: ReturnType; - status: ReturnType; - screenshot: ReturnType; - screenshotRegion: ReturnType; - click: ReturnType; - moveMouse: ReturnType; - mouseDown: ReturnType; - mouseUp: ReturnType; - drag: ReturnType; - scroll: ReturnType; - getCursorPosition: ReturnType; - typeText: ReturnType; - keyPress: ReturnType; - keyDown: ReturnType; - keyUp: ReturnType; - getScreenSize: ReturnType; - getProcessStatus: ReturnType; - }; - let handler: DesktopHandler; - - beforeEach(() => { - mockService = { - start: mock(() => - Promise.resolve({ - success: true, - data: { success: true, resolution: [1920, 1080], dpi: 96 } - }) - ), - stop: mock(() => - Promise.resolve({ success: true, data: { success: true } }) - ), - status: mock(() => - Promise.resolve({ - success: true, - data: { - status: 'active', - processes: {}, - resolution: [1920, 1080], - dpi: 96 - } - }) - ), - screenshot: mock(() => - Promise.resolve({ - success: true, - data: { - data: 'base64-image', - imageFormat: 'png', - width: 1920, - height: 1080 - } - }) - ), - screenshotRegion: mock(() => - Promise.resolve({ - success: true, - data: { - data: 'base64-image', - imageFormat: 'png', - width: 400, - height: 300 - } - }) - ), - click: mock(() => Promise.resolve({ success: true })), - moveMouse: mock(() => Promise.resolve({ success: true })), - mouseDown: mock(() => Promise.resolve({ success: true })), - mouseUp: mock(() => Promise.resolve({ success: true })), - drag: mock(() => Promise.resolve({ success: true })), - scroll: mock(() => Promise.resolve({ success: true })), - getCursorPosition: mock(() => - Promise.resolve({ success: true, data: { x: 120, y: 300 } }) - ), - typeText: mock(() => Promise.resolve({ success: true })), - keyPress: mock(() => Promise.resolve({ success: true })), - keyDown: mock(() => Promise.resolve({ success: true })), - keyUp: mock(() => Promise.resolve({ success: true })), - getScreenSize: mock(() => - Promise.resolve({ success: true, data: { width: 1920, height: 1080 } }) - ), - getProcessStatus: mock(() => - Promise.resolve({ - success: true, - data: { healthy: true, running: true, pid: 1234 } - }) - ) - }; - - handler = new DesktopHandler(mockService as any, createNoOpLogger()); - }); - - test('routes requests to the correct desktop service method', async () => { - const routes: RouteCase[] = [ - { - method: 'POST', - path: '/api/desktop/start', - body: { resolution: [1920, 1080] }, - methodName: 'start' - }, - { method: 'POST', path: '/api/desktop/stop', methodName: 'stop' }, - { method: 'GET', path: '/api/desktop/status', methodName: 'status' }, - { - method: 'POST', - path: '/api/desktop/screenshot', - body: { imageFormat: 'png' }, - methodName: 'screenshot' - }, - { - method: 'POST', - path: '/api/desktop/mouse/click', - body: { x: 50, y: 60, button: 'left' }, - methodName: 'click' - }, - { - method: 'POST', - path: '/api/desktop/mouse/move', - body: { x: 99, y: 100 }, - methodName: 'moveMouse' - }, - { - method: 'POST', - path: '/api/desktop/keyboard/type', - body: { text: 'hello world' }, - methodName: 'typeText' - }, - { - method: 'POST', - path: '/api/desktop/keyboard/press', - body: { key: 'Enter' }, - methodName: 'keyPress' - }, - { - method: 'GET', - path: '/api/desktop/screen/size', - methodName: 'getScreenSize' - }, - { - method: 'GET', - path: '/api/desktop/process/xvfb/status', - methodName: 'getProcessStatus' - } - ]; - - for (const route of routes) { - const request = new Request(`http://localhost${route.path}`, { - method: route.method, - body: route.body ? JSON.stringify(route.body) : undefined, - headers: route.body ? { 'Content-Type': 'application/json' } : undefined - }); - - const response = await handler.handle(request, context); - expect(response.status).toBe(200); - expect(mockService[route.methodName]).toHaveBeenCalled(); - } - }); - - test('parses request body and passes body to service', async () => { - const payload = { x: 123, y: 456, button: 'right', clickCount: 2 }; - const request = new Request('http://localhost/api/desktop/mouse/click', { - method: 'POST', - body: JSON.stringify(payload), - headers: { 'Content-Type': 'application/json' } - }); - - const response = await handler.handle(request, context); - expect(response.status).toBe(200); - expect(mockService.click).toHaveBeenCalledWith(payload); - }); - - test('returns typed response with service data on success', async () => { - const request = new Request('http://localhost/api/desktop/screenshot', { - method: 'POST', - body: JSON.stringify({ imageFormat: 'png' }), - headers: { 'Content-Type': 'application/json' } - }); - - const response = await handler.handle(request, context); - expect(response.status).toBe(200); - const body = await response.json(); - expect(body).toEqual({ - success: true, - data: 'base64-image', - imageFormat: 'png', - width: 1920, - height: 1080 - }); - }); - - test('forwards service error response when service fails', async () => { - mockService.status = mock(() => - Promise.resolve({ - success: false, - error: { - code: 'DESKTOP_NOT_STARTED', - message: 'Desktop is not running' - } - }) - ); - handler = new DesktopHandler(mockService as any, createNoOpLogger()); - - const request = new Request('http://localhost/api/desktop/status', { - method: 'GET' - }); - - const response = await handler.handle(request, context); - const body = (await response.json()) as ErrorResponse; - - expect(response.status).toBe(409); - expect(body.code).toBe('DESKTOP_NOT_STARTED'); - expect(body.message).toBe('Desktop is not running'); - }); - - test('returns error response for unknown route', async () => { - const request = new Request('http://localhost/api/desktop/unknown', { - method: 'GET' - }); - - const response = await handler.handle(request, context); - const body = (await response.json()) as ErrorResponse; - - expect(response.status).toBe(500); - expect(body.code).toBe('UNKNOWN_ERROR'); - expect(body.message).toBe('Invalid desktop endpoint'); - }); - - test('passes process name from process status route', async () => { - const request = new Request( - 'http://localhost/api/desktop/process/xvfb/status', - { - method: 'GET' - } - ); - - const response = await handler.handle(request, context); - expect(response.status).toBe(200); - expect(mockService.getProcessStatus).toHaveBeenCalledWith('xvfb'); - }); -}); diff --git a/packages/sandbox-container/tests/handlers/execute-handler.test.ts b/packages/sandbox-container/tests/handlers/execute-handler.test.ts deleted file mode 100644 index 2dcac552d..000000000 --- a/packages/sandbox-container/tests/handlers/execute-handler.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'bun:test'; -import type { - ExecResult, - ExecuteRequest, - Logger, - ProcessStartResult -} from '@repo/shared'; -import type { ErrorResponse } from '@repo/shared/errors'; -import type { - RequestContext, - ServiceResult -} from '@sandbox-container/core/types'; -import { ExecuteHandler } from '@sandbox-container/handlers/execute-handler.js'; -import type { ProcessService } from '@sandbox-container/services/process-service'; -import { mocked } from '../test-utils'; - -// Mock the service dependencies -const mockProcessService = { - executeCommand: vi.fn(), - startProcess: vi.fn(), - getProcess: vi.fn(), - killProcess: vi.fn(), - listProcesses: vi.fn() -} as unknown as ProcessService; - -const mockLogger = { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - child: vi.fn() -} as Logger; -mockLogger.child = vi.fn(() => mockLogger); - -// Mock request context -const mockContext: RequestContext = { - requestId: 'req-123', - timestamp: new Date(), - corsHeaders: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type' - }, - sessionId: 'session-456' -}; - -describe('ExecuteHandler', () => { - let executeHandler: ExecuteHandler; - - beforeEach(async () => { - // Reset all mocks before each test - vi.clearAllMocks(); - - executeHandler = new ExecuteHandler(mockProcessService, mockLogger); - }); - - describe('handle - Regular Execution', () => { - it('should execute command successfully and return response', async () => { - // Mock successful command execution - const mockCommandResult = { - success: true, - data: { - success: true, - exitCode: 0, - stdout: 'hello\\n', - stderr: '', - duration: 100 - } - } as ServiceResult<{ - success: boolean; - exitCode: number; - stdout: string; - stderr: string; - duration: number; - }>; - - mocked(mockProcessService.executeCommand).mockResolvedValue( - mockCommandResult - ); - - const request = new Request('http://localhost:3000/api/execute', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - command: 'echo "hello"', - sessionId: 'session-456' - }) - }); - - const response = await executeHandler.handle(request, mockContext); - - // Verify response - expect(response.status).toBe(200); - const responseData = (await response.json()) as ExecResult; - expect(responseData.success).toBe(true); - expect(responseData.exitCode).toBe(0); - expect(responseData.stdout).toBe('hello\\n'); - expect(responseData.command).toBe('echo "hello"'); - expect(responseData.duration).toBeDefined(); - expect(responseData.timestamp).toBeDefined(); - - // Verify service was called correctly - expect(mockProcessService.executeCommand).toHaveBeenCalledWith( - 'echo "hello"', - expect.objectContaining({ - sessionId: 'session-456' - }) - ); - }); - - it('should handle command execution errors', async () => { - // Mock successful service operation with failed command result - const mockCommandResult = { - success: true, - data: { - success: false, // Command failed - exitCode: 1, - stdout: '', - stderr: 'command not found: nonexistent-command', - duration: 50 - } - } as ServiceResult<{ - success: boolean; - exitCode: number; - stdout: string; - stderr: string; - duration: number; - }>; - - mocked(mockProcessService.executeCommand).mockResolvedValue( - mockCommandResult - ); - - const request = new Request('http://localhost:3000/api/execute', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ command: 'nonexistent-command' }) - }); - - const response = await executeHandler.handle(request, mockContext); - - // Verify response - service succeeded, command failed - expect(response.status).toBe(200); - const responseData = (await response.json()) as ExecResult; - expect(responseData.success).toBe(false); // Command failed - expect(responseData.exitCode).toBe(1); - expect(responseData.stderr).toContain('command not found'); - expect(responseData.command).toBe('nonexistent-command'); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should handle service failures (spawn errors)', async () => { - // Mock actual service failure (e.g., spawn error) - const mockServiceError = { - success: false, - error: { - message: 'Failed to spawn process', - code: 'PROCESS_ERROR' - } - } as ServiceResult; - - mocked(mockProcessService.executeCommand).mockResolvedValue( - mockServiceError - ); - - const request = new Request('http://localhost:3000/api/execute', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ command: 'ls' }) - }); - - const response = await executeHandler.handle(request, mockContext); - - // Verify error response for service failure - NEW format: {code, message, context, httpStatus} - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('PROCESS_ERROR'); - expect(responseData.message).toContain('Failed to spawn process'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.context).toBeDefined(); - }); - - // Test removed: ValidationMiddleware was deleted in Phase 0 of error consolidation - // Handlers now parse request bodies directly using parseRequestBody() - // Invalid JSON will be caught during parsing, not by missing validatedData - }); - - describe('handle - Background Execution', () => { - it('should start background process successfully', async () => { - const mockProcessResult = { - success: true as const, - data: { - id: 'proc-123', - command: 'sleep 10', - status: 'running' as const, - startTime: new Date(), - pid: 12345, - stdout: '', - stderr: '', - outputListeners: new Set< - (stream: 'stdout' | 'stderr', data: string) => void - >(), - statusListeners: new Set<(status: string) => void>() - } - }; - - mocked(mockProcessService.startProcess).mockResolvedValue( - mockProcessResult - ); - - const request = new Request('http://localhost:3000/api/execute', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - command: 'sleep 10', - background: true, - sessionId: 'session-456' - }) - }); - - const response = await executeHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as ProcessStartResult; - expect(responseData.success).toBe(true); - expect(responseData.processId).toBe('proc-123'); - expect(responseData.pid).toBe(12345); - expect(responseData.command).toBe('sleep 10'); - expect(responseData.timestamp).toBeDefined(); - - expect(mockProcessService.startProcess).toHaveBeenCalledWith( - 'sleep 10', - expect.objectContaining({ - sessionId: 'session-456' - }) - ); - }); - }); - - describe('handleStream - Streaming Execution', () => { - it('should return streaming response for valid command', async () => { - // Mock process service to return a readable stream - new ReadableStream({ - start(controller) { - // Simulate SSE events - controller.enqueue( - 'data: {"type":"start","timestamp":"2023-01-01T00:00:00Z"}\\n\\n' - ); - controller.enqueue( - 'data: {"type":"stdout","data":"streaming test\\n","timestamp":"2023-01-01T00:00:01Z"}\\n\\n' - ); - controller.enqueue( - 'data: {"type":"complete","exitCode":0,"timestamp":"2023-01-01T00:00:02Z"}\\n\\n' - ); - controller.close(); - } - }); - - // Mock successful process start for streaming - const mockStreamProcessResult = { - success: true as const, - data: { - id: 'stream-proc-123', - command: 'echo "streaming test"', - status: 'running' as const, - startTime: new Date(), - pid: 12345, - stdout: '', - stderr: '', - outputListeners: new Set< - (stream: 'stdout' | 'stderr', data: string) => void - >(), - statusListeners: new Set<(status: string) => void>() - } - }; - - mocked(mockProcessService.startProcess).mockResolvedValue( - mockStreamProcessResult - ); - - const request = new Request('http://localhost:3000/api/execute/stream', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ command: 'echo "streaming test"' }) - }); - - const response = await executeHandler.handle(request, mockContext); - - // Verify streaming response - expect(response.status).toBe(200); - expect(response.headers.get('Content-Type')).toBe('text/event-stream'); - expect(response.headers.get('Cache-Control')).toBe('no-cache'); - expect(response.body).toBeDefined(); - - // Verify service was called - expect(mockProcessService.startProcess).toHaveBeenCalledWith( - 'echo "streaming test"', - expect.any(Object) - ); - }); - }); -}); diff --git a/packages/sandbox-container/tests/handlers/file-handler.test.ts b/packages/sandbox-container/tests/handlers/file-handler.test.ts deleted file mode 100644 index da5b4c4c2..000000000 --- a/packages/sandbox-container/tests/handlers/file-handler.test.ts +++ /dev/null @@ -1,783 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'bun:test'; -import type { - DeleteFileResult, - FileExistsResult, - Logger, - MkdirResult, - MoveFileResult, - ReadFileResult, - RenameFileResult, - WriteFileResult -} from '@repo/shared'; -import type { ErrorResponse } from '@repo/shared/errors'; -import type { RequestContext } from '@sandbox-container/core/types'; -import { FileHandler } from '@sandbox-container/handlers/file-handler'; -import type { FileService } from '@sandbox-container/services/file-service'; - -// Mock the dependencies - use partial mock to avoid missing properties -const mockFileService = { - readFile: vi.fn(), - writeFile: vi.fn(), - deleteFile: vi.fn(), - renameFile: vi.fn(), - moveFile: vi.fn(), - createDirectory: vi.fn(), - read: vi.fn(), - write: vi.fn(), - delete: vi.fn(), - rename: vi.fn(), - move: vi.fn(), - mkdir: vi.fn(), - exists: vi.fn(), - stat: vi.fn(), - getFileStats: vi.fn() - // Remove private properties to avoid type conflicts -} as unknown as FileService; - -const mockLogger = { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - child: vi.fn() -} as Logger; -mockLogger.child = vi.fn(() => mockLogger); - -// Mock request context -const mockContext: RequestContext = { - requestId: 'req-123', - timestamp: new Date(), - corsHeaders: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type' - }, - sessionId: 'session-456' -}; - -describe('FileHandler', () => { - let fileHandler: FileHandler; - - beforeEach(async () => { - // Reset all mocks before each test - vi.clearAllMocks(); - - fileHandler = new FileHandler(mockFileService, mockLogger); - }); - - describe('handleRead - POST /api/read', () => { - it('should read file successfully', async () => { - const readFileData = { - path: '/tmp/test.txt', - encoding: 'utf-8', - sessionId: 'session-id' - }; - const fileContent = 'Hello, World!'; - - (mockFileService.readFile as any).mockResolvedValue({ - success: true, - data: fileContent - }); - - const request = new Request('http://localhost:3000/api/read', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(readFileData) - }); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as ReadFileResult; - expect(responseData.success).toBe(true); - expect(responseData.content).toBe(fileContent); - expect(responseData.path).toBe('/tmp/test.txt'); - expect(responseData.timestamp).toBeDefined(); - - // Verify service was called correctly - expect(mockFileService.readFile).toHaveBeenCalledWith( - '/tmp/test.txt', - { - encoding: 'utf-8' - }, - 'session-id' - ); - }); - - it('should pass undefined encoding when not specified', async () => { - const readFileData = { - path: '/tmp/test.txt' - // encoding not specified - }; - - (mockFileService.readFile as any).mockResolvedValue({ - success: true, - data: 'file content' - }); - - const request = new Request('http://localhost:3000/api/read', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(readFileData) - }); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as ReadFileResult; - expect(responseData.success).toBe(true); - expect(responseData.content).toBeDefined(); - expect(responseData.path).toBe('/tmp/test.txt'); - expect(responseData.timestamp).toBeDefined(); - - expect(mockFileService.readFile).toHaveBeenCalledWith( - '/tmp/test.txt', - { - encoding: undefined - }, - undefined - ); - }); - - it('should handle file read errors', async () => { - const readFileData = { path: '/tmp/nonexistent.txt' }; - - (mockFileService.readFile as any).mockResolvedValue({ - success: false, - error: { - message: 'File not found', - code: 'FILE_NOT_FOUND', - details: { path: '/tmp/nonexistent.txt' } - } - }); - - const request = new Request('http://localhost:3000/api/read', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(readFileData) - }); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.status).toBe(404); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('FILE_NOT_FOUND'); - expect(responseData.message).toBe('File not found'); - expect(responseData.httpStatus).toBe(404); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('handleWrite - POST /api/write', () => { - it('should write file successfully', async () => { - const writeFileData = { - path: '/tmp/output.txt', - content: 'Hello, File!', - encoding: 'utf-8', - sessionId: 'session-123' - }; - - (mockFileService.writeFile as any).mockResolvedValue({ - success: true - }); - - const request = new Request('http://localhost:3000/api/write', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(writeFileData) - }); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as WriteFileResult; - expect(responseData.success).toBe(true); - expect(responseData.path).toBe('/tmp/output.txt'); // ✅ Check path field - expect(responseData.timestamp).toBeDefined(); - - // Verify service was called correctly - expect(mockFileService.writeFile).toHaveBeenCalledWith( - '/tmp/output.txt', - 'Hello, File!', - { - encoding: 'utf-8' - }, - 'session-123' - ); - }); - - it('should pass undefined sessionId when not provided', async () => { - const writeFileData = { - path: '/tmp/output.txt', - content: 'Hello, File!' - }; - - (mockFileService.writeFile as any).mockResolvedValue({ - success: true - }); - - const request = new Request('http://localhost:3000/api/write', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(writeFileData) - }); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - expect(mockFileService.writeFile).toHaveBeenCalledWith( - '/tmp/output.txt', - 'Hello, File!', - {}, - undefined - ); - }); - - it('should handle file write errors', async () => { - const writeFileData = { - path: '/readonly/file.txt', - content: 'content' - }; - - (mockFileService.writeFile as any).mockResolvedValue({ - success: false, - error: { - message: 'Permission denied', - code: 'PERMISSION_DENIED', - details: { path: '/readonly/file.txt' } - } - }); - - const request = new Request('http://localhost:3000/api/write', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(writeFileData) - }); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.status).toBe(403); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('PERMISSION_DENIED'); - expect(responseData.message).toBe('Permission denied'); - expect(responseData.httpStatus).toBe(403); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('handleDelete - POST /api/delete', () => { - it('should delete file successfully', async () => { - const deleteFileData = { - path: '/tmp/delete-me.txt' - }; - - (mockFileService.deleteFile as any).mockResolvedValue({ - success: true - }); - - const request = new Request('http://localhost:3000/api/delete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(deleteFileData) - }); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as DeleteFileResult; - expect(responseData.success).toBe(true); - expect(responseData.path).toBe('/tmp/delete-me.txt'); // ✅ Check path field - expect(responseData.timestamp).toBeDefined(); - - expect(mockFileService.deleteFile).toHaveBeenCalledWith( - '/tmp/delete-me.txt' - ); - }); - - it('should handle file delete errors', async () => { - const deleteFileData = { path: '/tmp/nonexistent.txt' }; - - (mockFileService.deleteFile as any).mockResolvedValue({ - success: false, - error: { - message: 'File not found', - code: 'FILE_NOT_FOUND' - } - }); - - const request = new Request('http://localhost:3000/api/delete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(deleteFileData) - }); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.status).toBe(404); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('FILE_NOT_FOUND'); - expect(responseData.message).toBe('File not found'); - expect(responseData.httpStatus).toBe(404); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('handleRename - POST /api/rename', () => { - it('should rename file successfully', async () => { - const renameFileData = { - oldPath: '/tmp/old-name.txt', - newPath: '/tmp/new-name.txt' - }; - - (mockFileService.renameFile as any).mockResolvedValue({ - success: true - }); - - const request = new Request('http://localhost:3000/api/rename', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(renameFileData) - }); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as RenameFileResult; - expect(responseData.success).toBe(true); - expect(responseData.path).toBe('/tmp/old-name.txt'); - expect(responseData.newPath).toBe('/tmp/new-name.txt'); - expect(responseData.timestamp).toBeDefined(); - - expect(mockFileService.renameFile).toHaveBeenCalledWith( - '/tmp/old-name.txt', - '/tmp/new-name.txt' - ); - }); - - it('should handle file rename errors', async () => { - const renameFileData = { - oldPath: '/tmp/nonexistent.txt', - newPath: '/tmp/renamed.txt' - }; - - (mockFileService.renameFile as any).mockResolvedValue({ - success: false, - error: { - message: 'Source file not found', - code: 'FILE_NOT_FOUND' - } - }); - - const request = new Request('http://localhost:3000/api/rename', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(renameFileData) - }); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.status).toBe(404); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('FILE_NOT_FOUND'); - expect(responseData.message).toBe('Source file not found'); - expect(responseData.httpStatus).toBe(404); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('handleMove - POST /api/move', () => { - it('should move file successfully', async () => { - const moveFileData = { - sourcePath: '/tmp/source.txt', - destinationPath: '/tmp/destination.txt' - }; - - (mockFileService.moveFile as any).mockResolvedValue({ - success: true - }); - - const request = new Request('http://localhost:3000/api/move', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(moveFileData) - }); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as MoveFileResult; - expect(responseData.success).toBe(true); - expect(responseData.path).toBe('/tmp/source.txt'); - expect(responseData.newPath).toBe('/tmp/destination.txt'); - expect(responseData.timestamp).toBeDefined(); - - expect(mockFileService.moveFile).toHaveBeenCalledWith( - '/tmp/source.txt', - '/tmp/destination.txt' - ); - }); - - it('should handle file move errors', async () => { - const moveFileData = { - sourcePath: '/tmp/source.txt', - destinationPath: '/readonly/destination.txt' - }; - - (mockFileService.moveFile as any).mockResolvedValue({ - success: false, - error: { - message: 'Permission denied on destination', - code: 'PERMISSION_DENIED' - } - }); - - const request = new Request('http://localhost:3000/api/move', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(moveFileData) - }); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.status).toBe(403); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('PERMISSION_DENIED'); - expect(responseData.message).toBe('Permission denied on destination'); - expect(responseData.httpStatus).toBe(403); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('handleMkdir - POST /api/mkdir', () => { - it('should create directory successfully', async () => { - const mkdirData = { - path: '/tmp/new-directory', - recursive: true - }; - - (mockFileService.createDirectory as any).mockResolvedValue({ - success: true - }); - - const request = new Request('http://localhost:3000/api/mkdir', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(mkdirData) - }); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as MkdirResult; - expect(responseData.success).toBe(true); - expect(responseData.path).toBe('/tmp/new-directory'); - expect(responseData.recursive).toBe(true); - expect(responseData.timestamp).toBeDefined(); - - expect(mockFileService.createDirectory).toHaveBeenCalledWith( - '/tmp/new-directory', - { - recursive: true - } - ); - }); - - it('should create directory without recursive option', async () => { - const mkdirData = { - path: '/tmp/simple-dir' - // recursive not specified - }; - - (mockFileService.createDirectory as any).mockResolvedValue({ - success: true - }); - - const request = new Request('http://localhost:3000/api/mkdir', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(mkdirData) - }); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as MkdirResult; - expect(responseData.success).toBe(true); - expect(responseData.path).toBe('/tmp/simple-dir'); - expect(responseData.recursive).toBe(false); - expect(responseData.timestamp).toBeDefined(); - - expect(mockFileService.createDirectory).toHaveBeenCalledWith( - '/tmp/simple-dir', - { - recursive: undefined - } - ); - }); - - it('should handle directory creation errors', async () => { - const mkdirData = { - path: '/readonly/new-dir', - recursive: false - }; - - (mockFileService.createDirectory as any).mockResolvedValue({ - success: false, - error: { - message: 'Permission denied', - code: 'PERMISSION_DENIED' - } - }); - - const request = new Request('http://localhost:3000/api/mkdir', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(mkdirData) - }); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.status).toBe(403); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('PERMISSION_DENIED'); - expect(responseData.message).toBe('Permission denied'); - expect(responseData.httpStatus).toBe(403); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('handleExists - POST /api/exists', () => { - it('should return true when file exists', async () => { - const existsData = { - path: '/tmp/test.txt', - sessionId: 'session-123' - }; - - (mockFileService.exists as any).mockResolvedValue({ - success: true, - data: true - }); - - const request = new Request('http://localhost:3000/api/exists', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(existsData) - }); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as FileExistsResult; - expect(responseData.success).toBe(true); - expect(responseData.exists).toBe(true); - expect(responseData.path).toBe('/tmp/test.txt'); - expect(responseData.timestamp).toBeDefined(); - - expect(mockFileService.exists).toHaveBeenCalledWith( - '/tmp/test.txt', - 'session-123' - ); - }); - - it('should return false when file does not exist', async () => { - const existsData = { - path: '/tmp/nonexistent.txt', - sessionId: 'session-123' - }; - - (mockFileService.exists as any).mockResolvedValue({ - success: true, - data: false - }); - - const request = new Request('http://localhost:3000/api/exists', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(existsData) - }); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as FileExistsResult; - expect(responseData.success).toBe(true); - expect(responseData.exists).toBe(false); - }); - - it('should handle errors when checking file existence', async () => { - const existsData = { - path: '/invalid/path', - sessionId: 'session-123' - }; - - (mockFileService.exists as any).mockResolvedValue({ - success: false, - error: { - message: 'Invalid path', - code: 'VALIDATION_FAILED', - httpStatus: 400 - } - }); - - const request = new Request('http://localhost:3000/api/exists', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(existsData) - }); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.status).toBe(400); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('VALIDATION_FAILED'); - }); - }); - - describe('route handling', () => { - it('should return 500 for invalid endpoints', async () => { - const request = new Request( - 'http://localhost:3000/api/invalid-operation', - { - method: 'POST' - } - ); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.message).toContain('Invalid file endpoint'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should handle root path correctly', async () => { - const request = new Request('http://localhost:3000/', { - method: 'GET' - }); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.message).toContain('Invalid file endpoint'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('CORS headers', () => { - it('should include CORS headers in all successful responses', async () => { - const readFileData = { path: '/tmp/test.txt' }; - - (mockFileService.readFile as any).mockResolvedValue({ - success: true, - data: 'file content' - }); - - const request = new Request('http://localhost:3000/api/read', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(readFileData) - }); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); - expect(response.headers.get('Access-Control-Allow-Methods')).toBe( - 'GET, POST, OPTIONS' - ); - expect(response.headers.get('Access-Control-Allow-Headers')).toBe( - 'Content-Type' - ); - }); - - it('should include CORS headers in error responses', async () => { - const request = new Request('http://localhost:3000/api/invalid', { - method: 'POST' - }); - - const response = await fileHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); - }); - }); - - describe('response format consistency', () => { - it('should have domain-specific response formats for each operation', async () => { - // Test all operations return correct domain-specific fields - const operations = [ - { - endpoint: '/api/read', - data: { path: '/tmp/test.txt' }, - mockResponse: { success: true, data: 'content' }, - expectedFields: ['success', 'path', 'content', 'timestamp'] - }, - { - endpoint: '/api/write', - data: { path: '/tmp/test.txt', content: 'data' }, - mockResponse: { success: true }, - expectedFields: ['success', 'path', 'timestamp'] - }, - { - endpoint: '/api/delete', - data: { path: '/tmp/test.txt' }, - mockResponse: { success: true }, - expectedFields: ['success', 'path', 'timestamp'] - } - ]; - - for (const operation of operations) { - // Reset mocks - vi.clearAllMocks(); - - // Mock appropriate service method - if (operation.endpoint === '/api/read') { - (mockFileService.readFile as any).mockResolvedValue( - operation.mockResponse - ); - } else if (operation.endpoint === '/api/write') { - (mockFileService.writeFile as any).mockResolvedValue( - operation.mockResponse - ); - } else if (operation.endpoint === '/api/delete') { - (mockFileService.deleteFile as any).mockResolvedValue( - operation.mockResponse - ); - } - - const request = new Request( - `http://localhost:3000${operation.endpoint}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(operation.data) - } - ); - - const response = await fileHandler.handle(request, mockContext); - const responseData = (await response.json()) as - | ReadFileResult - | WriteFileResult - | DeleteFileResult; - - // Check that all expected fields are present - for (const field of operation.expectedFields) { - expect(responseData).toHaveProperty(field); - } - - // Ensure no generic 'data' wrapper exists (except for read which has 'content') - if (operation.endpoint !== '/api/read') { - expect(responseData).not.toHaveProperty('data'); - } - - // Check common fields - expect(responseData.success).toBe(true); - expect(responseData.timestamp).toBeDefined(); - } - }); - }); -}); diff --git a/packages/sandbox-container/tests/handlers/git-handler.test.ts b/packages/sandbox-container/tests/handlers/git-handler.test.ts deleted file mode 100644 index 693177ada..000000000 --- a/packages/sandbox-container/tests/handlers/git-handler.test.ts +++ /dev/null @@ -1,506 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'bun:test'; -import type { GitCheckoutResult, Logger } from '@repo/shared'; -import type { ErrorResponse } from '@repo/shared/errors'; -import type { RequestContext } from '@sandbox-container/core/types'; -import { GitHandler } from '@sandbox-container/handlers/git-handler'; -import type { GitService } from '@sandbox-container/services/git-service'; - -// Mock the dependencies - use partial mock to avoid private property issues -const mockGitService = { - cloneRepository: vi.fn(), - checkoutBranch: vi.fn(), - getCurrentBranch: vi.fn(), - listBranches: vi.fn() -} as unknown as GitService; - -const mockLogger = { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - child: vi.fn() -} as Logger; -mockLogger.child = vi.fn(() => mockLogger); - -// Mock request context -const mockContext: RequestContext = { - requestId: 'req-123', - timestamp: new Date(), - corsHeaders: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type' - }, - sessionId: 'session-456' -}; - -describe('GitHandler', () => { - let gitHandler: GitHandler; - - beforeEach(async () => { - // Reset all mocks before each test - vi.clearAllMocks(); - - gitHandler = new GitHandler(mockGitService, mockLogger); - }); - - describe('handleCheckout - POST /api/git/checkout', () => { - it('should clone repository successfully with all options', async () => { - const gitCheckoutData = { - repoUrl: 'https://github.com/user/awesome-repo.git', - branch: 'develop', - targetDir: '/tmp/my-project', - sessionId: 'session-456' - }; - - const mockGitResult = { - path: '/tmp/my-project', - branch: 'develop' - }; - - (mockGitService.cloneRepository as any).mockResolvedValue({ - success: true, - data: mockGitResult - }); - - const request = new Request('http://localhost:3000/api/git/checkout', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(gitCheckoutData) - }); - - const response = await gitHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as GitCheckoutResult; - expect(responseData.success).toBe(true); - expect(responseData.repoUrl).toBe( - 'https://github.com/user/awesome-repo.git' - ); - expect(responseData.targetDir).toBe('/tmp/my-project'); - expect(responseData.branch).toBe('develop'); - expect(responseData.timestamp).toBeDefined(); - - // Verify service was called correctly - expect(mockGitService.cloneRepository).toHaveBeenCalledWith( - 'https://github.com/user/awesome-repo.git', - { - branch: 'develop', - targetDir: '/tmp/my-project', - sessionId: 'session-456' - } - ); - }); - - it('should clone repository with minimal options', async () => { - const gitCheckoutData = { - repoUrl: 'https://github.com/user/simple-repo.git' - // branch, targetDir, sessionId not provided - }; - - const mockGitResult = { - path: '/tmp/git-clone-simple-repo-1672531200-abc123', - branch: 'main' - }; - - (mockGitService.cloneRepository as any).mockResolvedValue({ - success: true, - data: mockGitResult - }); - - const request = new Request('http://localhost:3000/api/git/checkout', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(gitCheckoutData) - }); - - const response = await gitHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as GitCheckoutResult; - expect(responseData.success).toBe(true); - expect(responseData.repoUrl).toBe( - 'https://github.com/user/simple-repo.git' - ); - expect(responseData.branch).toBe('main'); // Service returned branch - expect(responseData.targetDir).toBe( - '/tmp/git-clone-simple-repo-1672531200-abc123' - ); // Generated path - expect(responseData.timestamp).toBeDefined(); - - // Verify service was called with correct parameters - expect(mockGitService.cloneRepository).toHaveBeenCalledWith( - 'https://github.com/user/simple-repo.git', - { - branch: undefined, - targetDir: undefined, - sessionId: 'session-456' // From mockContext - } - ); - }); - - it('should handle git URL validation errors', async () => { - const gitCheckoutData = { - repoUrl: 'invalid-url-format', - branch: 'main' - }; - - (mockGitService.cloneRepository as any).mockResolvedValue({ - success: false, - error: { - message: 'Git URL validation failed: Invalid URL scheme', - code: 'INVALID_GIT_URL', - details: { - repoUrl: 'invalid-url-format', - errors: ['Invalid URL scheme'] - } - } - }); - - const request = new Request('http://localhost:3000/api/git/checkout', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(gitCheckoutData) - }); - - const response = await gitHandler.handle(request, mockContext); - - expect(response.status).toBe(400); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('INVALID_GIT_URL'); - expect(responseData.message).toContain('Invalid URL scheme'); - expect(responseData.httpStatus).toBe(400); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should handle target directory validation errors', async () => { - const gitCheckoutData = { - repoUrl: 'https://github.com/user/repo.git', - targetDir: '/malicious/../path' - }; - - (mockGitService.cloneRepository as any).mockResolvedValue({ - success: false, - error: { - message: 'Target directory validation failed: Path outside sandbox', - code: 'VALIDATION_FAILED', - details: { - targetDirectory: '/malicious/../path', - errors: ['Path outside sandbox'] - } - } - }); - - const request = new Request('http://localhost:3000/api/git/checkout', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(gitCheckoutData) - }); - - const response = await gitHandler.handle(request, mockContext); - - expect(response.status).toBe(400); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('VALIDATION_FAILED'); - expect(responseData.message).toContain('Path outside sandbox'); - expect(responseData.httpStatus).toBe(400); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should handle git clone command failures', async () => { - const gitCheckoutData = { - repoUrl: 'https://github.com/user/nonexistent-repo.git', - branch: 'main' - }; - - (mockGitService.cloneRepository as any).mockResolvedValue({ - success: false, - error: { - message: 'Git clone operation failed', - code: 'GIT_CLONE_FAILED', - details: { - repoUrl: 'https://github.com/user/nonexistent-repo.git', - exitCode: 128, - stderr: - "fatal: repository 'https://github.com/user/nonexistent-repo.git' not found" - } - } - }); - - const request = new Request('http://localhost:3000/api/git/checkout', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(gitCheckoutData) - }); - - const response = await gitHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('GIT_CLONE_FAILED'); - expect(responseData.context.exitCode).toBe(128); - expect(responseData.context.stderr).toContain('repository'); - expect(responseData.context.stderr).toContain('not found'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should handle invalid branch names', async () => { - const gitCheckoutData = { - repoUrl: 'https://github.com/user/repo.git', - branch: 'nonexistent-branch' - }; - - (mockGitService.cloneRepository as any).mockResolvedValue({ - success: false, - error: { - message: 'Git clone operation failed', - code: 'GIT_CLONE_FAILED', - details: { - repoUrl: 'https://github.com/user/repo.git', - exitCode: 128, - stderr: - 'fatal: Remote branch nonexistent-branch not found in upstream origin' - } - } - }); - - const request = new Request('http://localhost:3000/api/git/checkout', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(gitCheckoutData) - }); - - const response = await gitHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('GIT_CLONE_FAILED'); - expect(responseData.context.stderr).toContain( - 'nonexistent-branch not found' - ); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should handle service exceptions', async () => { - const gitCheckoutData = { - repoUrl: 'https://github.com/user/repo.git' - }; - - (mockGitService.cloneRepository as any).mockResolvedValue({ - success: false, - error: { - message: 'Failed to clone repository', - code: 'GIT_OPERATION_FAILED', - details: { - repoUrl: 'https://github.com/user/repo.git', - originalError: 'Command not found' - } - } - }); - - const request = new Request('http://localhost:3000/api/git/checkout', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(gitCheckoutData) - }); - - const response = await gitHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('GIT_OPERATION_FAILED'); - expect(responseData.context.originalError).toBe('Command not found'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('route handling', () => { - it('should return 404 for invalid git endpoints', async () => { - const request = new Request( - 'http://localhost:3000/api/git/invalid-operation', - { - method: 'POST' - } - ); - - const response = await gitHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.message).toBe('Invalid git endpoint'); - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - - // Should not call any service methods - expect(mockGitService.cloneRepository).not.toHaveBeenCalled(); - }); - - it('should return 500 for root git path', async () => { - const request = new Request('http://localhost:3000/api/git/', { - method: 'POST' - }); - - const response = await gitHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.message).toBe('Invalid git endpoint'); - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should return 500 for git endpoint without operation', async () => { - const request = new Request('http://localhost:3000/api/git', { - method: 'POST' - }); - - const response = await gitHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.message).toBe('Invalid git endpoint'); - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('CORS headers', () => { - it('should include CORS headers in successful responses', async () => { - const gitCheckoutData = { - repoUrl: 'https://github.com/user/repo.git' - }; - - (mockGitService.cloneRepository as any).mockResolvedValue({ - success: true, - data: { path: '/tmp/repo', branch: 'main' } - }); - - const request = new Request('http://localhost:3000/api/git/checkout', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(gitCheckoutData) - }); - - const response = await gitHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); - expect(response.headers.get('Access-Control-Allow-Methods')).toBe( - 'GET, POST, OPTIONS' - ); - expect(response.headers.get('Access-Control-Allow-Headers')).toBe( - 'Content-Type' - ); - }); - - it('should include CORS headers in error responses', async () => { - const request = new Request('http://localhost:3000/api/git/invalid', { - method: 'POST' - }); - - const response = await gitHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); - }); - }); - - describe('response format consistency', () => { - it('should return consistent response format for successful clones', async () => { - const gitCheckoutData = { - repoUrl: 'https://github.com/user/repo.git', - branch: 'feature-branch', - targetDir: '/tmp/feature-work' - }; - - (mockGitService.cloneRepository as any).mockResolvedValue({ - success: true, - data: { path: '/tmp/feature-work', branch: 'feature-branch' } - }); - - const request = new Request('http://localhost:3000/api/git/checkout', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(gitCheckoutData) - }); - - const response = await gitHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as GitCheckoutResult; - - // Verify all expected fields are present - const expectedFields = [ - 'success', - 'repoUrl', - 'branch', - 'targetDir', - 'timestamp' - ]; - for (const field of expectedFields) { - expect(responseData).toHaveProperty(field); - } - - // Verify field values - expect(responseData.success).toBe(true); - expect(responseData.repoUrl).toBe('https://github.com/user/repo.git'); - expect(responseData.targetDir).toBe('/tmp/feature-work'); - expect(responseData.branch).toBe('feature-branch'); - expect(responseData.timestamp).toBeDefined(); - expect(new Date(responseData.timestamp)).toBeInstanceOf(Date); - }); - - it('should have proper Content-Type header', async () => { - const gitCheckoutData = { repoUrl: 'https://github.com/user/repo.git' }; - - (mockGitService.cloneRepository as any).mockResolvedValue({ - success: true, - data: { path: '/tmp/repo', branch: 'main' } - }); - - const request = new Request('http://localhost:3000/api/git/checkout', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(gitCheckoutData) - }); - - const response = await gitHandler.handle(request, mockContext); - - expect(response.headers.get('Content-Type')).toBe('application/json'); - }); - }); - - describe('depth option', () => { - it('should pass depth option to service', async () => { - const gitCheckoutData = { - repoUrl: 'https://github.com/user/repo.git', - depth: 1 - }; - - (mockGitService.cloneRepository as any).mockResolvedValue({ - success: true, - data: { path: '/workspace/repo', branch: 'main' } - }); - - const request = new Request('http://localhost:3000/api/git/checkout', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(gitCheckoutData) - }); - - const response = await gitHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - expect(mockGitService.cloneRepository).toHaveBeenCalledWith( - 'https://github.com/user/repo.git', - expect.objectContaining({ depth: 1 }) - ); - }); - }); -}); diff --git a/packages/sandbox-container/tests/handlers/interpreter-handler.test.ts b/packages/sandbox-container/tests/handlers/interpreter-handler.test.ts deleted file mode 100644 index 9a7a87d76..000000000 --- a/packages/sandbox-container/tests/handlers/interpreter-handler.test.ts +++ /dev/null @@ -1,544 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'bun:test'; -import type { - ContextCreateResult, - ContextDeleteResult, - ContextListResult, - InterpreterHealthResult, - Logger -} from '@repo/shared'; -import type { ErrorResponse } from '@repo/shared/errors'; -import { ErrorCode } from '@repo/shared/errors'; -import type { - RequestContext, - ServiceResult -} from '@sandbox-container/core/types'; -import { InterpreterHandler } from '@sandbox-container/handlers/interpreter-handler.js'; -import type { - Context, - CreateContextRequest, - HealthStatus, - InterpreterService -} from '@sandbox-container/services/interpreter-service'; -import { mocked } from '../test-utils'; - -// Mock the service dependencies -const mockInterpreterService = { - getHealthStatus: vi.fn(), - createContext: vi.fn(), - listContexts: vi.fn(), - deleteContext: vi.fn(), - executeCode: vi.fn() -} as unknown as InterpreterService; - -const mockLogger = { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - child: vi.fn() -} as Logger; -mockLogger.child = vi.fn(() => mockLogger); - -// Mock request context -const mockContext: RequestContext = { - requestId: 'req-123', - timestamp: new Date(), - corsHeaders: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type' - }, - sessionId: 'session-456' -}; - -describe('InterpreterHandler', () => { - let interpreterHandler: InterpreterHandler; - - beforeEach(async () => { - // Reset all mocks before each test - vi.clearAllMocks(); - - interpreterHandler = new InterpreterHandler( - mockInterpreterService, - mockLogger - ); - }); - - describe('handle - Health Check', () => { - it('should return healthy status when interpreter is ready', async () => { - // Mock successful health check - const mockHealthResult = { - success: true, - data: { - ready: true, - initializing: false, - progress: 1.0 - } - } as ServiceResult; - - mocked(mockInterpreterService.getHealthStatus).mockResolvedValue( - mockHealthResult - ); - - const request = new Request( - 'http://localhost:3000/api/interpreter/health', - { - method: 'GET' - } - ); - - const response = await interpreterHandler.handle(request, mockContext); - - // Verify success response: {success: true, status, timestamp} - expect(response.status).toBe(200); - const responseData = (await response.json()) as InterpreterHealthResult; - expect(responseData.success).toBe(true); - expect(responseData.status).toBe('healthy'); - expect(responseData.timestamp).toBeDefined(); - - // Verify service was called - expect(mockInterpreterService.getHealthStatus).toHaveBeenCalled(); - }); - - it('should return error when health check fails', async () => { - // Mock health check failure - const mockHealthError = { - success: false, - error: { - message: 'Interpreter not initialized', - code: 'INTERPRETER_NOT_READY', - details: { retryAfter: 5 } - } - } as ServiceResult; - - mocked(mockInterpreterService.getHealthStatus).mockResolvedValue( - mockHealthError - ); - - const request = new Request( - 'http://localhost:3000/api/interpreter/health', - { - method: 'GET' - } - ); - - const response = await interpreterHandler.handle(request, mockContext); - - // Verify error response: {code, message, context, httpStatus, timestamp} - expect(response.status).toBeGreaterThanOrEqual(400); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('INTERPRETER_NOT_READY'); - expect(responseData.message).toBe('Interpreter not initialized'); - expect(responseData.context).toBeDefined(); - expect(responseData.httpStatus).toBeDefined(); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('handle - Create Context', () => { - it('should create context successfully', async () => { - // Mock successful context creation - const mockContextResult = { - success: true, - data: { - id: 'ctx-123', - language: 'python', - cwd: '/workspace', - createdAt: '2023-01-01T00:00:00Z', - lastUsed: '2023-01-01T00:00:00Z' - } - } as ServiceResult; - - mocked(mockInterpreterService.createContext).mockResolvedValue( - mockContextResult - ); - - const contextRequest: CreateContextRequest = { - language: 'python', - cwd: '/workspace' - }; - - const request = new Request('http://localhost:3000/api/contexts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(contextRequest) - }); - - const response = await interpreterHandler.handle(request, mockContext); - - // Verify success response: {success: true, contextId, language, cwd, timestamp} - expect(response.status).toBe(200); - const responseData = (await response.json()) as ContextCreateResult; - expect(responseData.success).toBe(true); - expect(responseData.contextId).toBe('ctx-123'); - expect(responseData.language).toBe('python'); - expect(responseData.cwd).toBe('/workspace'); - expect(responseData.timestamp).toBeDefined(); - - // Verify service was called correctly - expect(mockInterpreterService.createContext).toHaveBeenCalledWith( - contextRequest - ); - }); - - it('should handle context creation errors', async () => { - // Mock context creation failure - const mockContextError = { - success: false, - error: { - message: 'Invalid language specified', - code: ErrorCode.VALIDATION_FAILED, - details: { language: 'invalid-lang' } - } - } as ServiceResult; - - mocked(mockInterpreterService.createContext).mockResolvedValue( - mockContextError - ); - - const contextRequest: CreateContextRequest = { - language: 'invalid-lang', - cwd: '/workspace' - }; - - const request = new Request('http://localhost:3000/api/contexts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(contextRequest) - }); - - const response = await interpreterHandler.handle(request, mockContext); - - // Verify error response: {code, message, context, httpStatus, timestamp} - expect(response.status).toBeGreaterThanOrEqual(400); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe(ErrorCode.VALIDATION_FAILED); - expect(responseData.message).toBe('Invalid language specified'); - expect(responseData.context).toMatchObject({ language: 'invalid-lang' }); - expect(responseData.httpStatus).toBeDefined(); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should return 503 with Retry-After for INTERPRETER_NOT_READY', async () => { - // Mock interpreter not ready error - const mockNotReadyError = { - success: false, - error: { - message: 'Interpreter is still initializing', - code: 'INTERPRETER_NOT_READY', - details: { retryAfter: 10 } - } - } as ServiceResult; - - mocked(mockInterpreterService.createContext).mockResolvedValue( - mockNotReadyError - ); - - const contextRequest: CreateContextRequest = { - language: 'python', - cwd: '/workspace' - }; - - const request = new Request('http://localhost:3000/api/contexts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(contextRequest) - }); - - const response = await interpreterHandler.handle(request, mockContext); - - // Verify 503 status with Retry-After header - expect(response.status).toBe(503); - expect(response.headers.get('Retry-After')).toBe('10'); - - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('INTERPRETER_NOT_READY'); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('handle - List Contexts', () => { - it('should list all contexts successfully', async () => { - // Mock successful context listing - const mockContexts = { - success: true, - data: [ - { - id: 'ctx-1', - language: 'python', - cwd: '/workspace1', - createdAt: '2023-01-01T00:00:00Z', - lastUsed: '2023-01-01T00:00:00Z' - }, - { - id: 'ctx-2', - language: 'javascript', - cwd: '/workspace2', - createdAt: '2023-01-01T00:00:00Z', - lastUsed: '2023-01-01T00:00:00Z' - } - ] - } as ServiceResult; - - mocked(mockInterpreterService.listContexts).mockResolvedValue( - mockContexts - ); - - const request = new Request('http://localhost:3000/api/contexts', { - method: 'GET' - }); - - const response = await interpreterHandler.handle(request, mockContext); - - // Verify success response: {success: true, contexts, timestamp} - expect(response.status).toBe(200); - const responseData = (await response.json()) as ContextListResult; - expect(responseData.success).toBe(true); - expect(responseData.contexts).toHaveLength(2); - expect(responseData.contexts[0].id).toBe('ctx-1'); - expect(responseData.contexts[0].language).toBe('python'); - expect(responseData.contexts[0].cwd).toBe('/workspace1'); - expect(responseData.timestamp).toBeDefined(); - - // Verify service was called - expect(mockInterpreterService.listContexts).toHaveBeenCalled(); - }); - - it('should handle list contexts errors', async () => { - // Mock listing failure - const mockListError = { - success: false, - error: { - message: 'Failed to list contexts', - code: 'UNKNOWN_ERROR', - details: {} - } - } as ServiceResult; - - mocked(mockInterpreterService.listContexts).mockResolvedValue( - mockListError - ); - - const request = new Request('http://localhost:3000/api/contexts', { - method: 'GET' - }); - - const response = await interpreterHandler.handle(request, mockContext); - - // Verify error response: {code, message, context, httpStatus, timestamp} - expect(response.status).toBeGreaterThanOrEqual(400); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.message).toBe('Failed to list contexts'); - expect(responseData.httpStatus).toBeDefined(); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('handle - Delete Context', () => { - it('should delete context successfully', async () => { - // Mock successful deletion - const mockDeleteResult = { - success: true, - data: undefined - } as ServiceResult; - - mocked(mockInterpreterService.deleteContext).mockResolvedValue( - mockDeleteResult - ); - - const request = new Request( - 'http://localhost:3000/api/contexts/ctx-123', - { - method: 'DELETE' - } - ); - - const response = await interpreterHandler.handle(request, mockContext); - - // Verify success response: {success: true, contextId, timestamp} - expect(response.status).toBe(200); - const responseData = (await response.json()) as ContextDeleteResult; - expect(responseData.success).toBe(true); - expect(responseData.contextId).toBe('ctx-123'); - expect(responseData.timestamp).toBeDefined(); - - // Verify service was called with correct context ID - expect(mockInterpreterService.deleteContext).toHaveBeenCalledWith( - 'ctx-123' - ); - }); - - it('should handle delete context errors', async () => { - // Mock deletion failure - const mockDeleteError = { - success: false, - error: { - message: 'Context not found', - code: ErrorCode.CONTEXT_NOT_FOUND, - details: { contextId: 'ctx-999' } - } - } as ServiceResult; - - mocked(mockInterpreterService.deleteContext).mockResolvedValue( - mockDeleteError - ); - - const request = new Request( - 'http://localhost:3000/api/contexts/ctx-999', - { - method: 'DELETE' - } - ); - - const response = await interpreterHandler.handle(request, mockContext); - - // Verify error response: {code, message, context, httpStatus, timestamp} - expect(response.status).toBeGreaterThanOrEqual(400); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe(ErrorCode.CONTEXT_NOT_FOUND); - expect(responseData.message).toBe('Context not found'); - expect(responseData.context).toMatchObject({ contextId: 'ctx-999' }); - expect(responseData.httpStatus).toBeDefined(); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('handle - Execute Code', () => { - it('should execute code and return streaming response', async () => { - // Mock streaming response from service - const mockStream = new ReadableStream({ - start(controller) { - controller.enqueue( - 'data: {"type":"start","timestamp":"2023-01-01T00:00:00Z"}\n\n' - ); - controller.enqueue( - 'data: {"type":"stdout","data":"Hello World\\n","timestamp":"2023-01-01T00:00:01Z"}\n\n' - ); - controller.enqueue( - 'data: {"type":"complete","exitCode":0,"timestamp":"2023-01-01T00:00:02Z"}\n\n' - ); - controller.close(); - } - }); - - const mockStreamResponse = new Response(mockStream, { - status: 200, - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache' - } - }); - - mocked(mockInterpreterService.executeCode).mockResolvedValue( - mockStreamResponse - ); - - const executeRequest = { - context_id: 'ctx-123', - code: 'print("Hello World")', - language: 'python' - }; - - const request = new Request('http://localhost:3000/api/execute/code', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(executeRequest) - }); - - const response = await interpreterHandler.handle(request, mockContext); - - // Verify streaming response - expect(response.status).toBe(200); - expect(response.headers.get('Content-Type')).toBe('text/event-stream'); - expect(response.headers.get('Cache-Control')).toBe('no-cache'); - expect(response.body).toBeDefined(); - - // Verify service was called correctly - expect(mockInterpreterService.executeCode).toHaveBeenCalledWith( - 'ctx-123', - 'print("Hello World")', - 'python' - ); - }); - - it('should handle execute code errors', async () => { - // Mock error response from service - const mockErrorResponse = new Response( - JSON.stringify({ - code: ErrorCode.CONTEXT_NOT_FOUND, - message: 'Context not found', - context: { contextId: 'ctx-invalid' }, - httpStatus: 404, - timestamp: new Date().toISOString() - }), - { - status: 404, - headers: { 'Content-Type': 'application/json' } - } - ); - - mocked(mockInterpreterService.executeCode).mockResolvedValue( - mockErrorResponse - ); - - const executeRequest = { - context_id: 'ctx-invalid', - code: 'print("test")', - language: 'python' - }; - - const request = new Request('http://localhost:3000/api/execute/code', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(executeRequest) - }); - - const response = await interpreterHandler.handle(request, mockContext); - - // Verify error response: {code, message, context, httpStatus, timestamp} - expect(response.status).toBe(404); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe(ErrorCode.CONTEXT_NOT_FOUND); - expect(responseData.message).toBe('Context not found'); - expect(responseData.context).toMatchObject({ contextId: 'ctx-invalid' }); - expect(responseData.httpStatus).toBe(404); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('handle - Invalid Endpoints', () => { - it('should return error for invalid interpreter endpoint', async () => { - const request = new Request( - 'http://localhost:3000/api/interpreter/invalid', - { - method: 'GET' - } - ); - - const response = await interpreterHandler.handle(request, mockContext); - - // Verify error response: {code, message, context, httpStatus, timestamp} - expect(response.status).toBeGreaterThanOrEqual(400); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.message).toBe('Invalid interpreter endpoint'); - expect(responseData.httpStatus).toBeDefined(); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should return error for invalid HTTP method', async () => { - const request = new Request('http://localhost:3000/api/contexts', { - method: 'PUT' // Invalid method - }); - - const response = await interpreterHandler.handle(request, mockContext); - - // Verify error response for invalid endpoint/method combination - expect(response.status).toBeGreaterThanOrEqual(400); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.message).toBe('Invalid interpreter endpoint'); - }); - }); -}); diff --git a/packages/sandbox-container/tests/handlers/misc-handler.test.ts b/packages/sandbox-container/tests/handlers/misc-handler.test.ts deleted file mode 100644 index e7d923b05..000000000 --- a/packages/sandbox-container/tests/handlers/misc-handler.test.ts +++ /dev/null @@ -1,462 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'bun:test'; -import type { HealthCheckResult, Logger, ShutdownResult } from '@repo/shared'; -import type { ErrorResponse } from '@repo/shared/errors'; -import type { RequestContext } from '@sandbox-container/core/types'; -import { - MiscHandler, - type VersionResult -} from '@sandbox-container/handlers/misc-handler'; - -// Mock the dependencies -const mockLogger = { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - child: vi.fn() -} as Logger; -mockLogger.child = vi.fn(() => mockLogger); - -// Mock request context -const mockContext: RequestContext = { - requestId: 'req-123', - timestamp: new Date(), - corsHeaders: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type' - }, - sessionId: 'session-456' -}; - -describe('MiscHandler', () => { - let miscHandler: MiscHandler; - - beforeEach(async () => { - // Reset all mocks before each test - vi.clearAllMocks(); - - miscHandler = new MiscHandler(mockLogger); - }); - - describe('handleRoot - GET /', () => { - it('should return welcome message with text/plain content type', async () => { - const request = new Request('http://localhost:3000/', { - method: 'GET' - }); - - const response = await miscHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - expect(await response.text()).toBe('Hello from Bun server! 🚀'); - expect(response.headers.get('Content-Type')).toBe( - 'text/plain; charset=utf-8' - ); - }); - - it('should include CORS headers in root response', async () => { - const request = new Request('http://localhost:3000/', { - method: 'GET' - }); - - const response = await miscHandler.handle(request, mockContext); - - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); - expect(response.headers.get('Access-Control-Allow-Methods')).toBe( - 'GET, POST, OPTIONS' - ); - expect(response.headers.get('Access-Control-Allow-Headers')).toBe( - 'Content-Type' - ); - }); - - it('should handle different HTTP methods on root', async () => { - const methods = ['GET', 'POST', 'PUT', 'DELETE']; - - for (const method of methods) { - const request = new Request('http://localhost:3000/', { - method - }); - - const response = await miscHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - expect(await response.text()).toBe('Hello from Bun server! 🚀'); - } - }); - }); - - describe('handleHealth - GET /api/health', () => { - it('should return health check response with JSON content type', async () => { - const request = new Request('http://localhost:3000/api/health', { - method: 'GET' - }); - - const response = await miscHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - expect(response.headers.get('Content-Type')).toBe('application/json'); - - const responseData = (await response.json()) as HealthCheckResult; - expect(responseData.success).toBe(true); - expect(responseData.status).toBe('healthy'); - expect(responseData.timestamp).toBeDefined(); - - // Verify timestamp format - expect(responseData.timestamp).toMatch( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ - ); - expect(new Date(responseData.timestamp)).toBeInstanceOf(Date); - }); - - it('should include CORS headers in health response', async () => { - const request = new Request('http://localhost:3000/api/health', { - method: 'GET' - }); - - const response = await miscHandler.handle(request, mockContext); - - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); - expect(response.headers.get('Access-Control-Allow-Methods')).toBe( - 'GET, POST, OPTIONS' - ); - expect(response.headers.get('Access-Control-Allow-Headers')).toBe( - 'Content-Type' - ); - }); - - it('should handle health requests with different HTTP methods', async () => { - const methods = ['GET', 'POST', 'PUT']; - - for (const method of methods) { - vi.clearAllMocks(); // Clear mocks between iterations - - const request = new Request('http://localhost:3000/api/health', { - method - }); - - const response = await miscHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as HealthCheckResult; - expect(responseData.success).toBe(true); - expect(responseData.status).toBe('healthy'); - } - }); - - it('should return unique timestamps for multiple health requests', async () => { - const request1 = new Request('http://localhost:3000/api/health', { - method: 'GET' - }); - const request2 = new Request('http://localhost:3000/api/health', { - method: 'GET' - }); - - const response1 = await miscHandler.handle(request1, mockContext); - // Small delay to ensure different timestamps - await new Promise((resolve) => setTimeout(resolve, 5)); - const response2 = await miscHandler.handle(request2, mockContext); - - const responseData1 = (await response1.json()) as HealthCheckResult; - const responseData2 = (await response2.json()) as HealthCheckResult; - - expect(responseData1.timestamp).not.toBe(responseData2.timestamp); - expect(new Date(responseData1.timestamp).getTime()).toBeLessThan( - new Date(responseData2.timestamp).getTime() - ); - }); - }); - - describe('handleVersion - GET /api/version', () => { - it('should return version from environment variable', async () => { - // Set environment variable for test - process.env.SANDBOX_VERSION = '1.2.3'; - - const request = new Request('http://localhost:3000/api/version', { - method: 'GET' - }); - - const response = await miscHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - expect(response.headers.get('Content-Type')).toBe('application/json'); - - const responseData = (await response.json()) as VersionResult; - expect(responseData.success).toBe(true); - expect(responseData.version).toBe('1.2.3'); - expect(responseData.timestamp).toBeDefined(); - expect(responseData.timestamp).toMatch( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ - ); - }); - - it('should return "unknown" when SANDBOX_VERSION is not set', async () => { - // Clear environment variable - delete process.env.SANDBOX_VERSION; - - const request = new Request('http://localhost:3000/api/version', { - method: 'GET' - }); - - const response = await miscHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as VersionResult; - expect(responseData.version).toBe('unknown'); - }); - - it('should include CORS headers in version response', async () => { - process.env.SANDBOX_VERSION = '1.0.0'; - - const request = new Request('http://localhost:3000/api/version', { - method: 'GET' - }); - - const response = await miscHandler.handle(request, mockContext); - - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); - expect(response.headers.get('Access-Control-Allow-Methods')).toBe( - 'GET, POST, OPTIONS' - ); - expect(response.headers.get('Access-Control-Allow-Headers')).toBe( - 'Content-Type' - ); - }); - }); - - describe('handleShutdown - POST /api/shutdown', () => { - it('should return shutdown response with JSON content type', async () => { - const request = new Request('http://localhost:3000/api/shutdown', { - method: 'POST' - }); - - const response = await miscHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - expect(response.headers.get('Content-Type')).toBe('application/json'); - - const responseData = (await response.json()) as ShutdownResult; - expect(responseData.success).toBe(true); - expect(responseData.message).toBe('Container shutdown initiated'); - expect(responseData.timestamp).toBeDefined(); - - // Verify timestamp format - expect(responseData.timestamp).toMatch( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ - ); - expect(new Date(responseData.timestamp)).toBeInstanceOf(Date); - }); - - it('should include CORS headers in shutdown response', async () => { - const request = new Request('http://localhost:3000/api/shutdown', { - method: 'POST' - }); - - const response = await miscHandler.handle(request, mockContext); - - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); - expect(response.headers.get('Access-Control-Allow-Methods')).toBe( - 'GET, POST, OPTIONS' - ); - expect(response.headers.get('Access-Control-Allow-Headers')).toBe( - 'Content-Type' - ); - }); - - it('should handle shutdown requests with GET method', async () => { - const request = new Request('http://localhost:3000/api/shutdown', { - method: 'GET' - }); - - const response = await miscHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as ShutdownResult; - expect(responseData.success).toBe(true); - expect(responseData.message).toBe('Container shutdown initiated'); - }); - - it('should return unique timestamps for multiple shutdown requests', async () => { - const request1 = new Request('http://localhost:3000/api/shutdown', { - method: 'POST' - }); - const request2 = new Request('http://localhost:3000/api/shutdown', { - method: 'POST' - }); - - const response1 = await miscHandler.handle(request1, mockContext); - // Small delay to ensure different timestamps - await new Promise((resolve) => setTimeout(resolve, 5)); - const response2 = await miscHandler.handle(request2, mockContext); - - const responseData1 = (await response1.json()) as ShutdownResult; - const responseData2 = (await response2.json()) as ShutdownResult; - - expect(responseData1.timestamp).not.toBe(responseData2.timestamp); - expect(new Date(responseData1.timestamp).getTime()).toBeLessThan( - new Date(responseData2.timestamp).getTime() - ); - }); - }); - - describe('route handling', () => { - it('should return 500 for invalid endpoints', async () => { - const request = new Request('http://localhost:3000/invalid-endpoint', { - method: 'GET' - }); - - const response = await miscHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.message).toBe('Invalid endpoint'); - expect(responseData.code).toBeDefined(); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - expect(responseData.context).toBeDefined(); - }); - - it('should return 500 for non-existent API endpoints', async () => { - const request = new Request('http://localhost:3000/api/nonexistent', { - method: 'GET' - }); - - const response = await miscHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.message).toBe('Invalid endpoint'); - expect(responseData.code).toBeDefined(); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - expect(responseData.context).toBeDefined(); - }); - - it('should include CORS headers in error responses', async () => { - const request = new Request('http://localhost:3000/invalid', { - method: 'GET' - }); - - const response = await miscHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); - }); - }); - - describe('response format consistency', () => { - it('should have consistent JSON response structure for API endpoints', async () => { - const apiEndpoints = [ - { - path: '/api/health', - expectedFields: ['success', 'status', 'timestamp'] - }, - { - path: '/api/shutdown', - expectedFields: ['success', 'message', 'timestamp'] - } - ]; - - for (const endpoint of apiEndpoints) { - const request = new Request(`http://localhost:3000${endpoint.path}`, { - method: 'GET' - }); - - const response = await miscHandler.handle(request, mockContext); - const responseData = (await response.json()) as - | HealthCheckResult - | ShutdownResult; - - // Verify all expected fields are present - expect(responseData.success).toBe(true); - expect(responseData.timestamp).toBeDefined(); - expect(responseData.timestamp).toMatch( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ - ); - - // Verify all expected fields are present - for (const field of endpoint.expectedFields) { - expect(responseData).toHaveProperty(field); - } - } - }); - - it('should have proper Content-Type headers for different response types', async () => { - const endpoints = [ - { path: '/', expectedContentType: 'text/plain; charset=utf-8' }, - { path: '/api/health', expectedContentType: 'application/json' }, - { path: '/api/shutdown', expectedContentType: 'application/json' } - ]; - - for (const endpoint of endpoints) { - const request = new Request(`http://localhost:3000${endpoint.path}`, { - method: 'GET' - }); - - const response = await miscHandler.handle(request, mockContext); - expect(response.headers.get('Content-Type')).toBe( - endpoint.expectedContentType - ); - } - }); - - it('should handle requests with different context properties', async () => { - const alternativeContext: RequestContext = { - requestId: 'req-alternative-456', - timestamp: new Date(), - corsHeaders: { - 'Access-Control-Allow-Origin': 'https://example.com', - 'Access-Control-Allow-Methods': 'GET, POST', - 'Access-Control-Allow-Headers': 'Authorization' - }, - sessionId: 'session-alternative' - }; - - const request = new Request('http://localhost:3000/api/health', { - method: 'GET' - }); - - const response = await miscHandler.handle(request, alternativeContext); - const responseData = (await response.json()) as HealthCheckResult; - - expect(responseData.success).toBe(true); - expect(responseData.status).toBe('healthy'); - expect(response.headers.get('Access-Control-Allow-Origin')).toBe( - 'https://example.com' - ); - expect(response.headers.get('Access-Control-Allow-Methods')).toBe( - 'GET, POST' - ); - expect(response.headers.get('Access-Control-Allow-Headers')).toBe( - 'Authorization' - ); - }); - }); - - describe('no service dependencies', () => { - it('should work without any service dependencies', async () => { - // MiscHandler only requires logger, no other services - const simpleLogger = { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - child: vi.fn() - } as Logger; - simpleLogger.child = vi.fn(() => simpleLogger); - - const independentHandler = new MiscHandler(simpleLogger); - - const request = new Request('http://localhost:3000/api/health', { - method: 'GET' - }); - - const response = await independentHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as HealthCheckResult; - expect(responseData.success).toBe(true); - expect(responseData.status).toBe('healthy'); - }); - }); -}); diff --git a/packages/sandbox-container/tests/handlers/port-handler.test.ts b/packages/sandbox-container/tests/handlers/port-handler.test.ts deleted file mode 100644 index 4cc2d077f..000000000 --- a/packages/sandbox-container/tests/handlers/port-handler.test.ts +++ /dev/null @@ -1,809 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'bun:test'; -import type { - Logger, - PortCloseResult, - PortExposeResult, - PortListResult, - PortWatchEvent -} from '@repo/shared'; -import type { ErrorResponse } from '@repo/shared/errors'; -import { ErrorCode } from '@repo/shared/errors'; -import type { - PortInfo, - ProxyErrorResponse, - RequestContext -} from '@sandbox-container/core/types'; -import { PortHandler } from '@sandbox-container/handlers/port-handler'; -import type { PortService } from '@sandbox-container/services/port-service'; -import type { ProcessService } from '@sandbox-container/services/process-service'; - -// Test-specific type for mock proxy response -// The proxy handler passes through responses from the target service unchanged, -// so the shape depends on what the target returns. This type represents our test mock. -interface MockProxySuccessResponse { - success: boolean; -} - -// Mock the dependencies - use partial mock to avoid private property issues -const mockPortService = { - exposePort: vi.fn(), - unexposePort: vi.fn(), - getExposedPorts: vi.fn(), - getPortInfo: vi.fn(), - proxyRequest: vi.fn(), - markPortInactive: vi.fn(), - cleanupInactivePorts: vi.fn(), - checkPortReady: vi.fn(), - destroy: vi.fn() -} as unknown as PortService; - -const mockLogger = { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - child: vi.fn() -} as Logger; -mockLogger.child = vi.fn(() => mockLogger); - -const mockProcessService = { - getProcess: vi.fn(), - startProcess: vi.fn(), - killProcess: vi.fn(), - listProcesses: vi.fn(), - killAllProcesses: vi.fn() -} as unknown as ProcessService; - -// Mock request context -const mockContext: RequestContext = { - requestId: 'req-123', - timestamp: new Date(), - corsHeaders: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type' - }, - sessionId: 'session-456' -}; - -describe('PortHandler', () => { - let portHandler: PortHandler; - - beforeEach(async () => { - // Reset all mocks before each test - vi.clearAllMocks(); - - portHandler = new PortHandler( - mockPortService, - mockProcessService, - mockLogger - ); - }); - - describe('handleExpose - POST /api/expose-port', () => { - it('should expose port successfully', async () => { - const exposePortData = { - port: 8080, - name: 'web-server' - }; - - const mockPortInfo: PortInfo = { - port: 8080, - name: 'web-server', - status: 'active', - exposedAt: new Date('2023-01-01T00:00:00Z') - }; - - (mockPortService.exposePort as any).mockResolvedValue({ - success: true, - data: mockPortInfo - }); - - const request = new Request('http://localhost:3000/api/expose-port', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(exposePortData) - }); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as PortExposeResult; - expect(responseData.success).toBe(true); - expect(responseData.port).toBe(8080); - expect(responseData.url).toBe('http://localhost:8080'); - expect(responseData.timestamp).toBeDefined(); - - // Verify service was called correctly - expect(mockPortService.exposePort).toHaveBeenCalledWith( - 8080, - 'web-server' - ); - }); - - it('should expose port without name', async () => { - const exposePortData = { - port: 3000 - // name not provided - }; - - const mockPortInfo: PortInfo = { - port: 3000, - status: 'active', - exposedAt: new Date('2023-01-01T00:00:00Z') - }; - - (mockPortService.exposePort as any).mockResolvedValue({ - success: true, - data: mockPortInfo - }); - - const request = new Request('http://localhost:3000/api/expose-port', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(exposePortData) - }); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as PortExposeResult; - expect(responseData.port).toBe(3000); - expect(responseData.url).toBe('http://localhost:3000'); - expect(responseData.timestamp).toBeDefined(); - - expect(mockPortService.exposePort).toHaveBeenCalledWith(3000, undefined); - }); - - it('should handle port expose failures', async () => { - const exposePortData = { port: 80 }; // Invalid port - - (mockPortService.exposePort as any).mockResolvedValue({ - success: false, - error: { - message: 'Port 80 is reserved', - code: 'INVALID_PORT', - details: { port: 80 } - } - }); - - const request = new Request('http://localhost:3000/api/expose-port', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(exposePortData) - }); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(400); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('INVALID_PORT'); - expect(responseData.message).toBe('Port 80 is reserved'); - expect(responseData.httpStatus).toBe(400); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should handle port already exposed error', async () => { - const exposePortData = { port: 8080 }; - - (mockPortService.exposePort as any).mockResolvedValue({ - success: false, - error: { - message: 'Port 8080 is already exposed', - code: 'PORT_ALREADY_EXPOSED' - } - }); - - const request = new Request('http://localhost:3000/api/expose-port', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(exposePortData) - }); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(409); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('PORT_ALREADY_EXPOSED'); - expect(responseData.message).toBe('Port 8080 is already exposed'); - expect(responseData.httpStatus).toBe(409); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('handleUnexpose - DELETE /api/exposed-ports/{port}', () => { - it('should unexpose port successfully', async () => { - (mockPortService.unexposePort as any).mockResolvedValue({ - success: true - }); - - const request = new Request( - 'http://localhost:3000/api/exposed-ports/8080', - { - method: 'DELETE' - } - ); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as PortCloseResult; - expect(responseData.success).toBe(true); - expect(responseData.port).toBe(8080); - expect(responseData.timestamp).toBeDefined(); - - expect(mockPortService.unexposePort).toHaveBeenCalledWith(8080); - }); - - it('should handle unexpose failures', async () => { - (mockPortService.unexposePort as any).mockResolvedValue({ - success: false, - error: { - message: 'Port 8080 is not exposed', - code: 'PORT_NOT_EXPOSED' - } - }); - - const request = new Request( - 'http://localhost:3000/api/exposed-ports/8080', - { - method: 'DELETE' - } - ); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(404); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('PORT_NOT_EXPOSED'); - expect(responseData.message).toBe('Port 8080 is not exposed'); - expect(responseData.httpStatus).toBe(404); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should handle invalid port numbers in URL', async () => { - const request = new Request( - 'http://localhost:3000/api/exposed-ports/invalid', - { - method: 'DELETE' - } - ); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.message).toBe('Invalid port endpoint'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - - // Should not call service for invalid port - expect(mockPortService.unexposePort).not.toHaveBeenCalled(); - }); - - it('should handle unsupported methods on exposed-ports endpoint', async () => { - const request = new Request( - 'http://localhost:3000/api/exposed-ports/8080', - { - method: 'GET' // Not supported for individual ports - } - ); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.message).toBe('Invalid port endpoint'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('handleList - GET /api/exposed-ports', () => { - it('should list exposed ports successfully', async () => { - const mockPorts: PortInfo[] = [ - { - port: 8080, - name: 'web-server', - status: 'active', - exposedAt: new Date('2023-01-01T00:00:00Z') - }, - { - port: 3000, - name: 'api-server', - status: 'active', - exposedAt: new Date('2023-01-01T00:01:00Z') - } - ]; - - (mockPortService.getExposedPorts as any).mockResolvedValue({ - success: true, - data: mockPorts - }); - - const request = new Request('http://localhost:3000/api/exposed-ports', { - method: 'GET' - }); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as PortListResult; - expect(responseData.success).toBe(true); - expect(responseData.ports).toHaveLength(2); - expect(responseData.ports[0].port).toBe(8080); - expect(responseData.ports[0].url).toBe('http://localhost:8080'); - expect(responseData.ports[0].status).toBe('active'); - expect(responseData.ports[1].port).toBe(3000); - expect(responseData.ports[1].url).toBe('http://localhost:3000'); - expect(responseData.timestamp).toBeDefined(); - - expect(mockPortService.getExposedPorts).toHaveBeenCalled(); - }); - - it('should return empty list when no ports are exposed', async () => { - (mockPortService.getExposedPorts as any).mockResolvedValue({ - success: true, - data: [] - }); - - const request = new Request('http://localhost:3000/api/exposed-ports', { - method: 'GET' - }); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as PortListResult; - expect(responseData.success).toBe(true); - expect(responseData.ports).toHaveLength(0); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should handle port listing errors', async () => { - (mockPortService.getExposedPorts as any).mockResolvedValue({ - success: false, - error: { - message: 'Database error', - code: ErrorCode.PORT_OPERATION_ERROR - } - }); - - const request = new Request('http://localhost:3000/api/exposed-ports', { - method: 'GET' - }); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe(ErrorCode.PORT_OPERATION_ERROR); - expect(responseData.message).toBe('Database error'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('handleProxy - GET /proxy/{port}/*', () => { - it('should proxy request successfully', async () => { - const mockProxyResponse = new Response('Proxied content', { - status: 200, - headers: { 'Content-Type': 'text/html' } - }); - - (mockPortService.proxyRequest as any).mockResolvedValue( - mockProxyResponse - ); - - const request = new Request('http://localhost:3000/proxy/8080/api/data', { - method: 'GET', - headers: { Authorization: 'Bearer token' } - }); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - expect(await response.text()).toBe('Proxied content'); - expect(response.headers.get('Content-Type')).toBe('text/html'); - - // Verify service was called with correct parameters - expect(mockPortService.proxyRequest).toHaveBeenCalledWith(8080, request); - }); - - it('should proxy POST request with body', async () => { - const mockProxyResponse = new Response('{"success": true}', { - status: 201, - headers: { 'Content-Type': 'application/json' } - }); - - (mockPortService.proxyRequest as any).mockResolvedValue( - mockProxyResponse - ); - - const requestBody = JSON.stringify({ data: 'test' }); - const request = new Request( - 'http://localhost:3000/proxy/3000/api/create', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: requestBody - } - ); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(201); - const responseData = (await response.json()) as MockProxySuccessResponse; - expect(responseData.success).toBe(true); - - expect(mockPortService.proxyRequest).toHaveBeenCalledWith(3000, request); - }); - - it('should handle proxy errors from service', async () => { - const mockErrorResponse = new Response('{"error": "Port not found"}', { - status: 404, - headers: { 'Content-Type': 'application/json' } - }); - - (mockPortService.proxyRequest as any).mockResolvedValue( - mockErrorResponse - ); - - const request = new Request('http://localhost:3000/proxy/9999/api/data', { - method: 'GET' - }); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(404); - const responseData = (await response.json()) as ProxyErrorResponse; - expect(responseData.error).toBe('Port not found'); - }); - - it('should handle invalid proxy URL format', async () => { - const request = new Request('http://localhost:3000/proxy/', { - method: 'GET' - }); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.message).toBe('Invalid port number in proxy URL'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - - // Should not call proxy service - expect(mockPortService.proxyRequest).not.toHaveBeenCalled(); - }); - - it('should handle invalid port number in proxy URL', async () => { - const request = new Request( - 'http://localhost:3000/proxy/invalid-port/api/data', - { - method: 'GET' - } - ); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.message).toBe('Invalid port number in proxy URL'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - - expect(mockPortService.proxyRequest).not.toHaveBeenCalled(); - }); - - it('should handle proxy service exceptions', async () => { - const proxyError = new Error('Connection refused'); - (mockPortService.proxyRequest as any).mockRejectedValue(proxyError); - - const request = new Request('http://localhost:3000/proxy/8080/api/data', { - method: 'GET' - }); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.message).toBe('Connection refused'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should handle non-Error exceptions in proxy', async () => { - (mockPortService.proxyRequest as any).mockRejectedValue('String error'); - - const request = new Request('http://localhost:3000/proxy/8080/api/data', { - method: 'GET' - }); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.message).toBe('Proxy request failed'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('route handling', () => { - it('should return 500 for invalid endpoints', async () => { - const request = new Request( - 'http://localhost:3000/api/invalid-endpoint', - { - method: 'GET' - } - ); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.message).toBe('Invalid port endpoint'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should handle malformed exposed-ports URLs', async () => { - const request = new Request('http://localhost:3000/api/exposed-ports/', { - method: 'DELETE' - }); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.message).toBe('Invalid port endpoint'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should handle root proxy path', async () => { - const mockProxyResponse = new Response('Root page'); - (mockPortService.proxyRequest as any).mockResolvedValue( - mockProxyResponse - ); - - const request = new Request('http://localhost:3000/proxy/8080/', { - method: 'GET' - }); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - expect(await response.text()).toBe('Root page'); - expect(mockPortService.proxyRequest).toHaveBeenCalledWith(8080, request); - }); - }); - - describe('CORS headers', () => { - it('should include CORS headers in successful responses', async () => { - (mockPortService.getExposedPorts as any).mockResolvedValue({ - success: true, - data: [] - }); - - const request = new Request('http://localhost:3000/api/exposed-ports', { - method: 'GET' - }); - - const response = await portHandler.handle(request, mockContext); - - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); - expect(response.headers.get('Access-Control-Allow-Methods')).toBe( - 'GET, POST, DELETE, OPTIONS' - ); - expect(response.headers.get('Access-Control-Allow-Headers')).toBe( - 'Content-Type' - ); - }); - - it('should include CORS headers in error responses', async () => { - const request = new Request('http://localhost:3000/api/invalid', { - method: 'GET' - }); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); - }); - }); - - describe('handlePortWatch - POST /api/port-watch', () => { - // Helper to collect SSE events from stream - async function collectEvents( - response: Response - ): Promise { - const events: PortWatchEvent[] = []; - const reader = response.body!.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - - const lines = buffer.split('\n\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { - if (line.startsWith('data: ')) { - events.push(JSON.parse(line.slice(6))); - } - } - } - return events; - } - - it('should emit ready event when port becomes available', async () => { - ( - mockPortService.checkPortReady as ReturnType - ).mockResolvedValue({ - ready: true, - statusCode: 200 - }); - - const request = new Request('http://localhost:3000/api/port-watch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ port: 8080 }) - }); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - expect(response.headers.get('Content-Type')).toBe('text/event-stream'); - - const events = await collectEvents(response); - expect(events).toEqual([ - { type: 'watching', port: 8080 }, - { type: 'ready', port: 8080, statusCode: 200 } - ]); - }); - - it('should emit process_exited when watched process terminates', async () => { - ( - mockProcessService.getProcess as ReturnType - ).mockResolvedValue({ - success: true, - data: { status: 'completed', exitCode: 0 } - }); - - const request = new Request('http://localhost:3000/api/port-watch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ port: 8080, processId: 'proc-123' }) - }); - - const response = await portHandler.handle(request, mockContext); - const events = await collectEvents(response); - - expect(events).toEqual([ - { type: 'watching', port: 8080 }, - { type: 'process_exited', port: 8080, exitCode: 0 } - ]); - }); - - it('should emit error when process not found', async () => { - ( - mockProcessService.getProcess as ReturnType - ).mockResolvedValue({ - success: false - }); - - const request = new Request('http://localhost:3000/api/port-watch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ port: 8080, processId: 'nonexistent' }) - }); - - const response = await portHandler.handle(request, mockContext); - const events = await collectEvents(response); - - expect(events).toEqual([ - { type: 'watching', port: 8080 }, - { type: 'error', port: 8080, error: 'Process not found' } - ]); - }); - - it('should emit error when port check throws', async () => { - ( - mockPortService.checkPortReady as ReturnType - ).mockRejectedValue(new Error('Connection refused')); - - const request = new Request('http://localhost:3000/api/port-watch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ port: 8080 }) - }); - - const response = await portHandler.handle(request, mockContext); - const events = await collectEvents(response); - - expect(events).toEqual([ - { type: 'watching', port: 8080 }, - { type: 'error', port: 8080, error: 'Connection refused' } - ]); - }); - }); - - describe('URL parsing edge cases', () => { - it('should handle ports with leading zeros', async () => { - const request = new Request( - 'http://localhost:3000/api/exposed-ports/008080', - { - method: 'DELETE' - } - ); - - (mockPortService.unexposePort as any).mockResolvedValue({ - success: true - }); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - // parseInt should handle leading zeros correctly - expect(mockPortService.unexposePort).toHaveBeenCalledWith(8080); - }); - - it('should handle very large port numbers', async () => { - const request = new Request( - 'http://localhost:3000/api/exposed-ports/999999', - { - method: 'DELETE' - } - ); - - (mockPortService.unexposePort as any).mockResolvedValue({ - success: false, - error: { message: 'Invalid port range', code: 'INVALID_PORT' } - }); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(400); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('INVALID_PORT'); - expect(responseData.message).toBe('Invalid port range'); - expect(responseData.httpStatus).toBe(400); - expect(responseData.timestamp).toBeDefined(); - expect(mockPortService.unexposePort).toHaveBeenCalledWith(999999); - }); - - it('should handle complex proxy paths with query parameters', async () => { - const mockProxyResponse = new Response('Query result'); - (mockPortService.proxyRequest as any).mockResolvedValue( - mockProxyResponse - ); - - const request = new Request( - 'http://localhost:3000/proxy/8080/api/search?q=test&page=1', - { - method: 'GET' - } - ); - - const response = await portHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - expect(mockPortService.proxyRequest).toHaveBeenCalledWith(8080, request); - }); - }); -}); diff --git a/packages/sandbox-container/tests/handlers/process-handler.test.ts b/packages/sandbox-container/tests/handlers/process-handler.test.ts deleted file mode 100644 index fea5f00cb..000000000 --- a/packages/sandbox-container/tests/handlers/process-handler.test.ts +++ /dev/null @@ -1,758 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'bun:test'; -import type { - Logger, - ProcessCleanupResult, - ProcessInfoResult, - ProcessKillResult, - ProcessListResult, - ProcessLogsResult, - ProcessStartResult, - StartProcessRequest -} from '@repo/shared'; -import type { ErrorResponse } from '@repo/shared/errors'; -import type { - ProcessInfo, - RequestContext -} from '@sandbox-container/core/types'; -import { ProcessHandler } from '@sandbox-container/handlers/process-handler'; -import type { ProcessService } from '@sandbox-container/services/process-service'; - -// Mock the dependencies - use partial mock to avoid private property issues -const mockProcessService = { - startProcess: vi.fn(), - getProcess: vi.fn(), - killProcess: vi.fn(), - killAllProcesses: vi.fn(), - listProcesses: vi.fn(), - executeCommand: vi.fn() -} as unknown as ProcessService; - -const mockLogger = { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - child: vi.fn() -} as Logger; -mockLogger.child = vi.fn(() => mockLogger); - -// Mock request context -const mockContext: RequestContext = { - requestId: 'req-123', - timestamp: new Date(), - corsHeaders: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type' - }, - sessionId: 'session-456' -}; - -describe('ProcessHandler', () => { - let processHandler: ProcessHandler; - - beforeEach(async () => { - // Reset all mocks before each test - vi.clearAllMocks(); - - processHandler = new ProcessHandler(mockProcessService, mockLogger); - }); - - describe('handleStart - POST /api/process/start', () => { - it('should start process successfully', async () => { - const startProcessData: StartProcessRequest = { - command: 'echo "hello"', - cwd: '/tmp' - }; - - const mockProcessInfo: ProcessInfo = { - id: 'proc-123', - pid: 12345, - command: 'echo "hello"', - status: 'running', - startTime: new Date('2023-01-01T00:00:00Z'), - sessionId: 'session-456', - stdout: '', - stderr: '', - outputListeners: new Set(), - statusListeners: new Set() - }; - - (mockProcessService.startProcess as any).mockResolvedValue({ - success: true, - data: mockProcessInfo - }); - - const request = new Request('http://localhost:3000/api/process/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(startProcessData) - }); - - const response = await processHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as ProcessStartResult; - expect(responseData.success).toBe(true); - expect(responseData.processId).toBe('proc-123'); - expect(responseData.pid).toBe(12345); - expect(responseData.command).toBe('echo "hello"'); - expect(responseData.timestamp).toBeDefined(); - - // Verify service was called correctly - expect(mockProcessService.startProcess).toHaveBeenCalledWith( - 'echo "hello"', - { cwd: '/tmp' } - ); - }); - - it('should handle process start failures', async () => { - const startProcessData = { command: 'invalid-command' }; - - (mockProcessService.startProcess as any).mockResolvedValue({ - success: false, - error: { - message: 'Command not found', - code: 'COMMAND_NOT_FOUND', - details: { command: 'invalid-command' } - } - }); - - const request = new Request('http://localhost:3000/api/process/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(startProcessData) - }); - - const response = await processHandler.handle(request, mockContext); - - // HTTP status is auto-mapped based on error code - expect(response.status).toBe(404); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('COMMAND_NOT_FOUND'); - expect(responseData.message).toBe('Command not found'); - expect(responseData.httpStatus).toBe(404); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('handleList - GET /api/process/list', () => { - it('should list all processes successfully', async () => { - const mockProcesses: ProcessInfo[] = [ - { - id: 'proc-1', - pid: 11111, - command: 'sleep 10', - status: 'running', - startTime: new Date('2023-01-01T00:00:00Z'), - sessionId: 'session-456', - stdout: '', - stderr: '', - outputListeners: new Set(), - statusListeners: new Set() - }, - { - id: 'proc-2', - pid: 22222, - command: 'cat file.txt', - status: 'completed', - startTime: new Date('2023-01-01T00:01:00Z'), - endTime: new Date('2023-01-01T00:01:30Z'), - exitCode: 0, - sessionId: 'session-456', - stdout: '', - stderr: '', - outputListeners: new Set(), - statusListeners: new Set() - } - ]; - - (mockProcessService.listProcesses as any).mockResolvedValue({ - success: true, - data: mockProcesses - }); - - const request = new Request('http://localhost:3000/api/process/list', { - method: 'GET' - }); - - const response = await processHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as ProcessListResult; - expect(responseData.success).toBe(true); - expect(responseData.processes).toHaveLength(2); - expect(responseData.processes[0].id).toBe('proc-1'); - expect(responseData.processes[1].status).toBe('completed'); - expect(responseData.timestamp).toBeDefined(); - - // Processes are sandbox-scoped, not session-scoped - // Handler only passes status filter, not sessionId - expect(mockProcessService.listProcesses).toHaveBeenCalledWith({}); - }); - - it('should filter processes by query parameters', async () => { - (mockProcessService.listProcesses as any).mockResolvedValue({ - success: true, - data: [] - }); - - const request = new Request( - 'http://localhost:3000/api/process/list?sessionId=session-123&status=running', - { - method: 'GET' - } - ); - - const response = await processHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - - // Verify filtering parameters were passed to service - // Only status is passed, sessionId is not used (sandbox-scoped) - expect(mockProcessService.listProcesses).toHaveBeenCalledWith({ - status: 'running' - }); - }); - - it('should handle process listing errors', async () => { - (mockProcessService.listProcesses as any).mockResolvedValue({ - success: false, - error: { - message: 'Database error', - code: 'UNKNOWN_ERROR' - } - }); - - const request = new Request('http://localhost:3000/api/process/list', { - method: 'GET' - }); - - const response = await processHandler.handle(request, mockContext); - - // HTTP status is auto-mapped based on error code - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.message).toBe('Database error'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('handleGet - GET /api/process/{id}', () => { - it('should get process by ID successfully', async () => { - const mockProcessInfo: ProcessInfo = { - id: 'proc-123', - pid: 12345, - command: 'sleep 60', - status: 'running', - startTime: new Date('2023-01-01T00:00:00Z'), - sessionId: 'session-456', - stdout: 'Process output', - stderr: 'Error output', - outputListeners: new Set(), - statusListeners: new Set() - }; - - (mockProcessService.getProcess as any).mockResolvedValue({ - success: true, - data: mockProcessInfo - }); - - const request = new Request( - 'http://localhost:3000/api/process/proc-123', - { - method: 'GET' - } - ); - - const response = await processHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as ProcessInfoResult; - expect(responseData.success).toBe(true); - expect(responseData.process.id).toBe('proc-123'); - expect(responseData.timestamp).toBeDefined(); - - expect(mockProcessService.getProcess).toHaveBeenCalledWith('proc-123'); - }); - - it('should return 404 when process not found', async () => { - (mockProcessService.getProcess as any).mockResolvedValue({ - success: false, - error: { - message: 'Process not found', - code: 'PROCESS_NOT_FOUND' - } - }); - - const request = new Request( - 'http://localhost:3000/api/process/nonexistent', - { - method: 'GET' - } - ); - - const response = await processHandler.handle(request, mockContext); - - // HTTP status is auto-mapped: PROCESS_NOT_FOUND → 404 - expect(response.status).toBe(404); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('PROCESS_NOT_FOUND'); - expect(responseData.message).toBe('Process not found'); - expect(responseData.httpStatus).toBe(404); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('handleKill - DELETE /api/process/{id}', () => { - it('should kill process successfully', async () => { - (mockProcessService.killProcess as any).mockResolvedValue({ - success: true - }); - - const request = new Request( - 'http://localhost:3000/api/process/proc-123', - { - method: 'DELETE' - } - ); - - const response = await processHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as ProcessKillResult; - expect(responseData.success).toBe(true); - expect(responseData.processId).toBe('proc-123'); - expect(responseData.timestamp).toBeDefined(); - - expect(mockProcessService.killProcess).toHaveBeenCalledWith('proc-123'); - }); - - it('should handle kill failures', async () => { - (mockProcessService.killProcess as any).mockResolvedValue({ - success: false, - error: { - message: 'Process already terminated', - code: 'PROCESS_ERROR' - } - }); - - const request = new Request( - 'http://localhost:3000/api/process/proc-123', - { - method: 'DELETE' - } - ); - - const response = await processHandler.handle(request, mockContext); - - // HTTP status is auto-mapped: PROCESS_ERROR → 500 - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('PROCESS_ERROR'); - expect(responseData.message).toBe('Process already terminated'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('handleKillAll - POST /api/process/kill-all', () => { - it('should kill all processes successfully', async () => { - (mockProcessService.killAllProcesses as any).mockResolvedValue({ - success: true, - data: 3 // Number of killed processes - }); - - const request = new Request( - 'http://localhost:3000/api/process/kill-all', - { - method: 'POST' - } - ); - - const response = await processHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as ProcessCleanupResult; - expect(responseData.success).toBe(true); - expect(responseData.cleanedCount).toBe(3); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should handle kill all failures', async () => { - (mockProcessService.killAllProcesses as any).mockResolvedValue({ - success: false, - error: { - message: 'Failed to kill processes', - code: 'UNKNOWN_ERROR' - } - }); - - const request = new Request( - 'http://localhost:3000/api/process/kill-all', - { - method: 'POST' - } - ); - - const response = await processHandler.handle(request, mockContext); - - // HTTP status is auto-mapped based on error code - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.message).toBe('Failed to kill processes'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('handleLogs - GET /api/process/{id}/logs', () => { - it('should get process logs successfully', async () => { - const mockProcessInfo: ProcessInfo = { - id: 'proc-123', - pid: 12345, - command: 'echo test', - status: 'completed', - startTime: new Date('2023-01-01T00:00:00Z'), - sessionId: 'session-456', - stdout: 'test output', - stderr: 'error output', - outputListeners: new Set(), - statusListeners: new Set() - }; - - (mockProcessService.getProcess as any).mockResolvedValue({ - success: true, - data: mockProcessInfo - }); - - const request = new Request( - 'http://localhost:3000/api/process/proc-123/logs', - { - method: 'GET' - } - ); - - const response = await processHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseData = (await response.json()) as ProcessLogsResult; - expect(responseData.success).toBe(true); - expect(responseData.processId).toBe('proc-123'); - expect(responseData.stdout).toBe('test output'); - expect(responseData.stderr).toBe('error output'); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should handle logs request for nonexistent process', async () => { - (mockProcessService.getProcess as any).mockResolvedValue({ - success: false, - error: { - message: 'Process not found', - code: 'PROCESS_NOT_FOUND' - } - }); - - const request = new Request( - 'http://localhost:3000/api/process/nonexistent/logs', - { - method: 'GET' - } - ); - - const response = await processHandler.handle(request, mockContext); - - // HTTP status is auto-mapped: PROCESS_NOT_FOUND → 404 - expect(response.status).toBe(404); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('PROCESS_NOT_FOUND'); - expect(responseData.message).toBe('Process not found'); - expect(responseData.httpStatus).toBe(404); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('handleStream - GET /api/process/{id}/stream', () => { - it('should create SSE stream for process logs', async () => { - const mockProcessInfo: ProcessInfo = { - id: 'proc-123', - pid: 12345, - command: 'long-running-command', - status: 'running', - startTime: new Date('2023-01-01T00:00:00Z'), - sessionId: 'session-456', - stdout: 'existing output', - stderr: 'existing error', - outputListeners: new Set(), - statusListeners: new Set() - }; - - (mockProcessService.getProcess as any).mockResolvedValue({ - success: true, - data: mockProcessInfo - }); - - const request = new Request( - 'http://localhost:3000/api/process/proc-123/stream', - { - method: 'GET' - } - ); - - const response = await processHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - expect(response.headers.get('Content-Type')).toBe('text/event-stream'); - expect(response.headers.get('Cache-Control')).toBe('no-cache'); - expect(response.headers.get('Connection')).toBe('keep-alive'); - - // Test streaming response body - expect(response.body).toBeDefined(); - const reader = response.body!.getReader(); - const { value, done } = await reader.read(); - expect(done).toBe(false); - - const chunk = new TextDecoder().decode(value); - expect(chunk).toContain('process_info'); - expect(chunk).toContain('proc-123'); - expect(chunk).toContain('long-running-command'); - - reader.releaseLock(); - }); - - it('should handle process not found during stream', async () => { - (mockProcessService.getProcess as any).mockResolvedValue({ - success: false, - error: { - message: 'Process not found', - code: 'PROCESS_NOT_FOUND' - } - }); - - const request = new Request( - 'http://localhost:3000/api/process/proc-123/stream', - { - method: 'GET' - } - ); - - const response = await processHandler.handle(request, mockContext); - - expect(response.status).toBe(404); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('PROCESS_NOT_FOUND'); - expect(responseData.message).toBe('Process not found'); - expect(responseData.httpStatus).toBe(404); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should handle internal error during stream setup', async () => { - (mockProcessService.getProcess as any).mockResolvedValue({ - success: false, - error: { - message: 'Internal error retrieving process', - code: 'INTERNAL_ERROR' - } - }); - - const request = new Request( - 'http://localhost:3000/api/process/proc-123/stream', - { - method: 'GET' - } - ); - - const response = await processHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('INTERNAL_ERROR'); - expect(responseData.message).toBe('Internal error retrieving process'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - it('should clean up listeners when stream is cancelled', async () => { - const outputListeners = new Set< - (stream: 'stdout' | 'stderr', data: string) => void - >(); - const statusListeners = new Set<(status: string) => void>(); - - const mockProcessInfo: ProcessInfo = { - id: 'proc-cancel', - pid: 99999, - command: 'long-running', - status: 'running', - startTime: new Date('2023-01-01T00:00:00Z'), - sessionId: 'session-456', - stdout: '', - stderr: '', - outputListeners, - statusListeners - }; - - (mockProcessService.getProcess as any).mockResolvedValue({ - success: true, - data: mockProcessInfo - }); - - const request = new Request( - 'http://localhost:3000/api/process/proc-cancel/stream', - { method: 'GET' } - ); - - const response = await processHandler.handle(request, mockContext); - expect(response.status).toBe(200); - - // Read the initial chunks to ensure listeners are registered - const reader = response.body!.getReader(); - await reader.read(); // process_info event - - // Listeners should be registered - expect(outputListeners.size).toBe(1); - expect(statusListeners.size).toBe(1); - - // Cancel the stream - await reader.cancel(); - - // Listeners should be cleaned up - expect(outputListeners.size).toBe(0); - expect(statusListeners.size).toBe(0); - }); - - describe('route handling', () => { - it('should return 404 for invalid endpoints', async () => { - // Mock getProcess to return process not found for invalid process ID - (mockProcessService.getProcess as any).mockResolvedValue({ - success: false, - error: { - message: 'Process not found', - code: 'PROCESS_NOT_FOUND' - } - }); - - const request = new Request( - 'http://localhost:3000/api/process/invalid-endpoint', - { - method: 'GET' - } - ); - - const response = await processHandler.handle(request, mockContext); - - // HTTP status is auto-mapped: PROCESS_NOT_FOUND → 404 - expect(response.status).toBe(404); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('PROCESS_NOT_FOUND'); - expect(responseData.message).toBe('Process not found'); - expect(responseData.httpStatus).toBe(404); - }); - - it('should handle malformed process ID paths', async () => { - // Mock getProcess to return process not found for empty process ID - (mockProcessService.getProcess as any).mockResolvedValue({ - success: false, - error: { - message: 'Process not found', - code: 'PROCESS_NOT_FOUND' - } - }); - - const request = new Request('http://localhost:3000/api/process/', { - method: 'GET' - }); - - const response = await processHandler.handle(request, mockContext); - - // HTTP status is auto-mapped: PROCESS_NOT_FOUND → 404 - expect(response.status).toBe(404); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('PROCESS_NOT_FOUND'); - expect(responseData.message).toBe('Process not found'); - expect(responseData.httpStatus).toBe(404); - }); - - it('should handle unsupported HTTP methods for process endpoints', async () => { - const request = new Request( - 'http://localhost:3000/api/process/proc-123', - { - method: 'PUT' // Unsupported method - } - ); - - const response = await processHandler.handle(request, mockContext); - - // HTTP status is auto-mapped: UNKNOWN_ERROR → 500 - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.message).toBe('Invalid process endpoint'); - expect(responseData.httpStatus).toBe(500); - }); - - it('should handle unsupported actions on process endpoints', async () => { - const request = new Request( - 'http://localhost:3000/api/process/proc-123/unsupported-action', - { - method: 'GET' - } - ); - - const response = await processHandler.handle(request, mockContext); - - // HTTP status is auto-mapped: UNKNOWN_ERROR → 500 - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.message).toBe('Invalid process endpoint'); - expect(responseData.httpStatus).toBe(500); - }); - }); - - describe('CORS headers', () => { - it('should include CORS headers in all responses', async () => { - (mockProcessService.listProcesses as any).mockResolvedValue({ - success: true, - data: [] - }); - - const request = new Request('http://localhost:3000/api/process/list', { - method: 'GET' - }); - - const response = await processHandler.handle(request, mockContext); - - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); - expect(response.headers.get('Access-Control-Allow-Methods')).toBe( - 'GET, POST, DELETE, OPTIONS' - ); - expect(response.headers.get('Access-Control-Allow-Headers')).toBe( - 'Content-Type' - ); - }); - - it('should include CORS headers in error responses', async () => { - // Mock getProcess to return process not found - (mockProcessService.getProcess as any).mockResolvedValue({ - success: false, - error: { - message: 'Process not found', - code: 'PROCESS_NOT_FOUND' - } - }); - - const request = new Request('http://localhost:3000/api/process/invalid', { - method: 'GET' - }); - - const response = await processHandler.handle(request, mockContext); - - expect(response.status).toBe(404); - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); - }); - }); -}); diff --git a/packages/sandbox-container/tests/handlers/pty-ws-handler.test.ts b/packages/sandbox-container/tests/handlers/pty-ws-handler.test.ts deleted file mode 100644 index 4a0d4a22e..000000000 --- a/packages/sandbox-container/tests/handlers/pty-ws-handler.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, expect, it, mock } from 'bun:test'; -import type { Disposable, PtyOptions } from '@repo/shared'; -import { createNoOpLogger } from '@repo/shared'; -import type { ServerWebSocket } from 'bun'; -import type { ServiceResult } from '../../src/core/types'; -import { - PtyWebSocketHandler, - type PtyWSData -} from '../../src/handlers/pty-ws-handler'; -import type { Pty } from '../../src/pty'; - -type MockWebSocket = Pick< - ServerWebSocket, - 'data' | 'send' | 'sendBinary' | 'close' ->; - -interface MockSessionManager { - getPty: ( - sessionId: string, - options?: PtyOptions - ) => Promise>; -} - -type MockPty = Pick< - Pty, - 'getBufferedOutput' | 'onData' | 'write' | 'resize' -> & { - closed: boolean; -}; - -const createMockWS = (data: PtyWSData): MockWebSocket => ({ - data, - send: mock(() => 0), - sendBinary: mock(() => 1), - close: mock(() => {}) -}); - -const createMockPty = (overrides: Partial = {}): MockPty => ({ - getBufferedOutput: () => new Uint8Array([1, 2, 3]), - onData: mock((): Disposable => ({ dispose: () => {} })), - write: mock(() => {}), - resize: mock(() => {}), - closed: false, - ...overrides -}); - -describe('PtyWebSocketHandler', () => { - const logger = createNoOpLogger(); - - it('should send buffered output and ready message on connection', async () => { - const mockPty = createMockPty(); - const sessionManager = { - getPty: mock(() => Promise.resolve({ success: true, data: mockPty })) - }; - const handler = new PtyWebSocketHandler(sessionManager as any, logger); - const ws = createMockWS({ - type: 'pty', - sessionId: 'test-session', - connectionId: 'conn-1' - }); - - await handler.onOpen(ws as any); - - expect(sessionManager.getPty).toHaveBeenCalledWith('test-session', { - cols: undefined, - rows: undefined - }); - expect(ws.sendBinary).toHaveBeenCalled(); - expect(ws.send).toHaveBeenCalled(); - }); - - it('should close connection with error when PTY creation fails', async () => { - const sessionManager = { - getPty: mock(() => - Promise.resolve({ success: false, error: { message: 'Spawn failed' } }) - ) - }; - const handler = new PtyWebSocketHandler(sessionManager as any, logger); - const ws = createMockWS({ - type: 'pty', - sessionId: 'test-session', - connectionId: 'conn-1' - }); - - await handler.onOpen(ws as any); - - expect(ws.close).toHaveBeenCalledWith(1011, expect.any(String)); - }); - - it('should forward binary messages to PTY as input', async () => { - const mockPty = createMockPty(); - const sessionManager = { - getPty: mock(() => Promise.resolve({ success: true, data: mockPty })) - }; - const handler = new PtyWebSocketHandler(sessionManager as any, logger); - const ws = createMockWS({ - type: 'pty', - sessionId: 'test-session', - connectionId: 'conn-1' - }); - - await handler.onOpen(ws as any); - handler.onMessage( - ws as any, - new Uint8Array([104, 101, 108, 108, 111]).buffer - ); - - expect(mockPty.write).toHaveBeenCalled(); - }); - - it('should handle resize control messages', async () => { - const mockPty = createMockPty(); - const sessionManager = { - getPty: mock(() => Promise.resolve({ success: true, data: mockPty })) - }; - const handler = new PtyWebSocketHandler(sessionManager as any, logger); - const ws = createMockWS({ - type: 'pty', - sessionId: 'test-session', - connectionId: 'conn-1' - }); - - await handler.onOpen(ws as any); - handler.onMessage( - ws as any, - JSON.stringify({ type: 'resize', cols: 120, rows: 40 }) - ); - - expect(mockPty.resize).toHaveBeenCalledWith(120, 40); - }); - - it('should cleanup subscription on close', async () => { - const mockDispose = mock(() => {}); - const mockPty = createMockPty({ - onData: mock(() => ({ dispose: mockDispose })) - }); - const sessionManager = { - getPty: mock(() => Promise.resolve({ success: true, data: mockPty })) - }; - const handler = new PtyWebSocketHandler(sessionManager as any, logger); - const ws = createMockWS({ - type: 'pty', - sessionId: 'test-session', - connectionId: 'conn-1' - }); - - await handler.onOpen(ws as any); - handler.onClose(ws as any, 1000, 'Normal closure'); - - expect(mockDispose).toHaveBeenCalled(); - }); -}); diff --git a/packages/sandbox-container/tests/handlers/session-handler.test.ts b/packages/sandbox-container/tests/handlers/session-handler.test.ts deleted file mode 100644 index b4bbf052a..000000000 --- a/packages/sandbox-container/tests/handlers/session-handler.test.ts +++ /dev/null @@ -1,636 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'bun:test'; -import type { Logger, SessionDeleteResult } from '@repo/shared'; -import type { ErrorResponse } from '@repo/shared/errors'; -import { ErrorCode } from '@repo/shared/errors'; -import type { RequestContext } from '@sandbox-container/core/types'; -import { SessionHandler } from '@sandbox-container/handlers/session-handler'; -import type { SessionManager } from '@sandbox-container/services/session-manager'; -import type { Session } from '@sandbox-container/session'; - -// SessionListResult type - matches handler return format -interface SessionListResult { - success: boolean; - data: string[]; - timestamp: string; -} - -// SessionCreateResultGeneric - matches handler return format (with Session object) -interface SessionCreateResultGeneric { - success: boolean; - data: Session; - timestamp: string; -} - -// Mock the dependencies - use partial mock to avoid private property issues -const mockSessionManager = { - createSession: vi.fn(), - getSession: vi.fn(), - deleteSession: vi.fn(), - listSessions: vi.fn(), - destroy: vi.fn() -} as unknown as SessionManager; - -const mockLogger = { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - child: vi.fn() -} as Logger; -mockLogger.child = vi.fn(() => mockLogger); - -// Mock request context -const mockContext: RequestContext = { - requestId: 'req-123', - timestamp: new Date(), - corsHeaders: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type' - }, - sessionId: 'session-456' -}; - -describe('SessionHandler', () => { - let sessionHandler: SessionHandler; - - beforeEach(async () => { - // Reset all mocks before each test - vi.clearAllMocks(); - - sessionHandler = new SessionHandler(mockSessionManager, mockLogger); - }); - - describe('handleCreate - POST /api/session/create', () => { - it('should create session successfully', async () => { - const mockSession = {} as Session; // Session object (not SessionData) - - (mockSessionManager.createSession as any).mockResolvedValue({ - success: true, - data: mockSession - }); - - const request = new Request('http://localhost:3000/api/session/create', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }); - - const response = await sessionHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseBody = - (await response.json()) as SessionCreateResultGeneric; - expect(responseBody.success).toBe(true); - expect(responseBody.data).toEqual(mockSession); - expect(responseBody.timestamp).toBeDefined(); - expect(responseBody.timestamp).toMatch( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ - ); - - // Verify service was called correctly - expect(mockSessionManager.createSession).toHaveBeenCalled(); - }); - - it('should handle session creation failures', async () => { - (mockSessionManager.createSession as any).mockResolvedValue({ - success: false, - error: { - message: 'Failed to create session', - code: ErrorCode.UNKNOWN_ERROR, - details: { originalError: 'Store connection failed' } - } - }); - - const request = new Request('http://localhost:3000/api/session/create', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }); - - const response = await sessionHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe(ErrorCode.UNKNOWN_ERROR); - expect(responseData.message).toBe('Failed to create session'); - expect(responseData.context).toEqual({ - originalError: 'Store connection failed' - }); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should generate unique session IDs', async () => { - const mockSession1 = {} as Session; - const mockSession2 = {} as Session; - - (mockSessionManager.createSession as any) - .mockResolvedValueOnce({ success: true, data: mockSession1 }) - .mockResolvedValueOnce({ success: true, data: mockSession2 }); - - const request1 = new Request('http://localhost:3000/api/session/create', { - method: 'POST' - }); - const request2 = new Request('http://localhost:3000/api/session/create', { - method: 'POST' - }); - - const response1 = await sessionHandler.handle(request1, mockContext); - const response2 = await sessionHandler.handle(request2, mockContext); - - const responseBody1 = - (await response1.json()) as SessionCreateResultGeneric; - const responseBody2 = - (await response2.json()) as SessionCreateResultGeneric; - - // Verify both responses are successful and contain session data - expect(responseBody1.success).toBe(true); - expect(responseBody2.success).toBe(true); - expect(responseBody1.data).toEqual(mockSession1); - expect(responseBody2.data).toEqual(mockSession2); - expect(responseBody1.timestamp).toBeDefined(); - expect(responseBody2.timestamp).toBeDefined(); - - expect(mockSessionManager.createSession).toHaveBeenCalledTimes(2); - }); - }); - - describe('handleDelete - POST /api/session/delete', () => { - it('should delete session successfully', async () => { - (mockSessionManager.deleteSession as any).mockResolvedValue({ - success: true - }); - - const request = new Request('http://localhost:3000/api/session/delete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: 'test-session-123' }) - }); - - const response = await sessionHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseBody = (await response.json()) as SessionDeleteResult; - expect(responseBody.success).toBe(true); - expect(responseBody.sessionId).toBe('test-session-123'); - expect(responseBody.timestamp).toBeDefined(); - expect(responseBody.timestamp).toMatch( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ - ); - - // Verify service was called correctly - expect(mockSessionManager.deleteSession).toHaveBeenCalledWith( - 'test-session-123' - ); - }); - - it('should handle session deletion failures', async () => { - (mockSessionManager.deleteSession as any).mockResolvedValue({ - success: false, - error: { - message: "Session 'nonexistent' not found", - code: 'INTERNAL_ERROR', - details: { - sessionId: 'nonexistent', - originalError: 'Session not found' - } - } - }); - - const request = new Request('http://localhost:3000/api/session/delete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: 'nonexistent' }) - }); - - const response = await sessionHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('INTERNAL_ERROR'); - expect(responseData.message).toBe("Session 'nonexistent' not found"); - expect(responseData.context).toEqual({ - sessionId: 'nonexistent', - originalError: 'Session not found' - }); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should reject requests without sessionId', async () => { - const request = new Request('http://localhost:3000/api/session/delete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}) - }); - - const response = await sessionHandler.handle(request, mockContext); - - expect(response.status).toBe(400); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('VALIDATION_FAILED'); - expect(responseData.message).toBe('sessionId is required'); - expect(responseData.httpStatus).toBe(400); - - // Should not call service - expect(mockSessionManager.deleteSession).not.toHaveBeenCalled(); - }); - - it('should reject requests with invalid JSON', async () => { - const request = new Request('http://localhost:3000/api/session/delete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: 'invalid json' - }); - - const response = await sessionHandler.handle(request, mockContext); - - expect(response.status).toBe(400); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe('VALIDATION_FAILED'); - expect(responseData.message).toBe('Invalid request body'); - expect(responseData.httpStatus).toBe(400); - - // Should not call service - expect(mockSessionManager.deleteSession).not.toHaveBeenCalled(); - }); - - it('should include CORS headers in delete responses', async () => { - (mockSessionManager.deleteSession as any).mockResolvedValue({ - success: true - }); - - const request = new Request('http://localhost:3000/api/session/delete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: 'test-session' }) - }); - - const response = await sessionHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); - expect(response.headers.get('Access-Control-Allow-Methods')).toBe( - 'GET, POST, OPTIONS' - ); - expect(response.headers.get('Access-Control-Allow-Headers')).toBe( - 'Content-Type' - ); - }); - }); - - describe('handleList - GET /api/session/list', () => { - it('should list sessions successfully with active processes', async () => { - // SessionManager.listSessions() returns string[] (just session IDs) - const mockSessionIds = ['session-1', 'session-2', 'session-3']; - - (mockSessionManager.listSessions as any).mockResolvedValue({ - success: true, - data: mockSessionIds - }); - - const request = new Request('http://localhost:3000/api/session/list', { - method: 'GET' - }); - - const response = await sessionHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseBody = (await response.json()) as SessionListResult; - expect(responseBody.success).toBe(true); - expect(responseBody.data).toEqual(mockSessionIds); - expect(responseBody.data).toHaveLength(3); - expect(responseBody.timestamp).toBeDefined(); - expect(responseBody.timestamp).toMatch( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ - ); - - // Verify service was called correctly - expect(mockSessionManager.listSessions).toHaveBeenCalled(); - }); - - it('should return empty list when no sessions exist', async () => { - (mockSessionManager.listSessions as any).mockResolvedValue({ - success: true, - data: [] - }); - - const request = new Request('http://localhost:3000/api/session/list', { - method: 'GET' - }); - - const response = await sessionHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - const responseBody = (await response.json()) as SessionListResult; - expect(responseBody.success).toBe(true); - expect(responseBody.data).toHaveLength(0); - expect(responseBody.data).toEqual([]); - expect(responseBody.timestamp).toBeDefined(); - }); - - it('should handle sessions with various activeProcess values', async () => { - // SessionManager returns string[] - no activeProcess info available - const mockSessionIds = ['session-1', 'session-2', 'session-3']; - - (mockSessionManager.listSessions as any).mockResolvedValue({ - success: true, - data: mockSessionIds - }); - - const request = new Request('http://localhost:3000/api/session/list', { - method: 'GET' - }); - - const response = await sessionHandler.handle(request, mockContext); - - const responseBody = (await response.json()) as SessionListResult; - - // Handler returns array of session IDs - expect(responseBody.success).toBe(true); - expect(responseBody.data).toEqual([ - 'session-1', - 'session-2', - 'session-3' - ]); - expect(responseBody.timestamp).toBeDefined(); - }); - - it('should handle session listing failures', async () => { - (mockSessionManager.listSessions as any).mockResolvedValue({ - success: false, - error: { - message: 'Failed to list sessions', - code: ErrorCode.UNKNOWN_ERROR, - details: { originalError: 'Database connection lost' } - } - }); - - const request = new Request('http://localhost:3000/api/session/list', { - method: 'GET' - }); - - const response = await sessionHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.code).toBe(ErrorCode.UNKNOWN_ERROR); - expect(responseData.message).toBe('Failed to list sessions'); - expect(responseData.context).toEqual({ - originalError: 'Database connection lost' - }); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should handle sessions with undefined activeProcess', async () => { - // SessionManager returns string[] - no activeProcess info - const mockSessionIds = ['session-1']; - - (mockSessionManager.listSessions as any).mockResolvedValue({ - success: true, - data: mockSessionIds - }); - - const request = new Request('http://localhost:3000/api/session/list', { - method: 'GET' - }); - - const response = await sessionHandler.handle(request, mockContext); - - const responseBody = (await response.json()) as SessionListResult; - // Handler returns array of session IDs - expect(responseBody.success).toBe(true); - expect(responseBody.data).toEqual(['session-1']); - expect(responseBody.timestamp).toBeDefined(); - }); - }); - - describe('route handling', () => { - it('should return 500 for invalid session endpoints', async () => { - const request = new Request( - 'http://localhost:3000/api/session/invalid-operation', - { - method: 'POST' - } - ); - - const response = await sessionHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.message).toBe('Invalid session endpoint'); - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - - // Should not call any service methods - expect(mockSessionManager.createSession).not.toHaveBeenCalled(); - expect(mockSessionManager.listSessions).not.toHaveBeenCalled(); - expect(mockSessionManager.deleteSession).not.toHaveBeenCalled(); - }); - - it('should return 500 for root session path', async () => { - const request = new Request('http://localhost:3000/api/session/', { - method: 'GET' - }); - - const response = await sessionHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.message).toBe('Invalid session endpoint'); - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - - it('should return 500 for session endpoint without operation', async () => { - const request = new Request('http://localhost:3000/api/session', { - method: 'GET' - }); - - const response = await sessionHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - const responseData = (await response.json()) as ErrorResponse; - expect(responseData.message).toBe('Invalid session endpoint'); - expect(responseData.code).toBe('UNKNOWN_ERROR'); - expect(responseData.httpStatus).toBe(500); - expect(responseData.timestamp).toBeDefined(); - }); - }); - - describe('CORS headers', () => { - it('should include CORS headers in successful create responses', async () => { - const mockSession = {} as Session; - - (mockSessionManager.createSession as any).mockResolvedValue({ - success: true, - data: mockSession - }); - - const request = new Request('http://localhost:3000/api/session/create', { - method: 'POST' - }); - - const response = await sessionHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); - expect(response.headers.get('Access-Control-Allow-Methods')).toBe( - 'GET, POST, OPTIONS' - ); - expect(response.headers.get('Access-Control-Allow-Headers')).toBe( - 'Content-Type' - ); - }); - - it('should include CORS headers in successful list responses', async () => { - (mockSessionManager.listSessions as any).mockResolvedValue({ - success: true, - data: [] - }); - - const request = new Request('http://localhost:3000/api/session/list', { - method: 'GET' - }); - - const response = await sessionHandler.handle(request, mockContext); - - expect(response.status).toBe(200); - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); - }); - - it('should include CORS headers in error responses', async () => { - const request = new Request('http://localhost:3000/api/session/invalid', { - method: 'GET' - }); - - const response = await sessionHandler.handle(request, mockContext); - - expect(response.status).toBe(500); - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); - }); - }); - - describe('response format consistency', () => { - it('should have proper Content-Type header for all responses', async () => { - // Test create endpoint - const mockSession = {} as Session; - - (mockSessionManager.createSession as any).mockResolvedValue({ - success: true, - data: mockSession - }); - - const createRequest = new Request( - 'http://localhost:3000/api/session/create', - { - method: 'POST' - } - ); - - const createResponse = await sessionHandler.handle( - createRequest, - mockContext - ); - expect(createResponse.headers.get('Content-Type')).toBe( - 'application/json' - ); - - // Test list endpoint - (mockSessionManager.listSessions as any).mockResolvedValue({ - success: true, - data: [] - }); - - const listRequest = new Request( - 'http://localhost:3000/api/session/list', - { - method: 'GET' - } - ); - - const listResponse = await sessionHandler.handle( - listRequest, - mockContext - ); - expect(listResponse.headers.get('Content-Type')).toBe('application/json'); - }); - - it('should return consistent timestamp format', async () => { - const mockSession = {} as Session; - - (mockSessionManager.createSession as any).mockResolvedValue({ - success: true, - data: mockSession - }); - - const request = new Request('http://localhost:3000/api/session/create', { - method: 'POST' - }); - - const response = await sessionHandler.handle(request, mockContext); - const responseBody = - (await response.json()) as SessionCreateResultGeneric; - - // Verify timestamp is valid ISO string - expect(responseBody.timestamp).toBeDefined(); - expect(new Date(responseBody.timestamp)).toBeInstanceOf(Date); - expect(responseBody.timestamp).toMatch( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ - ); - }); - - it('should return session IDs as data in list response', async () => { - // SessionManager returns string[] - session IDs - const mockSessionIds = ['session-1']; - - (mockSessionManager.listSessions as any).mockResolvedValue({ - success: true, - data: mockSessionIds - }); - - const request = new Request('http://localhost:3000/api/session/list', { - method: 'GET' - }); - - const response = await sessionHandler.handle(request, mockContext); - const responseBody = (await response.json()) as SessionListResult; - - // Handler returns array of session IDs in data field - expect(responseBody.success).toBe(true); - expect(responseBody.data).toEqual(['session-1']); - expect(responseBody.timestamp).toBeDefined(); - }); - }); - - describe('data transformation', () => { - it('should properly return session IDs', async () => { - // SessionManager returns string[] - only session IDs - const mockSessionIds = ['session-external-id']; - - (mockSessionManager.listSessions as any).mockResolvedValue({ - success: true, - data: mockSessionIds - }); - - const request = new Request('http://localhost:3000/api/session/list', { - method: 'GET' - }); - - const response = await sessionHandler.handle(request, mockContext); - const responseBody = (await response.json()) as SessionListResult; - - // Handler returns array of session IDs - expect(responseBody.success).toBe(true); - expect(responseBody.data).toEqual(['session-external-id']); - expect(responseBody.data[0]).toBe('session-external-id'); - - // Verify response structure - expect(Object.keys(responseBody).sort()).toEqual( - ['data', 'success', 'timestamp'].sort() - ); - }); - }); -}); diff --git a/packages/sandbox-container/tests/handlers/watch-handler.test.ts b/packages/sandbox-container/tests/handlers/watch-handler.test.ts deleted file mode 100644 index ccf0b0623..000000000 --- a/packages/sandbox-container/tests/handlers/watch-handler.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { createNoOpLogger } from '@repo/shared'; -import { describe, expect, it, vi } from 'vitest'; -import { WatchHandler } from '../../src/handlers/watch-handler'; -import type { WatchService } from '../../src/services/watch-service'; - -function createMockWatchService(): WatchService { - return { - watchDirectory: vi.fn() - } as unknown as WatchService; -} - -function makeRequest(body: Record): Request { - return new Request('http://localhost:3000/api/watch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }); -} - -const defaultContext = { - traceContext: { traceId: 'test', spanId: 'test' }, - corsHeaders: {}, - requestId: 'test-req', - timestamp: new Date() -}; - -describe('WatchHandler', () => { - describe('include/exclude validation', () => { - it('should reject requests with both include and exclude', async () => { - const handler = new WatchHandler( - createMockWatchService(), - createNoOpLogger() - ); - - const response = await handler.handle( - makeRequest({ - path: '/workspace/test', - include: ['*.ts'], - exclude: ['node_modules'] - }), - defaultContext - ); - - expect(response.status).toBe(400); - const body = (await response.json()) as { message: string }; - expect(body.message).toContain( - 'include and exclude cannot be used together' - ); - }); - - it('should allow include without exclude', async () => { - const watchService = createMockWatchService(); - const mockStream = new ReadableStream(); - ( - watchService.watchDirectory as ReturnType - ).mockResolvedValue({ - success: true, - data: mockStream - }); - - const handler = new WatchHandler(watchService, createNoOpLogger()); - - const response = await handler.handle( - makeRequest({ path: '/workspace/test', include: ['*.ts'] }), - defaultContext - ); - - expect(response.status).toBe(200); - }); - - it('should allow exclude without include', async () => { - const watchService = createMockWatchService(); - const mockStream = new ReadableStream(); - ( - watchService.watchDirectory as ReturnType - ).mockResolvedValue({ - success: true, - data: mockStream - }); - - const handler = new WatchHandler(watchService, createNoOpLogger()); - - const response = await handler.handle( - makeRequest({ - path: '/workspace/test', - exclude: ['node_modules'] - }), - defaultContext - ); - - expect(response.status).toBe(200); - }); - - it('should allow empty include with non-empty exclude', async () => { - const watchService = createMockWatchService(); - const mockStream = new ReadableStream(); - ( - watchService.watchDirectory as ReturnType - ).mockResolvedValue({ - success: true, - data: mockStream - }); - - const handler = new WatchHandler(watchService, createNoOpLogger()); - - const response = await handler.handle( - makeRequest({ - path: '/workspace/test', - include: [], - exclude: ['node_modules'] - }), - defaultContext - ); - - expect(response.status).toBe(200); - }); - }); -}); diff --git a/packages/sandbox-container/tests/handlers/ws-adapter.test.ts b/packages/sandbox-container/tests/handlers/ws-adapter.test.ts deleted file mode 100644 index 05286c388..000000000 --- a/packages/sandbox-container/tests/handlers/ws-adapter.test.ts +++ /dev/null @@ -1,487 +0,0 @@ -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, - WebSocketAdapter, - type WSData -} from '../../src/handlers/ws-adapter'; - -// 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('WebSocketAdapter', () => { - let adapter: WebSocketAdapter; - let mockRouter: Router; - let mockLogger: Logger; - let mockWs: MockServerWebSocket; - - beforeEach(() => { - vi.clearAllMocks(); - mockRouter = createMockRouter(); - mockLogger = createMockLogger(); - adapter = new WebSocketAdapter(mockRouter, mockLogger); - mockWs = new MockServerWebSocket({ connectionId: 'test-conn-123' }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - 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 adapter.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 adapter.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 adapter.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 adapter.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 ignore cancel message for unknown request id', async () => { - await adapter.onMessage( - mockWs as any, - JSON.stringify({ type: 'cancel', id: 'missing-request' }) - ); - - expect(mockRouter.route).not.toHaveBeenCalled(); - expect(mockWs.getSentMessages()).toHaveLength(0); - }); - - 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 adapter.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 adapter.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 adapter.onMessage(mockWs as any, JSON.stringify(request)); - - // Streaming runs in background to avoid blocking the message handler. - // Wait for the final response which indicates streaming completed. - const waitForFinalResponse = async (maxWait = 1000) => { - const start = Date.now(); - while (Date.now() - start < maxWait) { - const messages = mockWs.getSentMessages(); - const finalResponse = messages.find( - (m) => m.type === 'response' && m.done === true - ); - if (finalResponse) return; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - }; - await waitForFinalResponse(); - - // 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 ignore cancel from another connection for an active stream', async () => { - const ownerWs = new MockServerWebSocket({ connectionId: 'owner-conn' }); - const otherWs = new MockServerWebSocket({ connectionId: 'other-conn' }); - - const request: WSRequest = { - type: 'request', - id: 'req-cross-cancel', - method: 'POST', - path: '/api/execute/stream', - body: { command: 'echo test' } - }; - - const stream = new ReadableStream({ - start() { - // Keep stream open so activeStreams retains the request while cancel is tested. - } - }); - - (mockRouter.route as any).mockResolvedValue( - new Response(stream, { - status: 200, - headers: { 'Content-Type': 'text/event-stream' } - }) - ); - - await adapter.onMessage(ownerWs as any, JSON.stringify(request)); - await new Promise((resolve) => setTimeout(resolve, 0)); - - await adapter.onMessage( - otherWs as any, - JSON.stringify({ type: 'cancel', id: request.id }) - ); - - const activeStreams = (adapter as any).activeStreams as Map< - string, - unknown - >; - expect(activeStreams.has(request.id)).toBe(true); - - await adapter.onMessage( - ownerWs as any, - JSON.stringify({ type: 'cancel', id: request.id }) - ); - expect(activeStreams.has(request.id)).toBe(false); - }); - - it('should only cancel streams for the closing connection', async () => { - const wsA = new MockServerWebSocket({ connectionId: 'conn-a' }); - const wsB = new MockServerWebSocket({ connectionId: 'conn-b' }); - - const requestA: WSRequest = { - type: 'request', - id: 'stream-a', - method: 'POST', - path: '/api/execute/stream', - body: { command: 'echo a' } - }; - - const requestB: WSRequest = { - type: 'request', - id: 'stream-b', - method: 'POST', - path: '/api/execute/stream', - body: { command: 'echo b' } - }; - - (mockRouter.route as any).mockImplementation(() => { - const stream = new ReadableStream({ - start() { - // Keep each stream open so ownership can be tested via onClose. - } - }); - - return new Response(stream, { - status: 200, - headers: { 'Content-Type': 'text/event-stream' } - }); - }); - - await adapter.onMessage(wsA as any, JSON.stringify(requestA)); - await adapter.onMessage(wsB as any, JSON.stringify(requestB)); - await new Promise((resolve) => setTimeout(resolve, 0)); - - const activeStreams = (adapter as any).activeStreams as Map< - string, - unknown - >; - expect(activeStreams.has('stream-a')).toBe(true); - expect(activeStreams.has('stream-b')).toBe(true); - - adapter.onClose(wsA as any, 1000, 'Normal closure'); - - expect(activeStreams.has('stream-a')).toBe(false); - expect(activeStreams.has('stream-b')).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 adapter.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 adapter: WebSocketAdapter; - let mockRouter: Router; - let mockLogger: Logger; - - beforeEach(() => { - mockRouter = createMockRouter(); - mockLogger = createMockLogger(); - adapter = new WebSocketAdapter(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) => - adapter.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 adapter.onMessage(mockWs as any, JSON.stringify(failRequest)); - await adapter.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-container/tests/middleware/logging.test.ts b/packages/sandbox-container/tests/middleware/logging.test.ts deleted file mode 100644 index 779e8bbbb..000000000 --- a/packages/sandbox-container/tests/middleware/logging.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'bun:test'; -import type { Logger } from '@repo/shared'; -import type { RequestContext } from '@sandbox-container/core/types'; -import { LoggingMiddleware } from '@sandbox-container/middleware/logging'; - -const mockLogger = { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - child: vi.fn() -} as Logger; -mockLogger.child = vi.fn(() => mockLogger); - -function makeContext(overrides?: Partial): RequestContext { - return { - requestId: 'req-test-1', - timestamp: new Date(), - corsHeaders: {}, - sessionId: 'session-default', - sandboxId: 'sandbox-abc123', - ...overrides - }; -} - -function makeRequest(method = 'GET', path = '/api/test'): Request { - return new Request(`http://localhost:3000${path}`, { method }); -} - -describe('LoggingMiddleware', () => { - let middleware: LoggingMiddleware; - - beforeEach(() => { - vi.clearAllMocks(); - middleware = new LoggingMiddleware(mockLogger); - }); - - it('should log a successful request at debug level with sandboxId', async () => { - const context = makeContext({ sandboxId: 'sandbox-abc123' }); - const request = makeRequest('POST', '/api/exec'); - const mockResponse = new Response(JSON.stringify({ success: true }), { - status: 200 - }); - - const next = vi.fn().mockResolvedValue(mockResponse); - const response = await middleware.handle(request, context, next); - - expect(response.status).toBe(200); - expect(mockLogger.debug).toHaveBeenCalledTimes(1); - expect(mockLogger.info).not.toHaveBeenCalled(); - expect(mockLogger.error).not.toHaveBeenCalled(); - - const [message, loggedContext] = ( - mockLogger.debug as ReturnType - ).mock.calls[0]; - expect(message).toBe('POST /api/exec 200'); - expect(loggedContext.method).toBe('POST'); - expect(loggedContext.pathname).toBe('/api/exec'); - expect(loggedContext.sandboxId).toBe('sandbox-abc123'); - expect(loggedContext.requestId).toBe('req-test-1'); - expect(loggedContext.sessionId).toBe('session-default'); - expect(loggedContext.statusCode).toBe(200); - expect(typeof loggedContext.durationMs).toBe('number'); - }); - - it('should log a 4xx response at debug level', async () => { - const context = makeContext({ sandboxId: 'sandbox-warn' }); - const request = makeRequest('GET', '/api/missing'); - const mockResponse = new Response('Not found', { status: 404 }); - - const next = vi.fn().mockResolvedValue(mockResponse); - await middleware.handle(request, context, next); - - expect(mockLogger.debug).toHaveBeenCalledTimes(1); - expect(mockLogger.info).not.toHaveBeenCalled(); - expect(mockLogger.warn).not.toHaveBeenCalled(); - expect(mockLogger.error).not.toHaveBeenCalled(); - - const [message, loggedContext] = ( - mockLogger.debug as ReturnType - ).mock.calls[0]; - expect(message).toBe('GET /api/missing 404'); - expect(loggedContext.sandboxId).toBe('sandbox-warn'); - expect(loggedContext.statusCode).toBe(404); - }); - - it('should log a 5xx response at warn level', async () => { - const context = makeContext({ sandboxId: 'sandbox-err' }); - const request = makeRequest('GET', '/api/fail'); - const mockResponse = new Response('Internal error', { status: 500 }); - - const next = vi.fn().mockResolvedValue(mockResponse); - await middleware.handle(request, context, next); - - expect(mockLogger.warn).toHaveBeenCalledTimes(1); - expect(mockLogger.info).not.toHaveBeenCalled(); - expect(mockLogger.debug).not.toHaveBeenCalled(); - expect(mockLogger.error).not.toHaveBeenCalled(); - - const [message, loggedContext] = ( - mockLogger.warn as ReturnType - ).mock.calls[0]; - expect(message).toBe('GET /api/fail 500'); - expect(loggedContext.sandboxId).toBe('sandbox-err'); - expect(loggedContext.statusCode).toBe(500); - }); - - it('should log at warn level and rethrow when next() throws', async () => { - const context = makeContext({ sandboxId: 'sandbox-throw' }); - const request = makeRequest('DELETE', '/api/crash'); - const thrown = new Error('Handler exploded'); - - const next = vi.fn().mockRejectedValue(thrown); - - await expect(middleware.handle(request, context, next)).rejects.toThrow( - 'Handler exploded' - ); - - expect(mockLogger.warn).toHaveBeenCalledTimes(1); - expect(mockLogger.error).not.toHaveBeenCalled(); - const [message, loggedContext] = ( - mockLogger.warn as ReturnType - ).mock.calls[0]; - expect(message).toBe('DELETE /api/crash 500'); - expect(loggedContext.error).toBe('Handler exploded'); - expect(loggedContext.sandboxId).toBe('sandbox-throw'); - }); - - it('should include sandboxId as undefined when not provided in context', async () => { - const contextWithoutSandboxId = makeContext({ sandboxId: undefined }); - const request = makeRequest('GET', '/api/health'); - const mockResponse = new Response('ok', { status: 200 }); - - const next = vi.fn().mockResolvedValue(mockResponse); - await middleware.handle(request, contextWithoutSandboxId, next); - - const [, loggedContext] = (mockLogger.debug as ReturnType) - .mock.calls[0]; - expect(loggedContext.sandboxId).toBeUndefined(); - }); -}); diff --git a/packages/sandbox/src/clients/backup-client.ts b/packages/sandbox/src/clients/backup-client.ts deleted file mode 100644 index 9a4f3e96f..000000000 --- a/packages/sandbox/src/clients/backup-client.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { - CreateBackupRequest, - CreateBackupResponse, - RestoreBackupRequest, - RestoreBackupResponse -} from '@repo/shared'; -import { BaseHttpClient } from './base-client'; - -/** - * Client for backup operations. - * - * Handles communication with the container's backup endpoints. - * The container creates/extracts squashfs archives locally. - * R2 upload/download is handled by the Sandbox DO, not by this client. - */ -export class BackupClient extends BaseHttpClient { - /** - * Tell the container to create a squashfs archive from a directory. - * @param dir - Directory to back up - * @param archivePath - Where the container should write the archive - * @param sessionId - Session context - */ - async createArchive( - dir: string, - archivePath: string, - sessionId: string, - gitignore = false, - excludes: string[] = [] - ): Promise { - try { - const data: CreateBackupRequest = { - dir, - archivePath, - gitignore, - excludes, - sessionId - }; - - const response = await this.post( - '/api/backup/create', - data - ); - - return response; - } catch (error) { - throw error; - } - } - - /** - * Tell the container to restore a squashfs archive into a directory. - * @param dir - Target directory - * @param archivePath - Path to the archive file in the container - * @param sessionId - Session context - */ - async restoreArchive( - dir: string, - archivePath: string, - sessionId: string - ): Promise { - try { - const data: RestoreBackupRequest = { - dir, - archivePath, - sessionId - }; - - const response = await this.post( - '/api/backup/restore', - data - ); - - return response; - } catch (error) { - throw error; - } - } -} diff --git a/packages/sandbox/src/clients/base-client.ts b/packages/sandbox/src/clients/base-client.ts deleted file mode 100644 index d3548dca9..000000000 --- a/packages/sandbox/src/clients/base-client.ts +++ /dev/null @@ -1,240 +0,0 @@ -import type { Logger } from '@repo/shared'; -import { createNoOpLogger } from '@repo/shared'; -import type { ErrorResponse as NewErrorResponse } from '../errors'; -import { createErrorFromResponse, ErrorCode } from '../errors'; -import { createTransport, type ITransport } from './transport'; -import type { HttpClientOptions, ResponseHandler } from './types'; - -/** - * Abstract base class providing common HTTP/WebSocket functionality for all domain clients - * - * All requests go through the Transport abstraction layer, which handles: - * - HTTP and WebSocket modes transparently - * - Automatic retry for 503 errors (container starting) - * - Streaming responses - * - * WebSocket mode is useful when running inside Workers/Durable Objects - * where sub-request limits apply. - */ -export abstract class BaseHttpClient { - protected options: HttpClientOptions; - protected logger: Logger; - protected transport: ITransport; - - constructor(options: HttpClientOptions = {}) { - this.options = options; - this.logger = options.logger ?? createNoOpLogger(); - - // Always create a Transport - it handles both HTTP and WebSocket modes - if (options.transport) { - this.transport = options.transport; - } else { - const mode = options.transportMode ?? 'http'; - this.transport = createTransport({ - mode, - baseUrl: options.baseUrl ?? 'http://localhost:3000', - wsUrl: options.wsUrl, - logger: this.logger, - stub: options.stub, - port: options.port, - retryTimeoutMs: options.retryTimeoutMs - }); - } - } - - /** - * Update the transport's 503 retry budget - */ - setRetryTimeoutMs(ms: number): void { - this.transport.setRetryTimeoutMs(ms); - } - - /** - * Check if using WebSocket transport - */ - protected isWebSocketMode(): boolean { - return this.transport.getMode() === 'websocket'; - } - - /** - * Core fetch method - delegates to Transport which handles retry logic - */ - protected async doFetch( - path: string, - options?: RequestInit - ): Promise { - const { defaultHeaders } = this.options; - if (defaultHeaders) { - options = { - ...options, - headers: { - ...defaultHeaders, - ...(options?.headers as Record | undefined) - } - }; - } - return this.transport.fetch(path, options); - } - - /** - * Make a POST request with JSON body - */ - protected async post( - endpoint: string, - data: unknown, - responseHandler?: ResponseHandler - ): Promise { - const response = await this.doFetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(data) - }); - - return this.handleResponse(response, responseHandler); - } - - /** - * Make a GET request - */ - protected async get( - endpoint: string, - responseHandler?: ResponseHandler - ): Promise { - const response = await this.doFetch(endpoint, { - method: 'GET' - }); - - return this.handleResponse(response, responseHandler); - } - - /** - * Make a DELETE request - */ - protected async delete( - endpoint: string, - responseHandler?: ResponseHandler - ): Promise { - const response = await this.doFetch(endpoint, { - method: 'DELETE' - }); - - return this.handleResponse(response, responseHandler); - } - - /** - * Handle HTTP response with error checking and parsing - */ - protected async handleResponse( - response: Response, - customHandler?: ResponseHandler - ): Promise { - if (!response.ok) { - await this.handleErrorResponse(response); - } - - if (customHandler) { - return customHandler(response); - } - - try { - return await response.json(); - } catch (error) { - // Handle malformed JSON responses gracefully - const errorResponse: NewErrorResponse = { - code: ErrorCode.INVALID_JSON_RESPONSE, - message: `Invalid JSON response: ${ - error instanceof Error ? error.message : 'Unknown parsing error' - }`, - context: {}, - httpStatus: response.status, - timestamp: new Date().toISOString() - }; - throw createErrorFromResponse(errorResponse); - } - } - - /** - * Handle error responses with consistent error throwing - */ - protected async handleErrorResponse(response: Response): Promise { - let errorData: NewErrorResponse; - - try { - errorData = await response.json(); - } catch { - // Fallback if response isn't JSON or parsing fails - errorData = { - code: ErrorCode.INTERNAL_ERROR, - message: `HTTP error! status: ${response.status}`, - context: { statusText: response.statusText }, - httpStatus: response.status, - timestamp: new Date().toISOString() - }; - } - - // Convert ErrorResponse to appropriate Error class - const error = createErrorFromResponse(errorData); - - // Call error callback if provided - this.options.onError?.(errorData.message, undefined); - - throw error; - } - - /** - * Create a streaming response handler for Server-Sent Events - */ - protected async handleStreamResponse( - response: Response - ): Promise> { - if (!response.ok) { - await this.handleErrorResponse(response); - } - - if (!response.body) { - throw new Error('No response body for streaming'); - } - - return response.body; - } - - /** - * Stream request handler - * - * For HTTP mode, uses doFetch + handleStreamResponse to get proper error typing. - * For WebSocket mode, uses Transport's streaming support. - * - * @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> { - const streamHeaders = - method === 'POST' - ? { - ...this.options.defaultHeaders, - 'Content-Type': 'application/json' - } - : this.options.defaultHeaders; - - // WebSocket mode uses Transport's streaming directly - if (this.transport.getMode() === 'websocket') { - return this.transport.fetchStream(path, body, method, streamHeaders); - } - - // HTTP mode: use doFetch + handleStreamResponse for proper error typing - const response = await this.doFetch(path, { - method, - headers: { 'Content-Type': 'application/json' }, - body: body && method === 'POST' ? JSON.stringify(body) : undefined - }); - - return this.handleStreamResponse(response); - } -} diff --git a/packages/sandbox/src/clients/command-client.ts b/packages/sandbox/src/clients/command-client.ts deleted file mode 100644 index cee7761f8..000000000 --- a/packages/sandbox/src/clients/command-client.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { ExecuteRequest } from '@repo/shared'; -import { BaseHttpClient } from './base-client'; -import type { BaseApiResponse } from './types'; - -/** - * Request interface for command execution - */ -export type { ExecuteRequest }; - -/** - * Response interface for command execution - */ -export interface ExecuteResponse extends BaseApiResponse { - stdout: string; - stderr: string; - exitCode: number; - command: string; -} - -/** - * Client for command execution operations - */ -export class CommandClient extends BaseHttpClient { - /** - * Execute a command and return the complete result - * @param command - The command to execute - * @param sessionId - The session ID for this command execution - * @param timeoutMs - Optional timeout in milliseconds (unlimited by default) - * @param env - Optional environment variables for this command - * @param cwd - Optional working directory for this command - */ - async execute( - command: string, - sessionId: string, - options?: { - timeoutMs?: number; - env?: Record; - cwd?: string; - origin?: 'user' | 'internal'; - } - ): Promise { - try { - const data: ExecuteRequest = { - command, - sessionId, - ...(options?.timeoutMs !== undefined && { - timeoutMs: options.timeoutMs - }), - ...(options?.env !== undefined && { env: options.env }), - ...(options?.cwd !== undefined && { cwd: options.cwd }), - ...(options?.origin !== undefined && { origin: options.origin }) - }; - - const response = await this.post('/api/execute', data); - - // Call the callback if provided - this.options.onCommandComplete?.( - response.success, - response.exitCode, - response.stdout, - response.stderr, - response.command - ); - - return response; - } catch (error) { - // Call error callback if provided - this.options.onError?.( - error instanceof Error ? error.message : String(error), - command - ); - - throw error; - } - } - - /** - * Execute a command and return a stream of events - * @param command - The command to execute - * @param sessionId - The session ID for this command execution - * @param options - Optional per-command execution settings - */ - async executeStream( - command: string, - sessionId: string, - options?: { - timeoutMs?: number; - env?: Record; - cwd?: string; - origin?: 'user' | 'internal'; - } - ): Promise> { - try { - const data = { - command, - sessionId, - ...(options?.timeoutMs !== undefined && { - timeoutMs: options.timeoutMs - }), - ...(options?.env !== undefined && { env: options.env }), - ...(options?.cwd !== undefined && { cwd: options.cwd }), - ...(options?.origin !== undefined && { origin: options.origin }) - }; - - // Use doStreamFetch which handles both WebSocket and HTTP streaming - const stream = await this.doStreamFetch('/api/execute/stream', data); - - return stream; - } catch (error) { - // Call error callback if provided - this.options.onError?.( - error instanceof Error ? error.message : String(error), - command - ); - - throw error; - } - } -} diff --git a/packages/sandbox/src/clients/desktop-client.ts b/packages/sandbox/src/clients/desktop-client.ts deleted file mode 100644 index d9bf4d228..000000000 --- a/packages/sandbox/src/clients/desktop-client.ts +++ /dev/null @@ -1,579 +0,0 @@ -import { BaseHttpClient } from './base-client'; -import type { BaseApiResponse } from './types'; - -export interface DesktopStartOptions { - resolution?: [number, number]; - dpi?: number; -} - -export interface ScreenshotOptions { - format?: 'base64' | 'bytes'; - imageFormat?: 'png' | 'jpeg' | 'webp'; - quality?: number; - showCursor?: boolean; -} - -export interface ScreenshotRegion { - x: number; - y: number; - width: number; - height: number; -} - -export interface ClickOptions { - button?: 'left' | 'right' | 'middle'; -} - -export type ScrollDirection = 'up' | 'down' | 'left' | 'right'; -export type KeyInput = string; - -export interface TypeOptions { - delayMs?: number; -} - -export interface DesktopStartResponse extends BaseApiResponse { - resolution: [number, number]; - dpi: number; -} - -export interface DesktopStopResponse extends BaseApiResponse {} - -export interface DesktopStatusResponse extends BaseApiResponse { - status: 'active' | 'partial' | 'inactive'; - processes: Record< - string, - { running: boolean; pid?: number; uptime?: number } - >; - resolution: [number, number] | null; - dpi: number | null; -} - -export interface ScreenshotResponse extends BaseApiResponse { - data: string; - imageFormat: 'png' | 'jpeg' | 'webp'; - width: number; - height: number; -} - -export interface ScreenshotBytesResponse extends BaseApiResponse { - data: Uint8Array; - imageFormat: 'png' | 'jpeg' | 'webp'; - width: number; - height: number; -} - -export interface CursorPositionResponse extends BaseApiResponse { - x: number; - y: number; -} - -export interface ScreenSizeResponse extends BaseApiResponse { - width: number; - height: number; -} - -/** - * Public interface for desktop operations. - * Returned by `sandbox.desktop` via an RpcTarget wrapper so that pipelined - * method calls work across the Durable Object RPC boundary. - */ -export interface Desktop { - start(options?: DesktopStartOptions): Promise; - stop(): Promise; - status(): Promise; - screenshot( - options?: ScreenshotOptions & { format?: 'base64' } - ): Promise; - screenshot( - options: ScreenshotOptions & { format: 'bytes' } - ): Promise; - screenshot( - options?: ScreenshotOptions - ): Promise; - screenshotRegion( - region: ScreenshotRegion, - options?: ScreenshotOptions & { format?: 'base64' } - ): Promise; - screenshotRegion( - region: ScreenshotRegion, - options: ScreenshotOptions & { format: 'bytes' } - ): Promise; - screenshotRegion( - region: ScreenshotRegion, - options?: ScreenshotOptions - ): Promise; - click(x: number, y: number, options?: ClickOptions): Promise; - doubleClick(x: number, y: number, options?: ClickOptions): Promise; - tripleClick(x: number, y: number, options?: ClickOptions): Promise; - rightClick(x: number, y: number): Promise; - middleClick(x: number, y: number): Promise; - mouseDown(x?: number, y?: number, options?: ClickOptions): Promise; - mouseUp(x?: number, y?: number, options?: ClickOptions): Promise; - moveMouse(x: number, y: number): Promise; - drag( - startX: number, - startY: number, - endX: number, - endY: number, - options?: ClickOptions - ): Promise; - scroll( - x: number, - y: number, - direction: ScrollDirection, - amount?: number - ): Promise; - getCursorPosition(): Promise; - type(text: string, options?: TypeOptions): Promise; - press(key: KeyInput): Promise; - keyDown(key: KeyInput): Promise; - keyUp(key: KeyInput): Promise; - getScreenSize(): Promise; - getProcessStatus( - name: string - ): Promise< - BaseApiResponse & { running: boolean; pid?: number; uptime?: number } - >; -} - -/** - * Client for desktop environment lifecycle, input, and screen operations - */ -export class DesktopClient extends BaseHttpClient { - /** - * Start the desktop environment with optional resolution and DPI. - */ - async start(options?: DesktopStartOptions): Promise { - try { - const data = { - ...(options?.resolution !== undefined && { - resolution: options.resolution - }), - ...(options?.dpi !== undefined && { dpi: options.dpi }) - }; - - const response = await this.post( - '/api/desktop/start', - data - ); - - return response; - } catch (error) { - this.options.onError?.( - error instanceof Error ? error.message : String(error) - ); - throw error; - } - } - - /** - * Stop the desktop environment and all related processes. - */ - async stop(): Promise { - try { - const response = await this.post( - '/api/desktop/stop', - {} - ); - return response; - } catch (error) { - this.options.onError?.( - error instanceof Error ? error.message : String(error) - ); - throw error; - } - } - - /** - * Get desktop lifecycle and process health status. - */ - async status(): Promise { - try { - const response = await this.get( - '/api/desktop/status' - ); - return response; - } catch (error) { - throw error; - } - } - - /** - * Capture a full-screen screenshot as base64 (default). - */ - async screenshot( - options?: ScreenshotOptions & { format?: 'base64' } - ): Promise; - /** - * Capture a full-screen screenshot as bytes. - */ - async screenshot( - options: ScreenshotOptions & { format: 'bytes' } - ): Promise; - async screenshot( - options?: ScreenshotOptions - ): Promise { - try { - const wantsBytes = options?.format === 'bytes'; - const data = { - format: 'base64', - ...(options?.imageFormat !== undefined && { - imageFormat: options.imageFormat - }), - ...(options?.quality !== undefined && { quality: options.quality }), - ...(options?.showCursor !== undefined && { - showCursor: options.showCursor - }) - }; - - const response = await this.post( - '/api/desktop/screenshot', - data - ); - - if (wantsBytes) { - const binaryString = atob(response.data); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - - return { - ...response, - data: bytes - } as ScreenshotBytesResponse; - } - - return response; - } catch (error) { - throw error; - } - } - - /** - * Capture a region screenshot as base64 (default). - */ - async screenshotRegion( - region: ScreenshotRegion, - options?: ScreenshotOptions & { format?: 'base64' } - ): Promise; - /** - * Capture a region screenshot as bytes. - */ - async screenshotRegion( - region: ScreenshotRegion, - options: ScreenshotOptions & { format: 'bytes' } - ): Promise; - async screenshotRegion( - region: ScreenshotRegion, - options?: ScreenshotOptions - ): Promise { - try { - const wantsBytes = options?.format === 'bytes'; - const data = { - region, - format: 'base64', - ...(options?.imageFormat !== undefined && { - imageFormat: options.imageFormat - }), - ...(options?.quality !== undefined && { quality: options.quality }), - ...(options?.showCursor !== undefined && { - showCursor: options.showCursor - }) - }; - - const response = await this.post( - '/api/desktop/screenshot/region', - data - ); - - if (wantsBytes) { - const binaryString = atob(response.data); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - - return { - ...response, - data: bytes - } as ScreenshotBytesResponse; - } - - return response; - } catch (error) { - throw error; - } - } - - /** - * Single-click at the given coordinates. - */ - async click(x: number, y: number, options?: ClickOptions): Promise { - try { - await this.post('/api/desktop/mouse/click', { - x, - y, - button: options?.button ?? 'left', - clickCount: 1 - }); - } catch (error) { - throw error; - } - } - - /** - * Double-click at the given coordinates. - */ - async doubleClick( - x: number, - y: number, - options?: ClickOptions - ): Promise { - try { - await this.post('/api/desktop/mouse/click', { - x, - y, - button: options?.button ?? 'left', - clickCount: 2 - }); - } catch (error) { - throw error; - } - } - - /** - * Triple-click at the given coordinates. - */ - async tripleClick( - x: number, - y: number, - options?: ClickOptions - ): Promise { - try { - await this.post('/api/desktop/mouse/click', { - x, - y, - button: options?.button ?? 'left', - clickCount: 3 - }); - } catch (error) { - throw error; - } - } - - /** - * Right-click at the given coordinates. - */ - async rightClick(x: number, y: number): Promise { - try { - await this.post('/api/desktop/mouse/click', { - x, - y, - button: 'right', - clickCount: 1 - }); - } catch (error) { - throw error; - } - } - - /** - * Middle-click at the given coordinates. - */ - async middleClick(x: number, y: number): Promise { - try { - await this.post('/api/desktop/mouse/click', { - x, - y, - button: 'middle', - clickCount: 1 - }); - } catch (error) { - throw error; - } - } - - /** - * Press and hold a mouse button. - */ - async mouseDown( - x?: number, - y?: number, - options?: ClickOptions - ): Promise { - try { - await this.post('/api/desktop/mouse/down', { - ...(x !== undefined && { x }), - ...(y !== undefined && { y }), - button: options?.button ?? 'left' - }); - } catch (error) { - throw error; - } - } - - /** - * Release a held mouse button. - */ - async mouseUp(x?: number, y?: number, options?: ClickOptions): Promise { - try { - await this.post('/api/desktop/mouse/up', { - ...(x !== undefined && { x }), - ...(y !== undefined && { y }), - button: options?.button ?? 'left' - }); - } catch (error) { - throw error; - } - } - - /** - * Move the mouse cursor to coordinates. - */ - async moveMouse(x: number, y: number): Promise { - try { - await this.post('/api/desktop/mouse/move', { x, y }); - } catch (error) { - throw error; - } - } - - /** - * Drag from start coordinates to end coordinates. - */ - async drag( - startX: number, - startY: number, - endX: number, - endY: number, - options?: ClickOptions - ): Promise { - try { - await this.post('/api/desktop/mouse/drag', { - startX, - startY, - endX, - endY, - button: options?.button ?? 'left' - }); - } catch (error) { - throw error; - } - } - - /** - * Scroll at coordinates in the specified direction. - */ - async scroll( - x: number, - y: number, - direction: ScrollDirection, - amount = 3 - ): Promise { - try { - await this.post('/api/desktop/mouse/scroll', { - x, - y, - direction, - amount - }); - } catch (error) { - throw error; - } - } - - /** - * Get the current cursor coordinates. - */ - async getCursorPosition(): Promise { - try { - const response = await this.get( - '/api/desktop/mouse/position' - ); - return response; - } catch (error) { - throw error; - } - } - - /** - * Type text into the focused element. - */ - async type(text: string, options?: TypeOptions): Promise { - try { - await this.post('/api/desktop/keyboard/type', { - text, - ...(options?.delayMs !== undefined && { delayMs: options.delayMs }) - }); - } catch (error) { - throw error; - } - } - - /** - * Press and release a key or key combination. - */ - async press(key: KeyInput): Promise { - try { - await this.post('/api/desktop/keyboard/press', { key }); - } catch (error) { - throw error; - } - } - - /** - * Press and hold a key. - */ - async keyDown(key: KeyInput): Promise { - try { - await this.post('/api/desktop/keyboard/down', { key }); - } catch (error) { - throw error; - } - } - - /** - * Release a held key. - */ - async keyUp(key: KeyInput): Promise { - try { - await this.post('/api/desktop/keyboard/up', { key }); - } catch (error) { - throw error; - } - } - - /** - * Get the active desktop screen size. - */ - async getScreenSize(): Promise { - try { - const response = await this.get( - '/api/desktop/screen/size' - ); - return response; - } catch (error) { - throw error; - } - } - - /** - * Get health status for a specific desktop process. - */ - async getProcessStatus( - name: string - ): Promise< - BaseApiResponse & { running: boolean; pid?: number; uptime?: number } - > { - try { - const response = await this.get< - BaseApiResponse & { running: boolean; pid?: number; uptime?: number } - >(`/api/desktop/process/${encodeURIComponent(name)}/status`); - - return response; - } catch (error) { - throw error; - } - } -} diff --git a/packages/sandbox/src/clients/file-client.ts b/packages/sandbox/src/clients/file-client.ts deleted file mode 100644 index 85f6d4821..000000000 --- a/packages/sandbox/src/clients/file-client.ts +++ /dev/null @@ -1,266 +0,0 @@ -import type { - DeleteFileResult, - FileExistsResult, - ListFilesOptions, - ListFilesResult, - MkdirResult, - MoveFileResult, - ReadFileResult, - RenameFileResult, - WriteFileResult -} from '@repo/shared'; -import { BaseHttpClient } from './base-client'; -import type { HttpClientOptions, SessionRequest } from './types'; - -/** - * Request interface for creating directories - */ -export interface MkdirRequest extends SessionRequest { - path: string; - recursive?: boolean; -} - -/** - * Request interface for writing files - */ -export interface WriteFileRequest extends SessionRequest { - path: string; - content: string; - encoding?: string; -} - -/** - * Request interface for reading files - */ -export interface ReadFileRequest extends SessionRequest { - path: string; - encoding?: string; -} - -/** - * Request interface for file operations (delete, rename, move) - */ -export interface FileOperationRequest extends SessionRequest { - path: string; - newPath?: string; // For rename/move operations -} - -/** - * Client for file system operations - */ -export class FileClient extends BaseHttpClient { - /** - * Create a directory - * @param path - Directory path to create - * @param sessionId - The session ID for this operation - * @param options - Optional settings (recursive) - */ - async mkdir( - path: string, - sessionId: string, - options?: { recursive?: boolean } - ): Promise { - try { - const data = { - path, - sessionId, - recursive: options?.recursive ?? false - }; - - const response = await this.post('/api/mkdir', data); - - return response; - } catch (error) { - throw error; - } - } - - /** - * Write content to a file - * @param path - File path to write to - * @param content - Content to write - * @param sessionId - The session ID for this operation - * @param options - Optional settings (encoding) - */ - async writeFile( - path: string, - content: string, - sessionId: string, - options?: { encoding?: string } - ): Promise { - try { - const data = { - path, - content, - sessionId, - encoding: options?.encoding - }; - - const response = await this.post('/api/write', data); - - return response; - } catch (error) { - throw error; - } - } - - /** - * Read content from a file - * @param path - File path to read from - * @param sessionId - The session ID for this operation - * @param options - Optional settings (encoding) - */ - async readFile( - path: string, - sessionId: string, - options?: { encoding?: string } - ): Promise { - try { - const data = { - path, - sessionId, - encoding: options?.encoding - }; - - const response = await this.post('/api/read', data); - - return response; - } catch (error) { - throw error; - } - } - - /** - * Stream a file using Server-Sent Events - * Returns a ReadableStream of SSE events containing metadata, chunks, and completion - * @param path - File path to stream - * @param sessionId - The session ID for this operation - */ - async readFileStream( - path: string, - sessionId: string - ): Promise> { - try { - const data = { - path, - sessionId - }; - - // Use doStreamFetch which handles both WebSocket and HTTP streaming - const stream = await this.doStreamFetch('/api/read/stream', data); - return stream; - } catch (error) { - throw error; - } - } - - /** - * Delete a file - * @param path - File path to delete - * @param sessionId - The session ID for this operation - */ - async deleteFile(path: string, sessionId: string): Promise { - try { - const data = { path, sessionId }; - - const response = await this.post('/api/delete', data); - - return response; - } catch (error) { - throw error; - } - } - - /** - * Rename a file - * @param path - Current file path - * @param newPath - New file path - * @param sessionId - The session ID for this operation - */ - async renameFile( - path: string, - newPath: string, - sessionId: string - ): Promise { - try { - const data = { oldPath: path, newPath, sessionId }; - - const response = await this.post('/api/rename', data); - - return response; - } catch (error) { - throw error; - } - } - - /** - * Move a file - * @param path - Current file path - * @param newPath - Destination file path - * @param sessionId - The session ID for this operation - */ - async moveFile( - path: string, - newPath: string, - sessionId: string - ): Promise { - try { - const data = { sourcePath: path, destinationPath: newPath, sessionId }; - - const response = await this.post('/api/move', data); - - return response; - } catch (error) { - throw error; - } - } - - /** - * List files in a directory - * @param path - Directory path to list - * @param sessionId - The session ID for this operation - * @param options - Optional settings (recursive, includeHidden) - */ - async listFiles( - path: string, - sessionId: string, - options?: ListFilesOptions - ): Promise { - try { - const data = { - path, - sessionId, - options: options || {} - }; - - const response = await this.post( - '/api/list-files', - data - ); - - return response; - } catch (error) { - throw error; - } - } - - /** - * Check if a file or directory exists - * @param path - Path to check - * @param sessionId - The session ID for this operation - */ - async exists(path: string, sessionId: string): Promise { - try { - const data = { - path, - sessionId - }; - - const response = await this.post('/api/exists', data); - - return response; - } catch (error) { - throw error; - } - } -} diff --git a/packages/sandbox/src/clients/git-client.ts b/packages/sandbox/src/clients/git-client.ts deleted file mode 100644 index ade37aac4..000000000 --- a/packages/sandbox/src/clients/git-client.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { GitCheckoutResult } from '@repo/shared'; -import { extractRepoName, GitLogger } from '@repo/shared'; -import { BaseHttpClient } from './base-client'; -import type { HttpClientOptions, SessionRequest } from './types'; - -// Re-export for convenience -export type { GitCheckoutResult }; - -/** - * Request interface for Git checkout operations - */ -export interface GitCheckoutRequest extends SessionRequest { - repoUrl: string; - branch?: string; - targetDir?: string; - /** Clone depth for shallow clones (e.g., 1 for latest commit only) */ - depth?: number; -} - -/** - * Client for Git repository operations - */ -export class GitClient extends BaseHttpClient { - constructor(options: HttpClientOptions = {}) { - super(options); - // Wrap logger with GitLogger to auto-redact credentials - this.logger = new GitLogger(this.logger); - } - - /** - * Clone a Git repository - * @param repoUrl - URL of the Git repository to clone - * @param sessionId - The session ID for this operation - * @param options - Optional settings (branch, targetDir, depth) - */ - async checkout( - repoUrl: string, - sessionId: string, - options?: { - branch?: string; - targetDir?: string; - /** Clone depth for shallow clones (e.g., 1 for latest commit only) */ - depth?: number; - } - ): Promise { - try { - // Determine target directory - use provided path or generate from repo name - let targetDir = options?.targetDir; - if (!targetDir) { - targetDir = `/workspace/${extractRepoName(repoUrl)}`; - } - - const data: GitCheckoutRequest = { - repoUrl, - sessionId, - targetDir - }; - - // Only include branch if explicitly specified - // This allows Git to use the repository's default branch - if (options?.branch) { - data.branch = options.branch; - } - - if (options?.depth !== undefined) { - if (!Number.isInteger(options.depth) || options.depth <= 0) { - throw new Error( - `Invalid depth value: ${options.depth}. Must be a positive integer (e.g., 1, 5, 10).` - ); - } - data.depth = options.depth; - } - - const response = await this.post( - '/api/git/checkout', - data - ); - - return response; - } catch (error) { - throw error; - } - } -} diff --git a/packages/sandbox/src/clients/index.ts b/packages/sandbox/src/clients/index.ts index 727778a1f..5fac391a7 100644 --- a/packages/sandbox/src/clients/index.ts +++ b/packages/sandbox/src/clients/index.ts @@ -1,113 +1,86 @@ -// ============================================================================= -// Main client exports -// ============================================================================= +// RPC-backed client (sole client implementation) +export { RPCSandboxClient } from './rpc-sandbox-client'; -// Main aggregated client -export { SandboxClient } from './sandbox-client'; +// Types needed by sandbox.ts (formerly in deleted client files) -// ============================================================================= -// Domain-specific clients -// ============================================================================= - -export { BackupClient } from './backup-client'; -export { CommandClient } from './command-client'; -export { DesktopClient } from './desktop-client'; -export { FileClient } from './file-client'; -export { GitClient } from './git-client'; -export { InterpreterClient } from './interpreter-client'; -export { PortClient } from './port-client'; -export { ProcessClient } from './process-client'; -export { UtilityClient } from './utility-client'; -export { WatchClient } from './watch-client'; - -// ============================================================================= -// Transport layer -// ============================================================================= +export interface ExecuteResponse { + success: boolean; + timestamp: string; + stdout: string; + stderr: string; + exitCode: number; + command: string; +} +// Desktop types re-exported from @repo/shared export type { - ITransport, - TransportConfig, - TransportMode, - TransportOptions -} from './transport'; -export { - BaseTransport, - createTransport, - HttpTransport, - WebSocketTransport -} from './transport'; - -// ============================================================================= -// Client types and interfaces -// ============================================================================= + DesktopCursorPosition as CursorPositionResponse, + DesktopMouseButton, + DesktopScreenSize as ScreenSizeResponse, + DesktopScreenshotRegionRequest as ScreenshotRegion, + DesktopScreenshotRequest as ScreenshotOptions, + DesktopScreenshotResult as ScreenshotResponse, + DesktopScrollDirection, + DesktopStartRequest as DesktopStartOptions, + DesktopStartResult as DesktopStartResponse, + DesktopStatusResult as DesktopStatusResponse, + DesktopStopResult as DesktopStopResponse +} from '@repo/shared'; -export type { WatchRequest } from '@repo/shared'; -// Command client types -export type { ExecuteRequest, ExecuteResponse } from './command-client'; -// Desktop client types -export type { - ClickOptions, - CursorPositionResponse, - Desktop, - DesktopStartOptions, - DesktopStartResponse, - DesktopStatusResponse, - DesktopStopResponse, - KeyInput, - ScreenSizeResponse, - ScreenshotBytesResponse, - ScreenshotOptions, - ScreenshotRegion, - ScreenshotResponse, - ScrollDirection, - TypeOptions -} from './desktop-client'; -// File client types -export type { - FileOperationRequest, - MkdirRequest, - ReadFileRequest, - WriteFileRequest -} from './file-client'; -// Git client types -export type { GitCheckoutRequest, GitCheckoutResult } from './git-client'; -// Interpreter client types -export type { ExecutionCallbacks } from './interpreter-client'; -// Port client types -export type { - ExposePortRequest, - PortCloseResult, - PortExposeResult, - PortListResult, - UnexposePortRequest -} from './port-client'; -// Process client types -export type { - ProcessCleanupResult, - ProcessInfoResult, - ProcessKillResult, - ProcessListResult, - ProcessLogsResult, - ProcessStartResult, - StartProcessRequest -} from './process-client'; -// Core types -export type { - BaseApiResponse, - ContainerStub, - ErrorResponse, - HttpClientOptions, - RequestConfig, - ResponseHandler, - SessionRequest -} from './types'; -// Utility client types -export type { - CommandsResponse, - CreateSessionRequest, - CreateSessionResponse, - DeleteSessionRequest, - DeleteSessionResponse, - PingResponse, - VersionResponse -} from './utility-client'; +// Desktop interface (public API) +export interface Desktop { + start(options?: Record): Promise>; + stop(): Promise>; + status(): Promise>; + screenshot( + options?: Record + ): Promise>; + screenshotRegion( + region: Record, + options?: Record + ): Promise>; + click(x: number, y: number, options?: Record): Promise; + doubleClick( + x: number, + y: number, + options?: Record + ): Promise; + tripleClick( + x: number, + y: number, + options?: Record + ): Promise; + rightClick(x: number, y: number): Promise; + middleClick(x: number, y: number): Promise; + mouseDown( + x?: number, + y?: number, + options?: Record + ): Promise; + mouseUp( + x?: number, + y?: number, + options?: Record + ): Promise; + moveMouse(x: number, y: number): Promise; + drag( + startX: number, + startY: number, + endX: number, + endY: number, + options?: Record + ): Promise; + scroll( + x: number, + y: number, + direction: string, + amount?: number + ): Promise; + getCursorPosition(): Promise<{ x: number; y: number }>; + type(text: string, options?: Record): Promise; + press(key: string): Promise; + keyDown(key: string): Promise; + keyUp(key: string): Promise; + getScreenSize(): Promise<{ width: number; height: number }>; + getProcessStatus(name: string): Promise>; +} diff --git a/packages/sandbox/src/clients/interpreter-client.ts b/packages/sandbox/src/clients/interpreter-client.ts deleted file mode 100644 index d26b5e5ee..000000000 --- a/packages/sandbox/src/clients/interpreter-client.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { - type CodeContext, - type ContextCreateResult, - type ContextListResult, - type CreateContextOptions, - type ExecutionError, - type OutputMessage, - type Result, - ResultImpl -} from '@repo/shared'; -import type { ErrorResponse } from '../errors'; -import { - createErrorFromResponse, - ErrorCode, - InterpreterNotReadyError -} from '../errors'; -import { BaseHttpClient } from './base-client.js'; -import type { HttpClientOptions } from './types.js'; - -// Streaming execution data from the server -interface StreamingExecutionData { - type: 'result' | 'stdout' | 'stderr' | 'error' | 'execution_complete'; - text?: string; - html?: string; - png?: string; // base64 - jpeg?: string; // base64 - svg?: string; - latex?: string; - markdown?: string; - javascript?: string; - json?: unknown; - chart?: { - type: - | 'line' - | 'bar' - | 'scatter' - | 'pie' - | 'histogram' - | 'heatmap' - | 'unknown'; - data: unknown; - options?: unknown; - }; - data?: unknown; - metadata?: Record; - execution_count?: number; - ename?: string; - evalue?: string; - traceback?: string[]; - lineNumber?: number; - timestamp?: number; -} - -export interface ExecutionCallbacks { - onStdout?: (output: OutputMessage) => void | Promise; - onStderr?: (output: OutputMessage) => void | Promise; - onResult?: (result: Result) => void | Promise; - onError?: (error: ExecutionError) => void | Promise; -} - -export class InterpreterClient extends BaseHttpClient { - private readonly maxRetries = 3; - private readonly retryDelayMs = 1000; - - async createCodeContext( - options: CreateContextOptions = {} - ): Promise { - return this.executeWithRetry(async () => { - const response = await this.doFetch('/api/contexts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - language: options.language || 'python', - cwd: options.cwd || '/workspace', - env_vars: options.envVars - }) - }); - - if (!response.ok) { - const error = await this.parseErrorResponse(response); - throw error; - } - - const data = (await response.json()) as ContextCreateResult; - if (!data.success) { - throw new Error(`Failed to create context: ${JSON.stringify(data)}`); - } - - return { - id: data.contextId, - language: data.language, - cwd: data.cwd || '/workspace', - createdAt: new Date(data.timestamp), - lastUsed: new Date(data.timestamp) - }; - }); - } - - async runCodeStream( - contextId: string | undefined, - code: string, - language: string | undefined, - callbacks: ExecutionCallbacks, - timeoutMs?: number - ): Promise { - return this.executeWithRetry(async () => { - // Use doStreamFetch which handles both WebSocket and HTTP streaming - const stream = await this.doStreamFetch('/api/execute/code', { - context_id: contextId, - code, - language, - ...(timeoutMs !== undefined && { timeout_ms: timeoutMs }) - }); - - // Process streaming response - for await (const chunk of this.readLines(stream)) { - await this.parseExecutionResult(chunk, callbacks); - } - }); - } - - async listCodeContexts(): Promise { - return this.executeWithRetry(async () => { - const response = await this.doFetch('/api/contexts', { - method: 'GET', - headers: { 'Content-Type': 'application/json' } - }); - - if (!response.ok) { - const error = await this.parseErrorResponse(response); - throw error; - } - - const data = (await response.json()) as ContextListResult; - if (!data.success) { - throw new Error(`Failed to list contexts: ${JSON.stringify(data)}`); - } - - return data.contexts.map((ctx) => ({ - id: ctx.id, - language: ctx.language, - cwd: ctx.cwd || '/workspace', - createdAt: new Date(data.timestamp), - lastUsed: new Date(data.timestamp) - })); - }); - } - - async deleteCodeContext(contextId: string): Promise { - return this.executeWithRetry(async () => { - const response = await this.doFetch(`/api/contexts/${contextId}`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' } - }); - - if (!response.ok) { - const error = await this.parseErrorResponse(response); - throw error; - } - }); - } - - /** - * Get a raw stream for code execution. - * Used by CodeInterpreter.runCodeStreaming() for direct stream access. - */ - async streamCode( - contextId: string, - code: string, - language?: string - ): Promise> { - return this.doStreamFetch('/api/execute/code', { - context_id: contextId, - code, - language - }); - } - - /** - * Execute an operation with automatic retry for transient errors - */ - private async executeWithRetry(operation: () => Promise): Promise { - let lastError: Error | undefined; - - for (let attempt = 0; attempt < this.maxRetries; attempt++) { - try { - return await operation(); - } catch (error) { - lastError = error as Error; - - // Check if it's a retryable error (interpreter not ready) - if (this.isRetryableError(error)) { - // Don't retry on the last attempt - if (attempt < this.maxRetries - 1) { - // Exponential backoff with jitter - const delay = - this.retryDelayMs * 2 ** attempt + Math.random() * 1000; - await new Promise((resolve) => setTimeout(resolve, delay)); - continue; - } - } - - // Not retryable or last attempt - throw the error - throw error; - } - } - - throw lastError || new Error('Execution failed after retries'); - } - - private isRetryableError(error: unknown): boolean { - if (error instanceof InterpreterNotReadyError) { - return true; - } - - if (error instanceof Error) { - return ( - error.message.includes('not ready') || - error.message.includes('initializing') - ); - } - - return false; - } - - private async parseErrorResponse(response: Response): Promise { - try { - const errorData = (await response.json()) as ErrorResponse; - return createErrorFromResponse(errorData); - } catch { - // Fallback if response isn't JSON - const errorResponse: ErrorResponse = { - code: ErrorCode.INTERNAL_ERROR, - message: `HTTP ${response.status}: ${response.statusText}`, - context: {}, - httpStatus: response.status, - timestamp: new Date().toISOString() - }; - return createErrorFromResponse(errorResponse); - } - } - - private async *readLines( - stream: ReadableStream - ): AsyncGenerator { - const reader = stream.getReader(); - let buffer = ''; - - try { - while (true) { - const { done, value } = await reader.read(); - if (value) { - buffer += new TextDecoder().decode(value); - } - if (done) break; - - let newlineIdx = buffer.indexOf('\n'); - while (newlineIdx !== -1) { - yield buffer.slice(0, newlineIdx); - buffer = buffer.slice(newlineIdx + 1); - newlineIdx = buffer.indexOf('\n'); - } - } - - // Yield any remaining data - if (buffer.length > 0) { - yield buffer; - } - } finally { - // Cancel the stream first to properly terminate HTTP connections when breaking early - try { - await reader.cancel(); - } catch { - // Ignore cancel errors (stream may already be closed) - } - reader.releaseLock(); - } - } - - private async parseExecutionResult( - line: string, - callbacks: ExecutionCallbacks - ) { - if (!line.trim()) return; - - // Skip lines that don't start with "data: " (SSE format) - if (!line.startsWith('data: ')) return; - - try { - // Strip "data: " prefix and parse JSON - const jsonData = line.substring(6); // "data: " is 6 characters - const data = JSON.parse(jsonData) as StreamingExecutionData; - - switch (data.type) { - case 'stdout': - if (callbacks.onStdout && data.text) { - await callbacks.onStdout({ - text: data.text, - timestamp: data.timestamp || Date.now() - }); - } - break; - - case 'stderr': - if (callbacks.onStderr && data.text) { - await callbacks.onStderr({ - text: data.text, - timestamp: data.timestamp || Date.now() - }); - } - break; - - case 'result': - if (callbacks.onResult) { - // Create a ResultImpl instance from the raw data - const result = new ResultImpl(data); - await callbacks.onResult(result); - } - break; - - case 'error': - if (callbacks.onError) { - await callbacks.onError({ - name: data.ename || 'Error', - message: data.evalue || 'Unknown error', - traceback: data.traceback || [] - }); - } - break; - - case 'execution_complete': - // Signal completion - callbacks can handle cleanup if needed - break; - } - } catch { - // Silently ignore unparseable SSE lines - } - } -} diff --git a/packages/sandbox/src/clients/port-client.ts b/packages/sandbox/src/clients/port-client.ts deleted file mode 100644 index a4735ba8f..000000000 --- a/packages/sandbox/src/clients/port-client.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { - ExposePortRequest, - PortCloseResult, - PortExposeResult, - PortListResult, - PortWatchRequest -} from '@repo/shared'; -import { BaseHttpClient } from './base-client'; - -// Re-export for convenience -export type { - ExposePortRequest, - PortExposeResult, - PortCloseResult, - PortListResult -}; - -/** - * Request interface for unexposing ports - */ -export interface UnexposePortRequest { - port: number; -} - -/** - * Client for port management and preview URL operations - */ -export class PortClient extends BaseHttpClient { - /** - * Expose a port and get a preview URL - * @param port - Port number to expose - * @param sessionId - The session ID for this operation - * @param name - Optional name for the port - */ - async exposePort( - port: number, - sessionId: string, - name?: string - ): Promise { - try { - const data = { port, sessionId, name }; - - const response = await this.post( - '/api/expose-port', - data - ); - - return response; - } catch (error) { - throw error; - } - } - - /** - * Unexpose a port and remove its preview URL - * @param port - Port number to unexpose - * @param sessionId - The session ID for this operation - */ - async unexposePort( - port: number, - sessionId: string - ): Promise { - try { - const url = `/api/exposed-ports/${port}?session=${encodeURIComponent( - sessionId - )}`; - const response = await this.delete(url); - - return response; - } catch (error) { - throw error; - } - } - - /** - * Get all currently exposed ports - * @param sessionId - The session ID for this operation - */ - async getExposedPorts(sessionId: string): Promise { - try { - const url = `/api/exposed-ports?session=${encodeURIComponent(sessionId)}`; - const response = await this.get(url); - - return response; - } catch (error) { - throw error; - } - } - - /** - * Watch a port for readiness via SSE stream - * @param request - Port watch configuration - * @returns SSE stream that emits PortWatchEvent objects - */ - async watchPort( - request: PortWatchRequest - ): Promise> { - try { - const stream = await this.doStreamFetch('/api/port-watch', request); - return stream; - } catch (error) { - throw error; - } - } -} diff --git a/packages/sandbox/src/clients/process-client.ts b/packages/sandbox/src/clients/process-client.ts deleted file mode 100644 index 26779f11c..000000000 --- a/packages/sandbox/src/clients/process-client.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { - ProcessCleanupResult, - ProcessInfoResult, - ProcessKillResult, - ProcessListResult, - ProcessLogsResult, - ProcessStartResult, - StartProcessRequest -} from '@repo/shared'; -import { BaseHttpClient } from './base-client'; -import type { HttpClientOptions } from './types'; - -// Re-export for convenience -export type { - StartProcessRequest, - ProcessStartResult, - ProcessListResult, - ProcessInfoResult, - ProcessKillResult, - ProcessLogsResult, - ProcessCleanupResult -}; - -/** - * Client for background process management - */ -export class ProcessClient extends BaseHttpClient { - /** - * Start a background process - * @param command - Command to execute as a background process - * @param sessionId - The session ID for this operation - * @param options - Optional settings (processId) - */ - async startProcess( - command: string, - sessionId: string, - options?: { - processId?: string; - timeoutMs?: number; - env?: Record; - cwd?: string; - encoding?: string; - autoCleanup?: boolean; - origin?: 'user' | 'internal'; - } - ): Promise { - try { - const data: StartProcessRequest = { - command, - sessionId, - ...(options?.origin !== undefined && { origin: options.origin }), - ...(options?.processId !== undefined && { - processId: options.processId - }), - ...(options?.timeoutMs !== undefined && { - timeoutMs: options.timeoutMs - }), - ...(options?.env !== undefined && { env: options.env }), - ...(options?.cwd !== undefined && { cwd: options.cwd }), - ...(options?.encoding !== undefined && { encoding: options.encoding }), - ...(options?.autoCleanup !== undefined && { - autoCleanup: options.autoCleanup - }) - }; - - const response = await this.post( - '/api/process/start', - data - ); - - return response; - } catch (error) { - throw error; - } - } - - /** - * List all processes (sandbox-scoped, not session-scoped) - */ - async listProcesses(): Promise { - try { - const url = `/api/process/list`; - const response = await this.get(url); - - return response; - } catch (error) { - throw error; - } - } - - /** - * Get information about a specific process (sandbox-scoped, not session-scoped) - * @param processId - ID of the process to retrieve - */ - async getProcess(processId: string): Promise { - try { - const url = `/api/process/${processId}`; - const response = await this.get(url); - - return response; - } catch (error) { - throw error; - } - } - - /** - * Kill a specific process (sandbox-scoped, not session-scoped) - * @param processId - ID of the process to kill - */ - async killProcess(processId: string): Promise { - try { - const url = `/api/process/${processId}`; - const response = await this.delete(url); - - return response; - } catch (error) { - throw error; - } - } - - /** - * Kill all running processes (sandbox-scoped, not session-scoped) - */ - async killAllProcesses(): Promise { - try { - const url = `/api/process/kill-all`; - const response = await this.delete(url); - - return response; - } catch (error) { - throw error; - } - } - - /** - * Get logs from a specific process (sandbox-scoped, not session-scoped) - * @param processId - ID of the process to get logs from - */ - async getProcessLogs(processId: string): Promise { - try { - const url = `/api/process/${processId}/logs`; - const response = await this.get(url); - - return response; - } catch (error) { - throw error; - } - } - - /** - * Stream logs from a specific process (sandbox-scoped, not session-scoped) - * @param processId - ID of the process to stream logs from - */ - async streamProcessLogs( - processId: string - ): Promise> { - try { - const url = `/api/process/${processId}/stream`; - // Use doStreamFetch with GET method (process log streaming is GET) - const stream = await this.doStreamFetch(url, undefined, 'GET'); - - return stream; - } catch (error) { - throw error; - } - } -} diff --git a/packages/sandbox/src/clients/rpc-sandbox-client.ts b/packages/sandbox/src/clients/rpc-sandbox-client.ts index 9db8f611f..10cc1430d 100644 --- a/packages/sandbox/src/clients/rpc-sandbox-client.ts +++ b/packages/sandbox/src/clients/rpc-sandbox-client.ts @@ -44,13 +44,34 @@ import type { WriteFileResult } from '@repo/shared'; import type { ContainerConnection } from '../container-connection'; -import type { ExecuteResponse } from './command-client'; -import type { ExecutionCallbacks } from './interpreter-client'; -import type { TransportMode } from './transport'; -import type { - CreateSessionResponse, - DeleteSessionResponse -} from './utility-client'; +import type { ExecuteResponse } from './index'; + +// Inline the types that were previously imported from deleted files +type TransportMode = 'capnweb'; + +interface ExecutionCallbacks { + onStdout?: (output: { text: string; timestamp: number }) => void; + onStderr?: (output: { text: string; timestamp: number }) => void; + onResult?: (result: Record) => void | Promise; + onError?: (error: { + name: string; + message: string; + traceback?: string[]; + }) => void; +} + +interface CreateSessionResponse { + success: boolean; + id: string; + message?: string; + timestamp: string; +} + +interface DeleteSessionResponse { + success: boolean; + sessionId: string; + timestamp: string; +} // --------------------------------------------------------------------------- // Sub-client implementations @@ -66,6 +87,7 @@ class RPCCommandClient { timeoutMs?: number; env?: Record; cwd?: string; + origin?: string; } ): Promise { const rpc = await this.conn.rpc(); @@ -87,6 +109,7 @@ class RPCCommandClient { timeoutMs?: number; env?: Record; cwd?: string; + origin?: string; } ): Promise> { const rpc = await this.conn.rpc(); @@ -541,7 +564,9 @@ class RPCInterpreterClient { // Import ResultImpl lazily to avoid circular deps if (cb.onResult) { const { ResultImpl } = await import('@repo/shared'); - await cb.onResult(new ResultImpl(data)); + await cb.onResult( + new ResultImpl(data) as unknown as Record + ); } break; case 'error': diff --git a/packages/sandbox/src/clients/sandbox-client.ts b/packages/sandbox/src/clients/sandbox-client.ts deleted file mode 100644 index 785c01ec9..000000000 --- a/packages/sandbox/src/clients/sandbox-client.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { BackupClient } from './backup-client'; -import { CommandClient } from './command-client'; -import { DesktopClient } from './desktop-client'; -import { FileClient } from './file-client'; -import { GitClient } from './git-client'; -import { InterpreterClient } from './interpreter-client'; -import { PortClient } from './port-client'; -import { ProcessClient } from './process-client'; -import { - createTransport, - type ITransport, - type TransportMode -} from './transport'; -import type { HttpClientOptions } from './types'; -import { UtilityClient } from './utility-client'; -import { WatchClient } from './watch-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, - * reducing sub-request count inside Workers/Durable Objects - */ -export class SandboxClient { - public readonly backup: BackupClient; - public readonly commands: CommandClient; - public readonly files: FileClient; - public readonly processes: ProcessClient; - public readonly ports: PortClient; - public readonly git: GitClient; - public readonly interpreter: InterpreterClient; - public readonly utils: UtilityClient; - public readonly desktop: DesktopClient; - public readonly watch: WatchClient; - - private transport: ITransport | null = null; - - constructor(options: HttpClientOptions) { - // Create shared transport if WebSocket mode is enabled - if (options.transportMode === 'websocket' && options.wsUrl) { - this.transport = createTransport({ - mode: options.transportMode, - wsUrl: options.wsUrl, - baseUrl: options.baseUrl, - logger: options.logger, - stub: options.stub, - port: options.port, - retryTimeoutMs: options.retryTimeoutMs - }); - } - - // Ensure baseUrl is provided for all clients - const clientOptions: HttpClientOptions = { - baseUrl: 'http://localhost:3000', - ...options, - // Share transport across all clients - transport: this.transport ?? options.transport - }; - - // Initialize all domain clients with shared options - this.backup = new BackupClient(clientOptions); - this.commands = new CommandClient(clientOptions); - this.files = new FileClient(clientOptions); - this.processes = new ProcessClient(clientOptions); - this.ports = new PortClient(clientOptions); - this.git = new GitClient(clientOptions); - this.interpreter = new InterpreterClient(clientOptions); - this.utils = new UtilityClient(clientOptions); - this.desktop = new DesktopClient(clientOptions); - this.watch = new WatchClient(clientOptions); - } - - /** - * Update the 503 retry budget on all transports without recreating the client. - * - * In WebSocket mode a single shared transport is used, so one update covers - * every sub-client. In HTTP mode each sub-client owns its own transport, so - * all of them are updated individually. - */ - setRetryTimeoutMs(ms: number): void { - if (this.transport) { - // WebSocket mode — single shared transport - this.transport.setRetryTimeoutMs(ms); - } else { - // HTTP mode — each sub-client has its own transport - this.backup.setRetryTimeoutMs(ms); - this.commands.setRetryTimeoutMs(ms); - this.files.setRetryTimeoutMs(ms); - this.processes.setRetryTimeoutMs(ms); - this.ports.setRetryTimeoutMs(ms); - this.git.setRetryTimeoutMs(ms); - this.interpreter.setRetryTimeoutMs(ms); - this.utils.setRetryTimeoutMs(ms); - this.desktop.setRetryTimeoutMs(ms); - this.watch.setRetryTimeoutMs(ms); - } - } - - /** - * 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?.isConnected() ?? false; - } - - /** - * Stream a file directly to the container over a binary RPC channel. - * - * Requires the capnweb transport (`useWebSocket: 'capnweb'`). Calling this - * method with the HTTP or WebSocket transports throws an error because those - * transports do not support binary streaming. - */ - writeFileStream( - _path: string, - _content: ReadableStream, - _sessionId: string - ): Promise<{ - success: boolean; - path: string; - bytesWritten: number; - timestamp: string; - }> { - throw new Error( - 'writeFileStream requires the capnweb transport. Enable it with useWebSocket: "capnweb" in sandbox options.' - ); - } - - /** - * 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/base-transport.ts b/packages/sandbox/src/clients/transport/base-transport.ts deleted file mode 100644 index 2e59b4586..000000000 --- a/packages/sandbox/src/clients/transport/base-transport.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { Logger } from '@repo/shared'; -import { createNoOpLogger } from '@repo/shared'; -import type { ITransport, TransportConfig, TransportMode } from './types'; - -/** - * Container startup retry configuration - */ -const DEFAULT_RETRY_TIMEOUT_MS = 120_000; // 2 minutes total retry budget -const MIN_TIME_FOR_RETRY_MS = 15_000; // Need at least 15s remaining to retry - -/** - * Abstract base transport with shared retry logic - * - * Handles 503 retry for container startup - shared by all transports. - * Subclasses implement the transport-specific fetch and stream logic. - */ -export abstract class BaseTransport implements ITransport { - protected config: TransportConfig; - protected logger: Logger; - private retryTimeoutMs: number; - - constructor(config: TransportConfig) { - this.config = config; - this.logger = config.logger ?? createNoOpLogger(); - this.retryTimeoutMs = config.retryTimeoutMs ?? DEFAULT_RETRY_TIMEOUT_MS; - } - - abstract getMode(): TransportMode; - abstract connect(): Promise; - abstract disconnect(): void; - abstract isConnected(): boolean; - - setRetryTimeoutMs(ms: number): void { - this.retryTimeoutMs = ms; - } - - protected getRetryTimeoutMs(): number { - return this.retryTimeoutMs; - } - - /** - * Fetch with automatic retry for 503 (container starting) - * - * This is the primary entry point for making requests. It wraps the - * transport-specific doFetch() with retry logic for container startup. - */ - async fetch(path: string, options?: RequestInit): Promise { - const startTime = Date.now(); - let attempt = 0; - - while (true) { - const response = await this.doFetch(path, options); - - // Check for retryable 503 (container starting) - if (response.status === 503) { - const elapsed = Date.now() - startTime; - const remaining = this.retryTimeoutMs - elapsed; - - if (remaining > MIN_TIME_FOR_RETRY_MS) { - const delay = Math.min(3000 * 2 ** attempt, 30000); - - this.logger.info('Container not ready, retrying', { - status: response.status, - attempt: attempt + 1, - delayMs: delay, - remainingSec: Math.floor(remaining / 1000), - mode: this.getMode() - }); - - await this.sleep(delay); - attempt++; - continue; - } - - this.logger.error( - 'Container failed to become ready', - new Error( - `Failed after ${attempt + 1} attempts over ${Math.floor(elapsed / 1000)}s` - ) - ); - } - - return response; - } - } - - /** - * Transport-specific fetch implementation (no retry) - * Subclasses implement the actual HTTP or WebSocket fetch. - */ - protected abstract doFetch( - path: string, - options?: RequestInit - ): Promise; - - /** - * Transport-specific stream implementation - * Subclasses implement HTTP SSE or WebSocket streaming. - */ - abstract fetchStream( - path: string, - body?: unknown, - method?: 'GET' | 'POST', - headers?: Record - ): Promise>; - - /** - * Sleep utility for retry delays - */ - protected sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); - } -} diff --git a/packages/sandbox/src/clients/transport/factory.ts b/packages/sandbox/src/clients/transport/factory.ts deleted file mode 100644 index 019cde9b3..000000000 --- a/packages/sandbox/src/clients/transport/factory.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { HttpTransport } from './http-transport'; -import type { ITransport, TransportConfig, TransportMode } from './types'; -import { WebSocketTransport } from './ws-transport'; - -/** - * Transport options with mode selection - */ -export interface TransportOptions extends TransportConfig { - /** Transport mode */ - mode: TransportMode; -} - -/** - * Create a transport instance based on mode - * - * This is the primary API for creating transports. It handles - * the selection of HTTP or WebSocket transport based on the mode. - * - * @example - * ```typescript - * // HTTP transport (default) - * const http = createTransport({ - * mode: 'http', - * baseUrl: 'http://localhost:3000' - * }); - * - * // WebSocket transport - * const ws = createTransport({ - * mode: 'websocket', - * wsUrl: 'ws://localhost:3000/ws' - * }); - * ``` - */ -export function createTransport(options: TransportOptions): ITransport { - switch (options.mode) { - case 'websocket': - return new WebSocketTransport(options); - - default: - return new HttpTransport(options); - } -} diff --git a/packages/sandbox/src/clients/transport/http-transport.ts b/packages/sandbox/src/clients/transport/http-transport.ts deleted file mode 100644 index ffed4b9c8..000000000 --- a/packages/sandbox/src/clients/transport/http-transport.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { BaseTransport } from './base-transport'; -import type { TransportConfig, TransportMode } from './types'; - -/** - * HTTP transport implementation - * - * Uses standard fetch API for communication with the container. - * HTTP is stateless, so connect/disconnect are no-ops. - */ -export class HttpTransport extends BaseTransport { - private baseUrl: string; - - constructor(config: TransportConfig) { - super(config); - this.baseUrl = config.baseUrl ?? 'http://localhost:3000'; - } - - getMode(): TransportMode { - return 'http'; - } - - async connect(): Promise { - // No-op for HTTP - stateless protocol - } - - disconnect(): void { - // No-op for HTTP - stateless protocol - } - - isConnected(): boolean { - return true; // HTTP is always "connected" - } - - protected async doFetch( - path: string, - options?: RequestInit - ): Promise { - const url = this.buildUrl(path); - - if (this.config.stub) { - return this.config.stub.containerFetch( - url, - options || {}, - this.config.port - ); - } - return globalThis.fetch(url, options); - } - - async fetchStream( - path: string, - body?: unknown, - method: 'GET' | 'POST' = 'POST', - headers?: Record - ): Promise> { - const url = this.buildUrl(path); - const options = this.buildStreamOptions(body, method, headers); - - let response: Response; - if (this.config.stub) { - response = await this.config.stub.containerFetch( - url, - options, - this.config.port - ); - } else { - response = await globalThis.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; - } - - private buildUrl(path: string): string { - if (this.config.stub) { - return `http://localhost:${this.config.port}${path}`; - } - return `${this.baseUrl}${path}`; - } - - private buildStreamOptions( - body: unknown, - method: 'GET' | 'POST', - headers?: Record - ): RequestInit { - return { - method, - headers: - body && method === 'POST' - ? { ...headers, 'Content-Type': 'application/json' } - : headers, - body: body && method === 'POST' ? JSON.stringify(body) : undefined - }; - } -} diff --git a/packages/sandbox/src/clients/transport/index.ts b/packages/sandbox/src/clients/transport/index.ts deleted file mode 100644 index fa6ebbc24..000000000 --- a/packages/sandbox/src/clients/transport/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -// ============================================================================= -// Types -// ============================================================================= - -export type { TransportOptions } from './factory'; -export type { ITransport, TransportConfig, TransportMode } from './types'; - -// ============================================================================= -// Implementations (for advanced use cases) -// ============================================================================= - -export { BaseTransport } from './base-transport'; -export { HttpTransport } from './http-transport'; -export { WebSocketTransport } from './ws-transport'; - -// ============================================================================= -// Factory (primary API) -// ============================================================================= - -export { createTransport } from './factory'; diff --git a/packages/sandbox/src/clients/transport/types.ts b/packages/sandbox/src/clients/transport/types.ts deleted file mode 100644 index f00e9b8df..000000000 --- a/packages/sandbox/src/clients/transport/types.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { Logger } from '@repo/shared'; -import type { ContainerStub } from '../types'; - -/** - * Transport mode for SDK communication - */ -export type TransportMode = 'http' | 'websocket' | 'capnweb'; - -/** - * Configuration options for creating a transport - */ -export interface TransportConfig { - /** Base URL for HTTP requests */ - baseUrl?: string; - - /** WebSocket URL (required 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 (non-streaming requests) */ - requestTimeoutMs?: number; - - /** - * Idle timeout for streaming requests in milliseconds (WebSocket only). - * The timer resets on every chunk, so streams stay alive as long as data - * is flowing. Only triggers when the stream is silent for this duration. - * @default 300000 (5 minutes) - */ - streamIdleTimeoutMs?: number; - - /** Connection timeout in milliseconds (WebSocket only) */ - connectTimeoutMs?: number; - - /** Total retry budget in milliseconds for 503 retries during container startup. - * Defaults to 120_000 (2 minutes). Should be at least as large as the sum of - * instanceGetTimeoutMS + portReadyTimeoutMS to avoid the client giving up - * before the container has finished starting. */ - retryTimeoutMs?: number; -} - -/** - * Transport interface - all transports must implement this - * - * Provides a unified abstraction over HTTP and WebSocket communication. - * Both transports support fetch-compatible requests and streaming. - */ -export interface ITransport { - /** - * Make a fetch-compatible request - * @returns Standard Response object - */ - fetch(path: string, options?: RequestInit): Promise; - - /** - * Make a streaming request - * @returns ReadableStream for consuming SSE/streaming data - */ - fetchStream( - path: string, - body?: unknown, - method?: 'GET' | 'POST', - headers?: Record - ): Promise>; - - /** - * Get the transport mode - */ - getMode(): TransportMode; - - /** - * Connect the transport (no-op for HTTP) - */ - connect(): Promise; - - /** - * Disconnect the transport (no-op for HTTP) - */ - disconnect(): void; - - /** - * Check if connected (always true for HTTP) - */ - isConnected(): boolean; - - /** - * Update the 503 retry budget without recreating the transport - */ - setRetryTimeoutMs(ms: number): void; -} diff --git a/packages/sandbox/src/clients/transport/ws-transport.ts b/packages/sandbox/src/clients/transport/ws-transport.ts deleted file mode 100644 index c97d9324c..000000000 --- a/packages/sandbox/src/clients/transport/ws-transport.ts +++ /dev/null @@ -1,848 +0,0 @@ -import { - generateRequestId, - isWSError, - isWSResponse, - isWSStreamChunk, - type WSMethod, - type WSRequest, - type WSResponse, - type WSServerMessage, - type WSStreamChunk -} from '@repo/shared'; -import { BaseTransport } from './base-transport'; -import type { TransportConfig, TransportMode } from './types'; - -/** - * Default timeout values (all in milliseconds) - */ -const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; // 2 minutes for non-streaming requests -const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000; // 5 minutes idle timeout for streams -const DEFAULT_CONNECT_TIMEOUT_MS = 30_000; // 30 seconds for WebSocket connection -const MIN_TIME_FOR_CONNECT_RETRY_MS = 15_000; // Need 15s remaining to retry - -/** - * Pending request tracker for response matching - */ -interface PendingRequest { - resolve: (response: WSResponse) => void; - reject: (error: Error) => void; - streamController?: ReadableStreamDefaultController; - bufferedChunks?: Uint8Array[]; - isStreaming: boolean; - timeoutId?: ReturnType; - /** Called when first stream chunk is received (for deferred stream return) */ - onFirstChunk?: () => void; -} - -/** - * WebSocket transport state - */ -type WSTransportState = 'disconnected' | 'connecting' | 'connected' | 'error'; - -/** - * WebSocket transport implementation - * - * Multiplexes HTTP-like requests over a single WebSocket connection. - * Useful when running inside Workers/DO where sub-request limits apply. - */ -export class WebSocketTransport extends BaseTransport { - private ws: WebSocket | null = null; - private state: WSTransportState = 'disconnected'; - private pendingRequests: Map = new Map(); - private connectPromise: Promise | null = null; - - // Bound event handlers for proper add/remove - private boundHandleMessage: (event: MessageEvent) => void; - private boundHandleClose: (event: CloseEvent) => void; - - constructor(config: TransportConfig) { - super(config); - - if (!config.wsUrl) { - throw new Error('wsUrl is required for WebSocket transport'); - } - - // Bind handlers once in constructor - this.boundHandleMessage = this.handleMessage.bind(this); - this.boundHandleClose = this.handleClose.bind(this); - } - - getMode(): TransportMode { - return 'websocket'; - } - - /** - * Check if WebSocket is connected - */ - isConnected(): boolean { - return this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN; - } - - /** - * Connect to the WebSocket server - * - * The connection promise is assigned synchronously so concurrent - * callers share the same connection attempt. - */ - async connect(): Promise { - // Already connected - if (this.isConnected()) { - return; - } - - // Connection in progress - wait for it - if (this.connectPromise) { - return this.connectPromise; - } - - // Assign synchronously so concurrent callers await the same promise - this.connectPromise = this.doConnect(); - - try { - await this.connectPromise; - } finally { - // Clear promise AFTER await so concurrent callers share the same - // connection attempt, but future reconnects can start a new one. - this.connectPromise = null; - } - } - - /** - * Disconnect from the WebSocket server - */ - disconnect(): void { - this.cleanup(); - } - - /** - * Transport-specific fetch implementation - * Converts WebSocket response to standard Response object. - */ - protected async doFetch( - path: string, - options?: RequestInit - ): Promise { - await this.connect(); - - const method = (options?.method || 'GET') as WSMethod; - const body = this.parseBody(options?.body); - const headers = this.normalizeHeaders(options?.headers); - - const result = await this.request(method, path, body, headers); - - return new Response(JSON.stringify(result.body), { - status: result.status, - headers: { 'Content-Type': 'application/json' } - }); - } - - /** - * Streaming fetch implementation - */ - async fetchStream( - path: string, - body?: unknown, - method: 'GET' | 'POST' = 'POST', - headers?: Record - ): Promise> { - return this.requestStream(method, path, body, headers); - } - - /** - * Parse request body from RequestInit - */ - private parseBody(body: RequestInit['body']): unknown { - if (!body) { - return undefined; - } - - if (typeof body === 'string') { - try { - return JSON.parse(body); - } catch (error) { - throw new Error( - `Request body must be valid JSON: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - - throw new Error( - `WebSocket transport only supports string bodies. Got: ${typeof body}` - ); - } - - /** - * Normalize RequestInit headers into a plain object for WSRequest. - */ - private normalizeHeaders( - headers?: HeadersInit - ): Record | undefined { - if (!headers) { - return undefined; - } - - const normalized: Record = {}; - new Headers(headers).forEach((value, key) => { - normalized[key] = value; - }); - - return Object.keys(normalized).length > 0 ? normalized : undefined; - } - - /** - * Internal connection logic - */ - private async doConnect(): Promise { - this.state = 'connecting'; - // Use fetch-based WebSocket for DO context (Workers style) - if (this.config.stub) { - await this.connectViaFetch(); - } else { - // Use standard WebSocket for browser/Node - await this.connectViaWebSocket(); - } - } - - private async fetchUpgradeWithRetry( - attemptUpgrade: () => Promise - ): Promise { - const retryTimeoutMs = this.getRetryTimeoutMs(); - const startTime = Date.now(); - let attempt = 0; - - while (true) { - const response = await attemptUpgrade(); - - if (response.status !== 503) { - return response; - } - - const elapsed = Date.now() - startTime; - const remaining = retryTimeoutMs - elapsed; - - if (remaining <= MIN_TIME_FOR_CONNECT_RETRY_MS) { - return response; - } - - const delay = Math.min(3000 * 2 ** attempt, 30000); - - this.logger.info('WebSocket container not ready, retrying', { - status: response.status, - attempt: attempt + 1, - delayMs: delay, - remainingSec: Math.floor(remaining / 1000) - }); - - await this.sleep(delay); - attempt++; - } - } - - /** - * Connect using fetch-based WebSocket (Cloudflare Workers style) - * This is required when running inside a Durable Object. - * - * Uses stub.fetch() which routes WebSocket upgrade requests through the - * parent Container class that supports the WebSocket protocol. - */ - private async connectViaFetch(): Promise { - const timeoutMs = - this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; - - try { - // Build the WebSocket URL for the container - const wsPath = new URL(this.config.wsUrl!).pathname; - const httpUrl = `http://localhost:${this.config.port || 3000}${wsPath}`; - - const response = await this.fetchUpgradeWithRetry(() => - this.fetchUpgradeAttempt(httpUrl, timeoutMs) - ); - - // 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.config.wsUrl - }); - } catch (error) { - this.state = 'error'; - this.logger.error( - 'WebSocket fetch connection failed', - error instanceof Error ? error : new Error(String(error)) - ); - throw error; - } - } - - private async fetchUpgradeAttempt( - httpUrl: string, - timeoutMs: number - ): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - - try { - const request = new Request(httpUrl, { - headers: { - Upgrade: 'websocket', - Connection: 'Upgrade' - }, - signal: controller.signal - }); - - return await this.config.stub!.fetch(request); - } finally { - clearTimeout(timeout); - } - } - - /** - * Connect using standard WebSocket API (browser/Node style) - */ - private connectViaWebSocket(): Promise { - return new Promise((resolve, reject) => { - const timeoutMs = - this.config.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; - const timeout = setTimeout(() => { - this.cleanup(); - reject(new Error(`WebSocket connection timeout after ${timeoutMs}ms`)); - }, timeoutMs); - - try { - this.ws = new WebSocket(this.config.wsUrl!); - - // 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.config.wsUrl }); - 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); - } - }); - } - - /** - * Send a request and wait for response - */ - private async request( - method: WSMethod, - path: string, - body?: unknown, - headers?: Record - ): Promise<{ status: number; body: T }> { - await this.connect(); - - const id = generateRequestId(); - const request: WSRequest = { - type: 'request', - id, - method, - path, - body, - headers - }; - - return new Promise((resolve, reject) => { - const timeoutMs = - this.config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; - const timeoutId = setTimeout(() => { - this.pendingRequests.delete(id); - reject( - new Error(`Request timeout after ${timeoutMs}ms: ${method} ${path}`) - ); - }, timeoutMs); - - this.pendingRequests.set(id, { - resolve: (response: WSResponse) => { - clearTimeout(timeoutId); - this.pendingRequests.delete(id); - resolve({ status: response.status, body: response.body as T }); - }, - reject: (error: Error) => { - clearTimeout(timeoutId); - this.pendingRequests.delete(id); - reject(error); - }, - isStreaming: false, - timeoutId - }); - - try { - this.send(request); - } catch (error) { - clearTimeout(timeoutId); - this.pendingRequests.delete(id); - reject(error instanceof Error ? error : new Error(String(error))); - } - }); - } - - /** - * 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. - * - * This method waits for the first message before returning. If the server - * responds with an error (non-streaming response), it throws immediately - * rather than returning a stream that will error later. - * - * Uses an inactivity timeout instead of a total-duration timeout so that - * long-running streams (e.g. execStream from an agent) stay alive as long - * as data is flowing. The timer resets on every chunk or response message. - */ - private async requestStream( - method: WSMethod, - path: string, - body?: unknown, - headers?: Record - ): Promise> { - await this.connect(); - - const id = generateRequestId(); - const request: WSRequest = { - type: 'request', - id, - method, - path, - body, - headers - }; - - const idleTimeoutMs = - this.config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS; - - // We need to wait for the first message to determine if this is a streaming - // response or an immediate error. This prevents returning a stream that will - // error on first read. - return new Promise((resolveStream, rejectStream) => { - let streamController: ReadableStreamDefaultController; - let firstMessageReceived = false; - - const createIdleTimeout = (): ReturnType => { - return setTimeout(() => { - this.pendingRequests.delete(id); - const error = new Error( - `Stream idle timeout after ${idleTimeoutMs}ms: ${method} ${path}` - ); - if (firstMessageReceived) { - try { - streamController?.error(error); - } catch { - // Stream controller may already be closed/errored - } - } else { - rejectStream(error); - } - }, idleTimeoutMs); - }; - - const timeoutId = createIdleTimeout(); - - // Create the stream but don't return it until we get the first message - const stream = new ReadableStream({ - start: (controller) => { - streamController = controller; - }, - cancel: () => { - const pending = this.pendingRequests.get(id); - if (pending?.timeoutId) { - clearTimeout(pending.timeoutId); - } - - // Best-effort server-side cancellation for active streaming requests. - try { - this.send({ type: 'cancel', id }); - } catch (error) { - this.logger.debug('Failed to send stream cancel message', { - id, - error: error instanceof Error ? error.message : String(error) - }); - } - - this.pendingRequests.delete(id); - } - }); - - this.pendingRequests.set(id, { - resolve: (response: WSResponse) => { - const pending = this.pendingRequests.get(id); - if (pending?.timeoutId) { - clearTimeout(pending.timeoutId); - } - this.pendingRequests.delete(id); - - if (!firstMessageReceived) { - // First message is a final response (not streaming) - this is an error case - firstMessageReceived = true; - if (response.status >= 400) { - rejectStream( - new Error( - `Stream error: ${response.status} - ${JSON.stringify(response.body)}` - ) - ); - } else { - // Successful non-streaming response - close immediately - streamController?.close(); - resolveStream(stream); - } - } else { - // Stream was already returned, now closing - if (response.status >= 400) { - try { - streamController?.error( - new Error( - `Stream error: ${response.status} - ${JSON.stringify(response.body)}` - ) - ); - } catch { - // Stream controller may already be closed/errored - } - } else { - streamController?.close(); - } - } - }, - reject: (error: Error) => { - const pending = this.pendingRequests.get(id); - if (pending?.timeoutId) { - clearTimeout(pending.timeoutId); - } - this.pendingRequests.delete(id); - if (firstMessageReceived) { - try { - streamController?.error(error); - } catch { - // Stream controller may already be closed/errored - } - } else { - rejectStream(error); - } - }, - streamController: undefined, // Set after first chunk - isStreaming: true, - timeoutId, - // Custom handler for first stream chunk - onFirstChunk: () => { - if (!firstMessageReceived) { - firstMessageReceived = true; - // Update the pending request with the actual controller - const pending = this.pendingRequests.get(id); - if (pending) { - pending.streamController = streamController; - // Flush any chunks that arrived before the controller was set - if (pending.bufferedChunks) { - try { - for (const buffered of pending.bufferedChunks) { - streamController.enqueue(buffered); - } - } catch (error) { - this.logger.debug( - 'Failed to flush buffered chunks, cleaning up', - { - id, - error: - error instanceof Error ? error.message : String(error) - } - ); - if (pending.timeoutId) { - clearTimeout(pending.timeoutId); - } - this.pendingRequests.delete(id); - } - pending.bufferedChunks = undefined; - } - } - resolveStream(stream); - } - } - }); - - try { - this.send(request); - } catch (error) { - clearTimeout(timeoutId); - this.pendingRequests.delete(id); - rejectStream(error instanceof Error ? error : new Error(String(error))); - } - }); - } - - /** - * Send a message over the WebSocket - */ - private send(message: WSRequest | { type: 'cancel'; id: string }): 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, - type: message.type, - method: message.type === 'request' ? message.method : undefined, - path: message.type === 'request' ? message.path : undefined - }); - } - - /** - * 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 - * - * Resets the idle timeout on every chunk so that long-running streams - * with continuous output are not killed by the inactivity timer. - */ - private handleStreamChunk(chunk: WSStreamChunk): void { - const pending = this.pendingRequests.get(chunk.id); - if (!pending) { - this.logger.warn('Received stream chunk for unknown request', { - id: chunk.id - }); - return; - } - - // Call onFirstChunk FIRST to set up the stream controller - if (pending.onFirstChunk) { - pending.onFirstChunk(); - pending.onFirstChunk = undefined; // Only call once - } - - // NOW reset the idle timeout - controller is guaranteed to exist - if (pending.isStreaming) { - this.resetStreamIdleTimeout(chunk.id, pending); - } - - // Buffer chunks if controller not set yet (race between onFirstChunk and enqueue) - if (!pending.streamController) { - if (!pending.bufferedChunks) { - pending.bufferedChunks = []; - } - 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`; - } - pending.bufferedChunks.push(encoder.encode(sseData)); - 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 was cancelled or errored - clean up the pending request - this.logger.debug('Failed to enqueue stream chunk, cleaning up', { - id: chunk.id, - error: error instanceof Error ? error.message : String(error) - }); - // Clear timeout and remove from pending requests - if (pending.timeoutId) { - clearTimeout(pending.timeoutId); - } - this.pendingRequests.delete(chunk.id); - } - } - - /** - * Reset the idle timeout for a streaming request. - * Called on every incoming chunk to keep the stream alive while data flows. - */ - private resetStreamIdleTimeout(id: string, pending: PendingRequest): void { - if (pending.timeoutId) { - clearTimeout(pending.timeoutId); - } - - const idleTimeoutMs = - this.config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS; - pending.timeoutId = setTimeout(() => { - this.pendingRequests.delete(id); - if (pending.streamController) { - try { - pending.streamController.error( - new Error(`Stream idle timeout after ${idleTimeoutMs}ms`) - ); - } catch { - // Stream may already be closed/errored - } - } - }, idleTimeoutMs); - } - - /** - * 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; - this.connectPromise = null; - - const closeError = new Error( - `WebSocket closed: ${event.code} ${event.reason || 'No reason'}` - ); - - // Reject all pending requests, clear their timeouts, and error their stream controllers - for (const [, pending] of this.pendingRequests) { - // Clear timeout first to prevent memory leak - if (pending.timeoutId) { - clearTimeout(pending.timeoutId); - } - // Error stream controller if it exists - if (pending.streamController) { - try { - pending.streamController.error(closeError); - } catch { - // Stream may already be closed/errored - } - } - pending.reject(closeError); - } - 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; - // Clear all pending request timeouts before clearing the map - for (const pending of this.pendingRequests.values()) { - if (pending.timeoutId) { - clearTimeout(pending.timeoutId); - } - } - this.pendingRequests.clear(); - } -} diff --git a/packages/sandbox/src/clients/types.ts b/packages/sandbox/src/clients/types.ts deleted file mode 100644 index 5004d99a4..000000000 --- a/packages/sandbox/src/clients/types.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { Logger } from '@repo/shared'; -import type { ITransport, TransportMode } from './transport'; - -/** - * Minimal interface for container fetch functionality - */ -export interface ContainerStub { - containerFetch( - url: string, - options: RequestInit, - port?: number - ): Promise; - - /** - * Fetch that can handle WebSocket upgrades (routes through parent Container class). - * Required for WebSocket transport to establish control plane connections. - */ - fetch(request: Request): Promise; -} - -/** - * Shared HTTP client configuration options - */ -export interface HttpClientOptions { - logger?: Logger; - baseUrl?: string; - port?: number; - stub?: ContainerStub; - onCommandComplete?: ( - success: boolean, - exitCode: number, - stdout: string, - stderr: string, - 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?: ITransport; - - /** - * Total retry budget in milliseconds for 503 retries during container startup. - * Passed through to the transport layer. Defaults to 120_000 (2 minutes). - */ - retryTimeoutMs?: number; - - /** - * Headers merged into every outgoing container request. - * Used to propagate stable context (e.g. sandboxId) from the Durable Object - * to the container so container logs carry the same identifiers as DO logs. - */ - defaultHeaders?: Record; -} - -/** - * Base response interface for all API responses - */ -export interface BaseApiResponse { - success: boolean; - timestamp: string; -} - -/** - * Standard error response structure - matches BaseHandler.createErrorResponse() - */ -export interface ApiErrorResponse { - success: false; - error: string; - code: string; - details?: any; - timestamp: string; -} - -/** - * Validation error response structure - matches ValidationMiddleware - */ -export interface ValidationErrorResponse { - error: string; - message: string; - details?: any[]; - timestamp: string; -} - -/** - * Legacy error response interface - deprecated, use ApiErrorResponse - */ -export interface ErrorResponse { - error: string; - details?: string; - code?: string; -} - -/** - * HTTP request configuration - */ -export interface RequestConfig extends RequestInit { - endpoint: string; - data?: Record; -} - -/** - * Typed response handler - */ -export type ResponseHandler = (response: Response) => Promise; - -/** - * Common session-aware request interface - */ -export interface SessionRequest { - sessionId?: string; -} diff --git a/packages/sandbox/src/clients/utility-client.ts b/packages/sandbox/src/clients/utility-client.ts deleted file mode 100644 index dfe4df238..000000000 --- a/packages/sandbox/src/clients/utility-client.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { BaseHttpClient } from './base-client'; -import type { BaseApiResponse, HttpClientOptions } from './types'; - -/** - * Response interface for ping operations - */ -export interface PingResponse extends BaseApiResponse { - message: string; - uptime?: number; -} - -/** - * Response interface for getting available commands - */ -export interface CommandsResponse extends BaseApiResponse { - availableCommands: string[]; - count: number; -} - -/** - * Response interface for getting container version - */ -export interface VersionResponse extends BaseApiResponse { - version: string; -} - -/** - * Request interface for creating sessions - */ -export interface CreateSessionRequest { - id: string; - env?: Record; - cwd?: string; - commandTimeoutMs?: number; -} - -/** - * Response interface for creating sessions - */ -export interface CreateSessionResponse extends BaseApiResponse { - id: string; - message: string; -} - -/** - * Request interface for deleting sessions - */ -export interface DeleteSessionRequest { - sessionId: string; -} - -/** - * Response interface for deleting sessions - */ -export interface DeleteSessionResponse extends BaseApiResponse { - sessionId: string; -} - -/** - * Client for health checks and utility operations - */ -export class UtilityClient extends BaseHttpClient { - /** - * Ping the sandbox to check if it's responsive - */ - async ping(): Promise { - try { - const response = await this.get('/api/ping'); - - return response.message; - } catch (error) { - throw error; - } - } - - /** - * Get list of available commands in the sandbox environment - */ - async getCommands(): Promise { - try { - const response = await this.get('/api/commands'); - - return response.availableCommands; - } catch (error) { - throw error; - } - } - - /** - * Create a new execution session - * @param options - Session configuration (id, env, cwd) - */ - async createSession( - options: CreateSessionRequest - ): Promise { - try { - const response = await this.post( - '/api/session/create', - options - ); - - return response; - } catch (error) { - throw error; - } - } - - /** - * Delete an execution session - * @param sessionId - Session ID to delete - */ - async deleteSession(sessionId: string): Promise { - try { - const response = await this.post( - '/api/session/delete', - { sessionId } - ); - - return response; - } catch (error) { - throw error; - } - } - - /** - * Get the container version - * Returns the version embedded in the Docker image during build - */ - async getVersion(): Promise { - try { - const response = await this.get('/api/version'); - - return response.version; - } catch (error) { - // If version endpoint doesn't exist (old container), return 'unknown' - // This allows for backward compatibility - this.logger.debug( - 'Failed to get container version (may be old container)', - { error } - ); - return 'unknown'; - } - } -} diff --git a/packages/sandbox/src/clients/watch-client.ts b/packages/sandbox/src/clients/watch-client.ts deleted file mode 100644 index f61df72ca..000000000 --- a/packages/sandbox/src/clients/watch-client.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { - type FileWatchSSEEvent, - parseSSEFrames, - type SSEPartialEvent, - type WatchRequest -} from '@repo/shared'; -import { BaseHttpClient } from './base-client'; - -/** - * Client for file watch operations - * Uses inotify under the hood for native filesystem event notifications - * - * @internal This client is used internally by the SDK. - * Users should use `sandbox.watch()` instead. - */ -export class WatchClient extends BaseHttpClient { - /** - * Start watching a directory for changes. - * The returned promise resolves only after the watcher is established - * on the filesystem (i.e. the `watching` SSE event has been received). - * The returned stream still contains the `watching` event so consumers - * using `parseSSEStream` will see the full event sequence. - * - * @param request - Watch request with path and options - */ - async watch(request: WatchRequest): Promise> { - try { - const stream = await this.doStreamFetch('/api/watch', request); - const readyStream = await this.waitForReadiness(stream); - - return readyStream; - } catch (error) { - throw error; - } - } - - /** - * Read SSE chunks until the `watching` event appears, then return a - * wrapper stream that replays the buffered chunks followed by the - * remaining original stream data. - */ - private async waitForReadiness( - stream: ReadableStream - ): Promise> { - const reader = stream.getReader(); - const bufferedChunks: Uint8Array[] = []; - const decoder = new TextDecoder(); - let buffer = ''; - let currentEvent: SSEPartialEvent = { data: [] }; - let watcherReady = false; - - const processEventData = (eventData: string) => { - let event: FileWatchSSEEvent | undefined; - try { - event = JSON.parse(eventData) as FileWatchSSEEvent; - } catch { - return; - } - - if (event.type === 'watching') { - watcherReady = true; - } - - if (event.type === 'error') { - throw new Error(event.error || 'Watch failed to establish'); - } - }; - - try { - while (!watcherReady) { - const { done, value } = await reader.read(); - if (done) { - const finalParsed = parseSSEFrames(`${buffer}\n\n`, currentEvent); - for (const frame of finalParsed.events) { - processEventData(frame.data); - if (watcherReady) { - break; - } - } - - if (watcherReady) { - break; - } - - throw new Error('Watch stream ended before watcher was established'); - } - - bufferedChunks.push(value); - buffer += decoder.decode(value, { stream: true }); - const parsed = parseSSEFrames(buffer, currentEvent); - buffer = parsed.remaining; - currentEvent = parsed.currentEvent; - - for (const frame of parsed.events) { - processEventData(frame.data); - if (watcherReady) { - break; - } - } - } - } catch (error) { - reader.cancel().catch(() => {}); - throw error; - } - - // Return a stream that replays buffered chunks, then forwards the rest. - let replayIndex = 0; - return new ReadableStream({ - pull(controller) { - if (replayIndex < bufferedChunks.length) { - controller.enqueue(bufferedChunks[replayIndex++]); - return; - } - return reader.read().then(({ done: d, value: v }) => { - if (d) { - controller.close(); - return; - } - controller.enqueue(v); - }); - }, - cancel() { - return reader.cancel(); - } - }); - } -} diff --git a/packages/sandbox/src/file-stream.ts b/packages/sandbox/src/file-stream.ts deleted file mode 100644 index 73785ced9..000000000 --- a/packages/sandbox/src/file-stream.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { - type FileChunk, - type FileMetadata, - type FileStreamEvent, - parseSSEFrames, - type SSEPartialEvent -} from '@repo/shared'; - -/** - * Parse SSE (Server-Sent Events) lines from a stream - */ -async function* parseSSE( - stream: ReadableStream -): AsyncGenerator { - const reader = stream.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let currentEvent: SSEPartialEvent = { data: [] }; - - try { - while (true) { - const { done, value } = await reader.read(); - - if (done) { - break; - } - - buffer += decoder.decode(value, { stream: true }); - const parsed = parseSSEFrames(buffer, currentEvent); - buffer = parsed.remaining; - currentEvent = parsed.currentEvent; - - for (const frame of parsed.events) { - try { - const event = JSON.parse(frame.data) as FileStreamEvent; - yield event; - } catch { - // Skip invalid JSON events and continue processing - } - } - } - - // Flush complete frame from final trailing buffer - const finalParsed = parseSSEFrames(`${buffer}\n\n`, currentEvent); - for (const frame of finalParsed.events) { - try { - const event = JSON.parse(frame.data) as FileStreamEvent; - yield event; - } catch { - // Skip invalid JSON events and continue processing - } - } - } finally { - // Cancel the stream first to properly terminate HTTP connections when breaking early - try { - await reader.cancel(); - } catch { - // Ignore cancel errors (stream may already be closed) - } - reader.releaseLock(); - } -} - -/** - * Stream a file from the sandbox with automatic base64 decoding for binary files - * - * @param stream - The ReadableStream from readFileStream() - * @returns AsyncGenerator that yields FileChunk (string for text, Uint8Array for binary) - * - * @example - * ```ts - * const stream = await sandbox.readFileStream('/path/to/file.png'); - * for await (const chunk of streamFile(stream)) { - * if (chunk instanceof Uint8Array) { - * // Binary chunk - * console.log('Binary chunk:', chunk.length, 'bytes'); - * } else { - * // Text chunk - * console.log('Text chunk:', chunk); - * } - * } - * ``` - */ -export async function* streamFile( - stream: ReadableStream -): AsyncGenerator { - let metadata: FileMetadata | null = null; - - for await (const event of parseSSE(stream)) { - switch (event.type) { - case 'metadata': - metadata = { - mimeType: event.mimeType, - size: event.size, - isBinary: event.isBinary, - encoding: event.encoding - }; - break; - - case 'chunk': - if (!metadata) { - throw new Error('Received chunk before metadata'); - } - - if (metadata.isBinary && metadata.encoding === 'base64') { - // Decode base64 to Uint8Array for binary files - const binaryString = atob(event.data); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - yield bytes; - } else { - // Text files - yield as-is - yield event.data; - } - break; - - case 'complete': - if (!metadata) { - throw new Error('Stream completed without metadata'); - } - return metadata; - - case 'error': - throw new Error(`File streaming error: ${event.error}`); - } - } - - throw new Error('Stream ended unexpectedly'); -} - -/** - * Collect an entire file into memory from a stream - * - * @param stream - The ReadableStream from readFileStream() - * @returns Object containing the file content and metadata - * - * @example - * ```ts - * const stream = await sandbox.readFileStream('/path/to/file.txt'); - * const { content, metadata } = await collectFile(stream); - * console.log('Content:', content); - * console.log('MIME type:', metadata.mimeType); - * ``` - */ -export async function collectFile(stream: ReadableStream): Promise<{ - content: string | Uint8Array; - metadata: FileMetadata; -}> { - const chunks: Array = []; - - // Iterate through the generator and get the return value (metadata) - const generator = streamFile(stream); - let result = await generator.next(); - - while (!result.done) { - chunks.push(result.value); - result = await generator.next(); - } - - const metadata = result.value; - - if (!metadata) { - throw new Error('Failed to get file metadata'); - } - - // Combine chunks based on type - if (metadata.isBinary) { - // Binary file - combine Uint8Arrays - const totalLength = chunks.reduce( - (sum, chunk) => sum + (chunk instanceof Uint8Array ? chunk.length : 0), - 0 - ); - const combined = new Uint8Array(totalLength); - let offset = 0; - for (const chunk of chunks) { - if (chunk instanceof Uint8Array) { - combined.set(chunk, offset); - offset += chunk.length; - } - } - return { content: combined, metadata }; - } else { - // Text file - combine strings - const combined = chunks.filter((c) => typeof c === 'string').join(''); - return { content: combined, metadata }; - } -} diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts index 259e9c7e7..d53f56066 100644 --- a/packages/sandbox/src/index.ts +++ b/packages/sandbox/src/index.ts @@ -1,23 +1,8 @@ // Export the main Sandbox class and utilities - -// Export the new client architecture -export { - BackupClient, - CommandClient, - DesktopClient, - FileClient, - GitClient, - PortClient, - ProcessClient, - SandboxClient, - UtilityClient -} from './clients'; export { getSandbox, Sandbox } from './sandbox'; -// Legacy types are now imported from the new client architecture - // Required export for egress intercepting -export { ContainerProxy } from '@cloudflare/containers'; + // Export core SDK types for consumers export type { BackupOptions, @@ -35,7 +20,6 @@ export type { FileChunk, FileMetadata, FileStreamEvent, - // File watch types FileWatchSSEEvent, GitCheckoutResult, ISandbox, @@ -57,77 +41,13 @@ export type { WaitForPortOptions, WatchOptions } from '@repo/shared'; + // Export type guards for runtime validation export { isExecResult, isProcess, isProcessStatus } from '@repo/shared'; -// Export all client types from new architecture -export type { - BaseApiResponse, - - // Desktop client types - ClickOptions, - CommandsResponse, - ContainerStub, - - // Utility client types - CreateSessionRequest, - CreateSessionResponse, - CursorPositionResponse, - DeleteSessionRequest, - DeleteSessionResponse, - Desktop, - DesktopStartOptions, - DesktopStartResponse, - DesktopStatusResponse, - DesktopStopResponse, - ErrorResponse, - // Command client types - ExecuteRequest, - ExecuteResponse as CommandExecuteResponse, +// Export desktop types +export type { Desktop, ExecuteResponse } from './clients'; - // Port client types - ExposePortRequest, - FileOperationRequest, - - // Git client types - GitCheckoutRequest, - // Base client types - HttpClientOptions as SandboxClientOptions, - KeyInput, - - // File client types - MkdirRequest, - PingResponse, - PortCloseResult, - PortExposeResult, - PortListResult, - ProcessCleanupResult, - ProcessInfoResult, - ProcessKillResult, - ProcessListResult, - ProcessLogsResult, - ProcessStartResult, - ReadFileRequest, - RequestConfig, - ResponseHandler, - ScreenSizeResponse, - ScreenshotBytesResponse, - ScreenshotOptions, - ScreenshotRegion, - ScreenshotResponse, - ScrollDirection, - SessionRequest, - - // Process client types - StartProcessRequest, - TypeOptions, - UnexposePortRequest, - WriteFileRequest -} from './clients'; -export type { - ExecutionCallbacks, - InterpreterClient -} from './clients/interpreter-client.js'; // Export backup and process readiness errors export { BackupCreateError, @@ -144,23 +64,18 @@ export { ProcessExitedBeforeReadyError, ProcessReadyTimeoutError } from './errors'; -// Export file streaming utilities for binary file support -export { collectFile, streamFile } from './file-stream'; + // Export interpreter functionality export { CodeInterpreter } from './interpreter.js'; export { proxyTerminal } from './pty'; + // Re-export request handler utilities export { proxyToSandbox, type RouteInfo, type SandboxEnv } from './request-handler'; -// Export SSE parser for converting ReadableStream to AsyncIterable -export { - asyncIterableToSSEStream, - parseSSEStream, - responseToAsyncIterable -} from './sse-parser'; + // Export bucket mounting errors export { BucketMountError, diff --git a/packages/sandbox/src/interpreter.ts b/packages/sandbox/src/interpreter.ts index e65d8b12b..262672de6 100644 --- a/packages/sandbox/src/interpreter.ts +++ b/packages/sandbox/src/interpreter.ts @@ -8,7 +8,35 @@ import { ResultImpl, type RunCodeOptions } from '@repo/shared'; -import type { InterpreterClient } from './clients/interpreter-client.js'; + +// InterpreterClient type extracted from RPCSandboxClient +interface InterpreterClient { + createCodeContext( + options?: import('@repo/shared').CreateContextOptions + ): Promise; + runCodeStream( + contextId: string | undefined, + code: string, + language: string | undefined, + callbacks: { + onStdout?: (output: import('@repo/shared').OutputMessage) => void; + onStderr?: (output: import('@repo/shared').OutputMessage) => void; + onResult?: ( + result: import('@repo/shared').Result + ) => void | Promise; + onError?: (error: import('@repo/shared').ExecutionError) => void; + }, + timeoutMs?: number + ): Promise; + streamCode( + contextId: string, + code: string, + language?: string + ): Promise; + listCodeContexts(): Promise; + deleteCodeContext(contextId: string): Promise; +} + import type { Sandbox } from './sandbox.js'; import { validateLanguage } from './security.js'; diff --git a/packages/sandbox/src/local-mount-sync.ts b/packages/sandbox/src/local-mount-sync.ts index 44f77c02b..eb2ec86d3 100644 --- a/packages/sandbox/src/local-mount-sync.ts +++ b/packages/sandbox/src/local-mount-sync.ts @@ -1,6 +1,5 @@ import path from 'node:path/posix'; import type { FileWatchSSEEvent, Logger } from '@repo/shared'; -import type { SandboxClient } from './clients'; import type { RPCSandboxClient } from './clients/rpc-sandbox-client'; import { parseSSEStream } from './sse-parser'; @@ -19,7 +18,7 @@ interface LocalMountSyncOptions { mountPath: string; prefix: string | undefined; readOnly: boolean; - client: SandboxClient | RPCSandboxClient; + client: RPCSandboxClient; sessionId: string; logger: Logger; pollIntervalMs?: number; @@ -37,7 +36,7 @@ export class LocalMountSyncManager { private readonly mountPath: string; private readonly prefix: string | undefined; private readonly readOnly: boolean; - private readonly client: SandboxClient | RPCSandboxClient; + private readonly client: RPCSandboxClient; private readonly sessionId: string; private readonly logger: Logger; private readonly pollIntervalMs: number; diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index a0f8be602..6409428df 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -43,7 +43,7 @@ import { TraceContext } from '@repo/shared'; import { AwsClient } from 'aws4fetch'; -import { type Desktop, type ExecuteResponse, SandboxClient } from './clients'; +import type { Desktop, ExecuteResponse } from './clients'; import { RPCSandboxClient } from './clients/rpc-sandbox-client'; import { ContainerConnection } from './container-connection'; import type { ErrorResponse } from './errors'; @@ -410,8 +410,8 @@ export class Sandbox extends Container implements ISandbox { defaultPort = 3000; // Default port for the container's Bun server sleepAfter: string | number = '10m'; // Sleep the sandbox if no requests are made in this timeframe - client: SandboxClient | RPCSandboxClient; - containerRPC: ContainerConnection | null = null; + client: RPCSandboxClient; + containerRPC: ContainerConnection; private codeInterpreter: CodeInterpreter; private sandboxName: string | null = null; private normalizeId: boolean = false; @@ -421,7 +421,6 @@ export class Sandbox extends Container implements ISandbox { private logger: ReturnType; private keepAliveEnabled: boolean = false; private activeMounts: Map = new Map(); - private transport: 'http' | 'websocket' | 'capnweb' = 'http'; // R2 bucket binding for backup storage (optional — only set if user configures BACKUP_BUCKET) private backupBucket: R2Bucket | null = null; @@ -539,25 +538,6 @@ export class Sandbox extends Container implements ISandbox { return Math.max(120_000, startupBudgetMs + 30_000); } - /** - * Create a SandboxClient with current transport settings - */ - private createSandboxClient(): SandboxClient { - return new SandboxClient({ - logger: this.logger, - port: 3000, - stub: this, - retryTimeoutMs: this.computeRetryTimeoutMs(), - defaultHeaders: { - 'X-Sandbox-Id': this.ctx.id.toString() - }, - ...(this.transport === 'websocket' && { - transportMode: 'websocket' as const, - wsUrl: 'ws://localhost:3000/ws' - }) - }); - } - constructor(ctx: DurableObjectState<{}>, env: Env) { super(ctx, env); @@ -577,16 +557,6 @@ export class Sandbox extends Container implements ISandbox { sandboxId: this.ctx.id.toString() }); - // Read transport setting from env var - const transportEnv = envObj?.SANDBOX_TRANSPORT; - if (transportEnv === 'websocket' || transportEnv === 'capnweb') { - this.transport = transportEnv; - } else if (transportEnv != null && transportEnv !== 'http') { - this.logger.warn( - `Invalid SANDBOX_TRANSPORT value: "${transportEnv}". Must be "http", "websocket", or "capnweb". Defaulting to "http".` - ); - } - // Read R2 backup bucket binding if configured const backupBucket = envObj?.BACKUP_BUCKET; if (isR2Bucket(backupBucket)) { @@ -607,17 +577,13 @@ export class Sandbox extends Container implements ISandbox { }); } - // Direct capnweb RPC connection (created before client so RPCSandboxClient can use it) - if (this.transport === 'capnweb') { - this.containerRPC = new ContainerConnection({ - stub: this, - port: 3000, - logger: this.logger - }); - this.client = new RPCSandboxClient(this.containerRPC); - } else { - this.client = this.createSandboxClient(); - } + // capnweb RPC connection to the container + this.containerRPC = new ContainerConnection({ + stub: this, + port: 3000, + logger: this.logger + }); + this.client = new RPCSandboxClient(this.containerRPC); // Initialize code interpreter - pass 'this' after client is ready // The CodeInterpreter extracts client.interpreter from the sandbox @@ -1309,9 +1275,8 @@ export class Sandbox extends Container implements ISandbox { } } - // Disconnect WebSocket transport if active + // Disconnect capnweb RPC connection this.client.disconnect(); - this.containerRPC?.disconnect(); // Unmount all mounted buckets and cleanup for (const [mountPath, mountInfo] of this.activeMounts.entries()) { @@ -3979,8 +3944,7 @@ export class Sandbox extends Container implements ISandbox { dir, archivePath, backupSession, - gitignore, - excludes + { gitignore, excludes } ); if (!createResult.success) { diff --git a/packages/sandbox/tests/base-client.test.ts b/packages/sandbox/tests/base-client.test.ts deleted file mode 100644 index 9deac00cf..000000000 --- a/packages/sandbox/tests/base-client.test.ts +++ /dev/null @@ -1,736 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { BaseApiResponse, HttpClientOptions } from '../src/clients'; -import { BaseHttpClient } from '../src/clients/base-client'; -import type { ITransport, TransportMode } from '../src/clients/transport'; -import type { ErrorResponse } from '../src/errors'; -import { - CommandError, - FileNotFoundError, - FileSystemError, - PermissionDeniedError, - SandboxError -} from '../src/errors'; - -interface TestDataResponse extends BaseApiResponse { - data: string; -} - -interface TestResourceResponse extends BaseApiResponse { - id: string; -} - -interface TestSourceResponse extends BaseApiResponse { - source: string; -} - -interface TestStatusResponse extends BaseApiResponse { - status: string; -} - -class MockTransport implements ITransport { - public fetchMock = - vi.fn<(path: string, options?: RequestInit) => Promise>(); - public fetchStreamMock = - vi.fn< - ( - path: string, - body?: unknown, - method?: 'GET' | 'POST', - headers?: Record - ) => Promise> - >(); - public connectMock = vi.fn<() => Promise>(); - public disconnectMock = vi.fn<() => void>(); - public isConnectedMock = vi.fn<() => boolean>(); - public setRetryTimeoutMsMock = vi.fn<(ms: number) => void>(); - - constructor(private mode: TransportMode = 'http') { - this.connectMock.mockResolvedValue(undefined); - this.isConnectedMock.mockReturnValue(true); - } - - fetch(path: string, options?: RequestInit): Promise { - return this.fetchMock(path, options); - } - - fetchStream( - path: string, - body?: unknown, - method?: 'GET' | 'POST', - headers?: Record - ): Promise> { - return this.fetchStreamMock(path, body, method, headers); - } - - getMode(): TransportMode { - return this.mode; - } - - connect(): Promise { - return this.connectMock(); - } - - disconnect(): void { - this.disconnectMock(); - } - - isConnected(): boolean { - return this.isConnectedMock(); - } - - setRetryTimeoutMs(ms: number): void { - this.setRetryTimeoutMsMock(ms); - } -} - -class TestHttpClient extends BaseHttpClient { - constructor(options: HttpClientOptions = {}) { - super({ - baseUrl: 'http://test.com', - port: 3000, - ...options - }); - } - - public async testRequest( - endpoint: string, - data?: Record - ): Promise { - if (data) { - return this.post(endpoint, data); - } - return this.get(endpoint); - } - - public async testStreamRequest(endpoint: string): Promise { - const response = await this.doFetch(endpoint); - return this.handleStreamResponse(response); - } - - public async testDoStreamFetch( - endpoint: string, - body?: unknown, - method: 'GET' | 'POST' = 'POST' - ): Promise> { - return this.doStreamFetch(endpoint, body, method); - } - - public async testErrorHandling(errorResponse: ErrorResponse) { - const response = new Response(JSON.stringify(errorResponse), { - status: errorResponse.httpStatus || 400 - }); - return this.handleErrorResponse(response); - } -} - -describe('BaseHttpClient', () => { - let client: TestHttpClient; - let mockFetch: ReturnType; - let onError: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - - mockFetch = vi.fn(); - global.fetch = mockFetch as unknown as typeof fetch; - onError = vi.fn(); - - client = new TestHttpClient({ - baseUrl: 'http://test.com', - port: 3000, - onError - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('core request functionality', () => { - it('should handle successful API requests', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ success: true, data: 'operation completed' }), - { status: 200 } - ) - ); - - const result = await client.testRequest('/api/test'); - - expect(result.success).toBe(true); - expect(result.data).toBe('operation completed'); - }); - - it('should handle POST requests with data', async () => { - const requestData = { action: 'create', name: 'test-resource' }; - mockFetch.mockResolvedValue( - new Response(JSON.stringify({ success: true, id: 'resource-123' }), { - status: 201 - }) - ); - - const result = await client.testRequest( - '/api/create', - requestData - ); - - expect(result.success).toBe(true); - expect(result.id).toBe('resource-123'); - - const [url, options] = mockFetch.mock.calls[0]; - expect(url).toBe('http://test.com/api/create'); - expect(options.method).toBe('POST'); - expect(options.headers['Content-Type']).toBe('application/json'); - expect(JSON.parse(options.body)).toEqual(requestData); - }); - }); - - describe('error handling and mapping', () => { - it('should map container errors to client errors', async () => { - const errorMappingTests = [ - { - containerError: { - code: 'FILE_NOT_FOUND', - message: 'File not found: /test.txt', - context: { path: '/test.txt' }, - httpStatus: 404, - timestamp: new Date().toISOString() - }, - expectedError: FileNotFoundError - }, - { - containerError: { - code: 'PERMISSION_DENIED', - message: 'Permission denied', - context: { path: '/secure.txt' }, - httpStatus: 403, - timestamp: new Date().toISOString() - }, - expectedError: PermissionDeniedError - }, - { - containerError: { - code: 'COMMAND_EXECUTION_ERROR', - message: 'Command failed: badcmd', - context: { command: 'badcmd' }, - httpStatus: 400, - timestamp: new Date().toISOString() - }, - expectedError: CommandError - }, - { - containerError: { - code: 'FILESYSTEM_ERROR', - message: 'Filesystem error', - context: { path: '/test' }, - httpStatus: 500, - timestamp: new Date().toISOString() - }, - expectedError: FileSystemError - }, - { - containerError: { - code: 'UNKNOWN_ERROR', - message: 'Unknown error', - context: {}, - httpStatus: 500, - timestamp: new Date().toISOString() - }, - expectedError: SandboxError - } - ]; - - for (const test of errorMappingTests) { - await expect( - client.testErrorHandling(test.containerError as ErrorResponse) - ).rejects.toThrow(test.expectedError); - - expect(onError).toHaveBeenCalledWith( - test.containerError.message, - undefined - ); - } - }); - - it('should handle malformed error responses', async () => { - mockFetch.mockResolvedValue( - new Response('invalid json {', { status: 500 }) - ); - - await expect(client.testRequest('/api/test')).rejects.toThrow( - SandboxError - ); - }); - - it('should handle network failures', async () => { - mockFetch.mockRejectedValue(new Error('Network connection timeout')); - - await expect(client.testRequest('/api/test')).rejects.toThrow( - 'Network connection timeout' - ); - }); - - it('should handle server unavailable scenarios', async () => { - // Note: 503 triggers container retry loop (transient errors) - // For permanent server errors, use 500 - mockFetch.mockResolvedValue( - new Response('Internal Server Error', { status: 500 }) - ); - - await expect(client.testRequest('/api/test')).rejects.toThrow( - SandboxError - ); - - expect(onError).toHaveBeenCalledWith( - 'HTTP error! status: 500', - undefined - ); - }); - }); - - describe('streaming functionality', () => { - it('should handle streaming responses', async () => { - const streamData = 'data: {"type":"output","content":"stream data"}\n\n'; - const mockStream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(streamData)); - controller.close(); - } - }); - - mockFetch.mockResolvedValue( - new Response(mockStream, { - status: 200, - headers: { 'Content-Type': 'text/event-stream' } - }) - ); - - const stream = await client.testStreamRequest('/api/stream'); - - expect(stream).toBeInstanceOf(ReadableStream); - - const reader = stream.getReader(); - const { done, value } = await reader.read(); - const content = new TextDecoder().decode(value); - - expect(done).toBe(false); - expect(content).toContain('stream data'); - - reader.releaseLock(); - }); - - it('should handle streaming errors', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - error: 'Stream initialization failed', - code: 'STREAM_ERROR' - }), - { status: 400 } - ) - ); - - await expect(client.testStreamRequest('/api/bad-stream')).rejects.toThrow( - SandboxError - ); - }); - - it('should handle missing stream body', async () => { - mockFetch.mockResolvedValue( - new Response(null, { - status: 200, - headers: { 'Content-Type': 'text/event-stream' } - }) - ); - - await expect( - client.testStreamRequest('/api/empty-stream') - ).rejects.toThrow('No response body for streaming'); - }); - }); - - describe('defaultHeaders', () => { - it('should merge defaultHeaders into every request', async () => { - mockFetch.mockResolvedValue( - new Response(JSON.stringify({ success: true, data: 'ok' }), { - status: 200 - }) - ); - - const clientWithHeaders = new TestHttpClient({ - baseUrl: 'http://test.com', - port: 3000, - defaultHeaders: { - 'X-Sandbox-Id': 'sandbox-abc123', - 'X-Custom-Header': 'custom-value' - } - }); - - await clientWithHeaders.testRequest('/api/test'); - - const [, options] = mockFetch.mock.calls[0]; - expect(options.headers['X-Sandbox-Id']).toBe('sandbox-abc123'); - expect(options.headers['X-Custom-Header']).toBe('custom-value'); - }); - - it('should not add extra headers when defaultHeaders is not set', async () => { - mockFetch.mockResolvedValue( - new Response(JSON.stringify({ success: true, data: 'ok' }), { - status: 200 - }) - ); - - await client.testRequest('/api/test'); - - const [, options] = mockFetch.mock.calls[0]; - const headers = options.headers ?? {}; - expect(headers['X-Sandbox-Id']).toBeUndefined(); - }); - - it('should not allow defaultHeaders to override Content-Type on POST', async () => { - mockFetch.mockResolvedValue( - new Response(JSON.stringify({ success: true, id: '1' }), { - status: 201 - }) - ); - - const clientWithHeaders = new TestHttpClient({ - baseUrl: 'http://test.com', - port: 3000, - defaultHeaders: { - 'X-Sandbox-Id': 'sandbox-xyz', - 'Content-Type': 'text/plain' // should be overridden by POST logic - } - }); - - await clientWithHeaders.testRequest('/api/create', { name: 'test' }); - - const [, options] = mockFetch.mock.calls[0]; - // POST Content-Type should always be application/json regardless of defaultHeaders - expect(options.headers['Content-Type']).toBe('application/json'); - // Custom header should still be present - expect(options.headers['X-Sandbox-Id']).toBe('sandbox-xyz'); - }); - - it('should pass defaultHeaders to websocket transport requests', async () => { - const transport = new MockTransport('websocket'); - transport.fetchMock.mockResolvedValue( - new Response(JSON.stringify({ success: true, data: 'ok' }), { - status: 200 - }) - ); - - const clientWithHeaders = new TestHttpClient({ - transport, - defaultHeaders: { - 'X-Sandbox-Id': 'sandbox-ws', - 'X-Custom-Header': 'custom-value' - } - }); - - await clientWithHeaders.testRequest('/api/test'); - - const [, options] = transport.fetchMock.mock.calls[0]!; - expect(options?.headers).toEqual({ - 'X-Sandbox-Id': 'sandbox-ws', - 'X-Custom-Header': 'custom-value' - }); - }); - - it('should pass defaultHeaders to websocket streaming requests', async () => { - const transport = new MockTransport('websocket'); - transport.fetchStreamMock.mockResolvedValue( - new ReadableStream() - ); - - const clientWithHeaders = new TestHttpClient({ - transport, - defaultHeaders: { - 'X-Sandbox-Id': 'sandbox-stream' - } - }); - - await clientWithHeaders.testDoStreamFetch('/api/execute/stream', { - command: 'echo hello' - }); - - const [, body, method, headers] = - transport.fetchStreamMock.mock.calls[0]!; - expect(body).toEqual({ command: 'echo hello' }); - expect(method).toBe('POST'); - expect(headers).toEqual({ - 'X-Sandbox-Id': 'sandbox-stream', - 'Content-Type': 'application/json' - }); - }); - }); - - describe('stub integration', () => { - it('should use stub when provided instead of fetch', async () => { - const stubFetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ success: true, source: 'stub' }), { - status: 200 - }) - ); - - const stub = { containerFetch: stubFetch, fetch: vi.fn() }; - const stubClient = new TestHttpClient({ - baseUrl: 'http://test.com', - port: 3000, - stub - }); - - const result = - await stubClient.testRequest('/api/stub-test'); - - expect(result.success).toBe(true); - expect(result.source).toBe('stub'); - expect(stubFetch).toHaveBeenCalledWith( - 'http://localhost:3000/api/stub-test', - { method: 'GET' }, - 3000 - ); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('should handle stub errors', async () => { - const stubFetch = vi - .fn() - .mockRejectedValue(new Error('Stub connection failed')); - const stub = { containerFetch: stubFetch, fetch: vi.fn() }; - const stubClient = new TestHttpClient({ - baseUrl: 'http://test.com', - port: 3000, - stub - }); - - await expect(stubClient.testRequest('/api/stub-error')).rejects.toThrow( - 'Stub connection failed' - ); - }); - }); - - describe('edge cases and resilience', () => { - it('should handle responses with unusual status codes', async () => { - const unusualStatusTests = [ - { status: 201, shouldSucceed: true }, - { status: 202, shouldSucceed: true }, - { status: 409, shouldSucceed: false }, - { status: 422, shouldSucceed: false }, - { status: 429, shouldSucceed: false } - ]; - - for (const test of unusualStatusTests) { - mockFetch.mockResolvedValueOnce( - new Response( - test.shouldSucceed - ? JSON.stringify({ success: true, status: test.status }) - : JSON.stringify({ error: `Status ${test.status}` }), - { status: test.status } - ) - ); - - if (test.shouldSucceed) { - const result = await client.testRequest( - '/api/unusual-status' - ); - expect(result.success).toBe(true); - expect(result.status).toBe(test.status); - } else { - await expect( - client.testRequest('/api/unusual-status') - ).rejects.toThrow(); - } - } - }); - }); - - describe('container startup retry logic', () => { - // The client retries ONLY on 503 (Service Unavailable) status. - // 503 indicates transient errors like container starting up. - // 500 indicates permanent errors and should NOT be retried. - - it('should retry 503 errors (container starting)', async () => { - vi.useFakeTimers(); - - mockFetch - .mockResolvedValueOnce( - new Response('Container is starting. Please retry in a moment.', { - status: 503, - headers: { 'Retry-After': '3' } - }) - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ success: true, data: 'recovered' }), { - status: 200 - }) - ); - - const promise = client.testRequest('/api/test'); - await vi.advanceTimersByTimeAsync(5_000); - const result = await promise; - - expect(mockFetch).toHaveBeenCalledTimes(2); - expect(result.success).toBe(true); - expect(result.data).toBe('recovered'); - - vi.useRealTimers(); - }); - - it('should retry multiple 503 errors until success', async () => { - vi.useFakeTimers(); - - mockFetch - .mockResolvedValueOnce( - new Response('Container is starting.', { status: 503 }) - ) - .mockResolvedValueOnce( - new Response('Container is starting.', { status: 503 }) - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ success: true }), { status: 200 }) - ); - - const promise = client.testRequest('/api/test'); - await vi.advanceTimersByTimeAsync(15_000); - const result = await promise; - - expect(mockFetch).toHaveBeenCalledTimes(3); - expect(result.success).toBe(true); - - vi.useRealTimers(); - }); - - it('should NOT retry 500 errors (permanent failures)', async () => { - mockFetch.mockResolvedValueOnce( - new Response('Failed to start container: no such image', { - status: 500 - }) - ); - - await expect(client.testRequest('/api/test')).rejects.toThrow(); - expect(mockFetch).toHaveBeenCalledTimes(1); // No retry - }); - - it('should NOT retry 500 errors regardless of message content', async () => { - // Even if the message mentions transient-sounding errors, - // 500 status means permanent failure (DO decided it's not recoverable) - mockFetch.mockResolvedValueOnce( - new Response('Internal server error: container port not found', { - status: 500 - }) - ); - - await expect(client.testRequest('/api/test')).rejects.toThrow(); - expect(mockFetch).toHaveBeenCalledTimes(1); // No retry - }); - - it('should NOT retry 404 or other non-503 errors', async () => { - mockFetch.mockResolvedValueOnce( - new Response('Not found', { status: 404 }) - ); - - await expect(client.testRequest('/api/test')).rejects.toThrow(); - expect(mockFetch).toHaveBeenCalledTimes(1); - }); - - it('should respect MIN_TIME_FOR_RETRY_MS and stop retrying', async () => { - vi.useFakeTimers(); - - // Mock responses that would trigger retry - mockFetch.mockResolvedValue( - new Response('No container instance available', { status: 503 }) - ); - - const promise = client.testRequest('/api/test'); - - // Fast-forward past retry budget (120s) - await vi.advanceTimersByTimeAsync(125_000); - - // Should eventually give up and throw the 503 error - await expect(promise).rejects.toThrow(); - - vi.useRealTimers(); - }); - - it('should use exponential backoff: 3s, 6s, 12s, 24s, 30s', async () => { - vi.useFakeTimers(); - const delays: number[] = []; - let callCount = 0; - - mockFetch.mockImplementation(async () => { - delays.push(Date.now()); - callCount++; - // After 5 attempts, return success to avoid timeout - if (callCount >= 5) { - return new Response(JSON.stringify({ success: true }), { - status: 200 - }); - } - return new Response('No container instance available', { status: 503 }); - }); - - const promise = client.testRequest('/api/test'); - - // Advance time to allow all retries - await vi.advanceTimersByTimeAsync(80_000); - - await promise; - - // Check delays between attempts (approximately) - // Attempt 1 at 0ms, Attempt 2 at ~3000ms, Attempt 3 at ~9000ms, etc. - expect(delays.length).toBeGreaterThanOrEqual(4); - - vi.useRealTimers(); - }); - - it('should retry multiple 503 errors in sequence until success', async () => { - vi.useFakeTimers(); - - // Only 503 triggers retry - 500 does not - mockFetch - .mockResolvedValueOnce( - new Response('Container is starting.', { status: 503 }) - ) - .mockResolvedValueOnce( - new Response('Container is starting.', { status: 503 }) - ) - .mockResolvedValueOnce( - new Response('Container is starting.', { status: 503 }) - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ success: true }), { status: 200 }) - ); - - const promise = client.testRequest('/api/test'); - - // Advance time to allow all retries (3s + 6s + 12s = 21s) - await vi.advanceTimersByTimeAsync(25_000); - - const result = await promise; - - expect(mockFetch).toHaveBeenCalledTimes(4); - expect(result.success).toBe(true); - - vi.useRealTimers(); - }); - - it('should NOT retry on 500 regardless of error message', async () => { - // Previously this would retry based on message content - // Now we only retry on 503 status code - const errorMessages = [ - 'Container port not found', - 'The container is not listening', - 'ERROR: CONTAINER PORT NOT FOUND' - ]; - - for (const message of errorMessages) { - mockFetch.mockResolvedValueOnce(new Response(message, { status: 500 })); - - await expect(client.testRequest('/api/test')).rejects.toThrow(); - expect(mockFetch).toHaveBeenCalledTimes(1); - mockFetch.mockClear(); - } - }); - }); -}); diff --git a/packages/sandbox/tests/command-client.test.ts b/packages/sandbox/tests/command-client.test.ts deleted file mode 100644 index 62097baa8..000000000 --- a/packages/sandbox/tests/command-client.test.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ExecuteResponse } from '../src/clients'; -import { CommandClient } from '../src/clients/command-client'; -import { - CommandError, - CommandNotFoundError, - SandboxError -} from '../src/errors'; - -describe('CommandClient', () => { - let client: CommandClient; - let mockFetch: ReturnType; - let onCommandComplete: ReturnType; - let onError: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - - mockFetch = vi.fn(); - global.fetch = mockFetch as unknown as typeof fetch; - - onCommandComplete = vi.fn(); - onError = vi.fn(); - - client = new CommandClient({ - baseUrl: 'http://test.com', - port: 3000, - onCommandComplete, - onError - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('execute', () => { - it('should execute simple commands successfully', async () => { - const mockResponse: ExecuteResponse = { - success: true, - stdout: 'Hello World\n', - stderr: '', - exitCode: 0, - command: 'echo "Hello World"', - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.execute('echo "Hello World"', 'session-exec'); - - expect(result.success).toBe(true); - expect(result.stdout).toBe('Hello World\n'); - expect(result.stderr).toBe(''); - expect(result.exitCode).toBe(0); - expect(result.command).toBe('echo "Hello World"'); - expect(onCommandComplete).toHaveBeenCalledWith( - true, - 0, - 'Hello World\n', - '', - 'echo "Hello World"' - ); - }); - - it('should handle command failures with proper exit codes', async () => { - const mockResponse: ExecuteResponse = { - success: false, - stdout: '', - stderr: 'command not found: nonexistent-cmd\n', - exitCode: 127, - command: 'nonexistent-cmd', - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.execute('nonexistent-cmd', 'session-exec'); - - expect(result.success).toBe(false); - expect(result.exitCode).toBe(127); - expect(result.stderr).toContain('command not found'); - expect(result.stdout).toBe(''); - expect(onCommandComplete).toHaveBeenCalledWith( - false, - 127, - '', - 'command not found: nonexistent-cmd\n', - 'nonexistent-cmd' - ); - }); - - it('should handle container-level errors with proper error mapping', async () => { - const errorResponse = { - code: 'COMMAND_NOT_FOUND', - message: 'Command not found: invalidcmd', - context: { command: 'invalidcmd' }, - httpStatus: 404, - timestamp: new Date().toISOString() - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 404 }) - ); - - await expect(client.execute('invalidcmd', 'session-err')).rejects.toThrow( - CommandNotFoundError - ); - expect(onError).toHaveBeenCalledWith( - expect.stringContaining('Command not found'), - 'invalidcmd' - ); - }); - - it('should handle network failures gracefully', async () => { - mockFetch.mockRejectedValue(new Error('Network connection failed')); - - await expect(client.execute('ls', 'session-err')).rejects.toThrow( - 'Network connection failed' - ); - expect(onError).toHaveBeenCalledWith('Network connection failed', 'ls'); - }); - - it('should handle server errors with proper status codes', async () => { - const scenarios = [ - { status: 400, code: 'COMMAND_EXECUTION_ERROR', error: CommandError }, - { status: 500, code: 'EXECUTION_ERROR', error: SandboxError } - ]; - - for (const scenario of scenarios) { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - code: scenario.code, - message: 'Test error', - context: {}, - httpStatus: scenario.status, - timestamp: new Date().toISOString() - }), - { status: scenario.status } - ) - ); - await expect( - client.execute('test-command', 'session-err') - ).rejects.toThrow(scenario.error); - } - }); - - it('should handle commands with large output', async () => { - const largeOutput = 'line of output\n'.repeat(10000); - const mockResponse: ExecuteResponse = { - success: true, - stdout: largeOutput, - stderr: '', - exitCode: 0, - command: 'find / -type f', - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.execute('find / -type f', 'session-exec'); - - expect(result.success).toBe(true); - expect(result.stdout.length).toBeGreaterThan(100000); - expect(result.stdout.split('\n')).toHaveLength(10001); - expect(result.exitCode).toBe(0); - }); - - it('should handle concurrent command executions', async () => { - mockFetch.mockImplementation((url: string, options: RequestInit) => { - const body = JSON.parse(options.body as string); - const command = body.command; - return Promise.resolve( - new Response( - JSON.stringify({ - success: true, - stdout: `output for ${command}\n`, - stderr: '', - exitCode: 0, - command: command, - timestamp: '2023-01-01T00:00:00Z' - }), - { status: 200 } - ) - ); - }); - - const commands = ['echo 1', 'echo 2', 'echo 3', 'pwd', 'ls']; - const results = await Promise.all( - commands.map((cmd) => client.execute(cmd, 'session-concurrent')) - ); - - expect(results).toHaveLength(5); - results.forEach((result, index) => { - expect(result.success).toBe(true); - expect(result.stdout).toBe(`output for ${commands[index]}\n`); - expect(result.exitCode).toBe(0); - }); - expect(onCommandComplete).toHaveBeenCalledTimes(5); - }); - - it('should handle malformed server responses', async () => { - mockFetch.mockResolvedValue( - new Response('invalid json {', { - status: 200, - headers: { 'Content-Type': 'application/json' } - }) - ); - - await expect(client.execute('ls', 'session-err')).rejects.toThrow( - SandboxError - ); - expect(onError).toHaveBeenCalled(); - }); - - it('should handle empty command input', async () => { - const errorResponse = { - code: 'INVALID_COMMAND', - message: 'Invalid command: empty command provided', - context: {}, - httpStatus: 400, - timestamp: new Date().toISOString() - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 400 }) - ); - - await expect(client.execute('', 'session-err')).rejects.toThrow( - CommandError - ); - }); - - it('should handle streaming command execution', async () => { - const streamContent = [ - 'data: {"type":"start","command":"tail -f app.log","timestamp":"2023-01-01T00:00:00Z"}\n\n', - 'data: {"type":"stdout","data":"log line 1\\n","timestamp":"2023-01-01T00:00:01Z"}\n\n', - 'data: {"type":"stdout","data":"log line 2\\n","timestamp":"2023-01-01T00:00:02Z"}\n\n', - 'data: {"type":"complete","exitCode":0,"timestamp":"2023-01-01T00:00:03Z"}\n\n' - ].join(''); - - const mockStream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(streamContent)); - controller.close(); - } - }); - - mockFetch.mockResolvedValue( - new Response(mockStream, { - status: 200, - headers: { 'Content-Type': 'text/event-stream' } - }) - ); - - const stream = await client.executeStream( - 'tail -f app.log', - 'session-stream' - ); - expect(stream).toBeInstanceOf(ReadableStream); - - const reader = stream.getReader(); - const decoder = new TextDecoder(); - let content = ''; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - content += decoder.decode(value); - } - } finally { - reader.releaseLock(); - } - - expect(content).toContain('tail -f app.log'); - expect(content).toContain('log line 1'); - expect(content).toContain('log line 2'); - expect(content).toContain('"type":"complete"'); - }); - - it('should handle streaming errors gracefully', async () => { - const errorResponse = { - code: 'STREAM_START_ERROR', - message: 'Command failed to start streaming', - context: { command: 'invalid-stream-command' }, - httpStatus: 400, - timestamp: new Date().toISOString() - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 400 }) - ); - - await expect( - client.executeStream('invalid-stream-command', 'session-err') - ).rejects.toThrow(CommandError); - expect(onError).toHaveBeenCalledWith( - expect.stringContaining('Command failed to start streaming'), - 'invalid-stream-command' - ); - }); - - it('should handle streaming without response body', async () => { - mockFetch.mockResolvedValue( - new Response(null, { - status: 200, - headers: { 'Content-Type': 'text/event-stream' } - }) - ); - - await expect( - client.executeStream('test-command', 'session-err') - ).rejects.toThrow('No response body for streaming'); - }); - - it('should handle network failures during streaming setup', async () => { - mockFetch.mockRejectedValue( - new Error('Connection lost during streaming') - ); - - await expect( - client.executeStream('stream-command', 'session-err') - ).rejects.toThrow('Connection lost during streaming'); - expect(onError).toHaveBeenCalledWith( - 'Connection lost during streaming', - 'stream-command' - ); - }); - }); - - describe('callback integration', () => { - it('should work without any callbacks', async () => { - const clientWithoutCallbacks = new CommandClient({ - baseUrl: 'http://test.com', - port: 3000 - }); - - const mockResponse: ExecuteResponse = { - success: true, - stdout: 'test output\n', - stderr: '', - exitCode: 0, - command: 'echo test', - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await clientWithoutCallbacks.execute( - 'echo test', - 'session-nocb' - ); - - expect(result.success).toBe(true); - expect(result.stdout).toBe('test output\n'); - }); - - it('should handle errors gracefully without callbacks', async () => { - const clientWithoutCallbacks = new CommandClient({ - baseUrl: 'http://test.com', - port: 3000 - }); - - mockFetch.mockRejectedValue(new Error('Network failed')); - - await expect( - clientWithoutCallbacks.execute('test', 'session-nocb') - ).rejects.toThrow('Network failed'); - }); - - it('should call onCommandComplete for both success and failure', async () => { - const successResponse: ExecuteResponse = { - success: true, - stdout: 'success\n', - stderr: '', - exitCode: 0, - command: 'echo success', - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify(successResponse), { status: 200 }) - ); - - await client.execute('echo success', 'session-cb'); - expect(onCommandComplete).toHaveBeenLastCalledWith( - true, - 0, - 'success\n', - '', - 'echo success' - ); - - const failureResponse: ExecuteResponse = { - success: false, - stdout: '', - stderr: 'error\n', - exitCode: 1, - command: 'false', - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify(failureResponse), { status: 200 }) - ); - - await client.execute('false', 'session-cb'); - expect(onCommandComplete).toHaveBeenLastCalledWith( - false, - 1, - '', - 'error\n', - 'false' - ); - }); - }); - - describe('constructor options', () => { - it('should initialize with minimal options', async () => { - const minimalClient = new CommandClient(); - expect(minimalClient).toBeDefined(); - }); - - it('should initialize with full options', async () => { - const fullOptionsClient = new CommandClient({ - baseUrl: 'http://custom.com', - port: 8080, - onCommandComplete: vi.fn(), - onError: vi.fn() - }); - expect(fullOptionsClient).toBeDefined(); - }); - }); -}); diff --git a/packages/sandbox/tests/desktop-client.test.ts b/packages/sandbox/tests/desktop-client.test.ts deleted file mode 100644 index 60dc25740..000000000 --- a/packages/sandbox/tests/desktop-client.test.ts +++ /dev/null @@ -1,821 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { - CursorPositionResponse, - DesktopStartResponse, - DesktopStatusResponse, - DesktopStopResponse, - ScreenSizeResponse, - ScreenshotResponse -} from '../src/clients'; -import { DesktopClient } from '../src/clients/desktop-client'; -import { SandboxError } from '../src/errors'; - -describe('DesktopClient', () => { - let client: DesktopClient; - let mockFetch: ReturnType; - let onError: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - - mockFetch = vi.fn(); - global.fetch = mockFetch as unknown as typeof fetch; - - onError = vi.fn(); - - client = new DesktopClient({ - baseUrl: 'http://test.com', - port: 3000, - onError - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('lifecycle', () => { - it('should start the desktop environment with default options', async () => { - const mockResponse: DesktopStartResponse = { - success: true, - resolution: [1024, 768], - dpi: 96, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.start(); - - expect(result.success).toBe(true); - expect(result.resolution).toEqual([1024, 768]); - expect(result.dpi).toBe(96); - }); - - it('should start the desktop with custom resolution and DPI', async () => { - const mockResponse: DesktopStartResponse = { - success: true, - resolution: [1920, 1080], - dpi: 144, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.start({ - resolution: [1920, 1080], - dpi: 144 - }); - - expect(result.success).toBe(true); - expect(result.resolution).toEqual([1920, 1080]); - expect(result.dpi).toBe(144); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body.resolution).toEqual([1920, 1080]); - expect(body.dpi).toBe(144); - }); - - it('should omit undefined options from the start request body', async () => { - const mockResponse: DesktopStartResponse = { - success: true, - resolution: [1024, 768], - dpi: 96, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - await client.start(); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body).toEqual({}); - }); - - it('should call onError callback when start fails', async () => { - mockFetch.mockRejectedValue(new Error('Connection refused')); - - await expect(client.start()).rejects.toThrow('Connection refused'); - expect(onError).toHaveBeenCalledWith('Connection refused'); - }); - - it('should stop the desktop environment', async () => { - const mockResponse: DesktopStopResponse = { - success: true, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.stop(); - - expect(result.success).toBe(true); - }); - - it('should call onError callback when stop fails', async () => { - mockFetch.mockRejectedValue(new Error('Desktop not running')); - - await expect(client.stop()).rejects.toThrow('Desktop not running'); - expect(onError).toHaveBeenCalledWith('Desktop not running'); - }); - - it('should retrieve desktop status', async () => { - const mockResponse: DesktopStatusResponse = { - success: true, - status: 'active', - processes: { - xvfb: { running: true, pid: 100, uptime: 3600 }, - vnc: { running: true, pid: 101, uptime: 3600 }, - noVNC: { running: true, pid: 102, uptime: 3600 } - }, - resolution: [1024, 768], - dpi: 96, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.status(); - - expect(result.success).toBe(true); - expect(result.status).toBe('active'); - expect(result.processes.xvfb.running).toBe(true); - expect(result.resolution).toEqual([1024, 768]); - }); - - it('should handle inactive desktop status', async () => { - const mockResponse: DesktopStatusResponse = { - success: true, - status: 'inactive', - processes: { - xvfb: { running: false }, - vnc: { running: false }, - noVNC: { running: false } - }, - resolution: null, - dpi: null, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.status(); - - expect(result.status).toBe('inactive'); - expect(result.resolution).toBeNull(); - expect(result.dpi).toBeNull(); - }); - }); - - describe('screenshots', () => { - it('should capture a full-screen screenshot as base64', async () => { - const mockResponse: ScreenshotResponse = { - success: true, - data: 'iVBORw0KGgoAAAANS', - imageFormat: 'png', - width: 1024, - height: 768, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.screenshot(); - - expect(result.data).toBe('iVBORw0KGgoAAAANS'); - expect(result.imageFormat).toBe('png'); - expect(result.width).toBe(1024); - expect(result.height).toBe(768); - }); - - it('should capture a screenshot as bytes', async () => { - // Base64 for "hello" = "aGVsbG8=" - const mockResponse: ScreenshotResponse = { - success: true, - data: 'aGVsbG8=', - imageFormat: 'png', - width: 1024, - height: 768, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.screenshot({ format: 'bytes' }); - - expect(result.data).toBeInstanceOf(Uint8Array); - expect(result.imageFormat).toBe('png'); - expect(result.width).toBe(1024); - }); - - it('should pass screenshot options to the request', async () => { - const mockResponse: ScreenshotResponse = { - success: true, - data: 'abc', - imageFormat: 'jpeg', - width: 1024, - height: 768, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - await client.screenshot({ - imageFormat: 'jpeg', - quality: 80, - showCursor: true - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body.imageFormat).toBe('jpeg'); - expect(body.quality).toBe(80); - expect(body.showCursor).toBe(true); - }); - - it('should capture a region screenshot', async () => { - const mockResponse: ScreenshotResponse = { - success: true, - data: 'regionData', - imageFormat: 'png', - width: 200, - height: 100, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.screenshotRegion({ - x: 50, - y: 50, - width: 200, - height: 100 - }); - - expect(result.width).toBe(200); - expect(result.height).toBe(100); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body.region).toEqual({ - x: 50, - y: 50, - width: 200, - height: 100 - }); - }); - - it('should capture a region screenshot as bytes', async () => { - const mockResponse: ScreenshotResponse = { - success: true, - data: 'aGVsbG8=', - imageFormat: 'webp', - width: 300, - height: 200, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.screenshotRegion( - { x: 0, y: 0, width: 300, height: 200 }, - { format: 'bytes' } - ); - - expect(result.data).toBeInstanceOf(Uint8Array); - expect(result.imageFormat).toBe('webp'); - }); - }); - - describe('mouse operations', () => { - it('should perform a left click', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - timestamp: '2023-01-01T00:00:00Z' - }), - { status: 200 } - ) - ); - - await client.click(100, 200); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body).toEqual({ - x: 100, - y: 200, - button: 'left', - clickCount: 1 - }); - }); - - it('should perform a click with custom button', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - timestamp: '2023-01-01T00:00:00Z' - }), - { status: 200 } - ) - ); - - await client.click(50, 75, { button: 'right' }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body.button).toBe('right'); - }); - - it('should perform a double click', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - timestamp: '2023-01-01T00:00:00Z' - }), - { status: 200 } - ) - ); - - await client.doubleClick(100, 200); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body.clickCount).toBe(2); - expect(body.button).toBe('left'); - }); - - it('should perform a triple click', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - timestamp: '2023-01-01T00:00:00Z' - }), - { status: 200 } - ) - ); - - await client.tripleClick(100, 200); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body.clickCount).toBe(3); - }); - - it('should perform a right click', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - timestamp: '2023-01-01T00:00:00Z' - }), - { status: 200 } - ) - ); - - await client.rightClick(100, 200); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body.button).toBe('right'); - expect(body.clickCount).toBe(1); - }); - - it('should perform a middle click', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - timestamp: '2023-01-01T00:00:00Z' - }), - { status: 200 } - ) - ); - - await client.middleClick(100, 200); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body.button).toBe('middle'); - }); - - it('should press mouse button down at coordinates', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - timestamp: '2023-01-01T00:00:00Z' - }), - { status: 200 } - ) - ); - - await client.mouseDown(100, 200); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body.x).toBe(100); - expect(body.y).toBe(200); - expect(body.button).toBe('left'); - }); - - it('should press mouse button down at current position', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - timestamp: '2023-01-01T00:00:00Z' - }), - { status: 200 } - ) - ); - - await client.mouseDown(); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body.x).toBeUndefined(); - expect(body.y).toBeUndefined(); - }); - - it('should release mouse button', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - timestamp: '2023-01-01T00:00:00Z' - }), - { status: 200 } - ) - ); - - await client.mouseUp(100, 200, { button: 'right' }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body.x).toBe(100); - expect(body.y).toBe(200); - expect(body.button).toBe('right'); - }); - - it('should move the mouse cursor', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - timestamp: '2023-01-01T00:00:00Z' - }), - { status: 200 } - ) - ); - - await client.moveMouse(500, 300); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body).toEqual({ x: 500, y: 300 }); - }); - - it('should drag from start to end coordinates', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - timestamp: '2023-01-01T00:00:00Z' - }), - { status: 200 } - ) - ); - - await client.drag(10, 20, 300, 400); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body).toEqual({ - startX: 10, - startY: 20, - endX: 300, - endY: 400, - button: 'left' - }); - }); - - it('should scroll at coordinates', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - timestamp: '2023-01-01T00:00:00Z' - }), - { status: 200 } - ) - ); - - await client.scroll(100, 200, 'down', 5); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body).toEqual({ - x: 100, - y: 200, - direction: 'down', - amount: 5 - }); - }); - - it('should scroll with default amount', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - timestamp: '2023-01-01T00:00:00Z' - }), - { status: 200 } - ) - ); - - await client.scroll(100, 200, 'up'); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body.amount).toBe(3); - }); - - it('should get cursor position', async () => { - const mockResponse: CursorPositionResponse = { - success: true, - x: 512, - y: 384, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.getCursorPosition(); - - expect(result.x).toBe(512); - expect(result.y).toBe(384); - }); - }); - - describe('keyboard operations', () => { - it('should type text', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - timestamp: '2023-01-01T00:00:00Z' - }), - { status: 200 } - ) - ); - - await client.type('Hello, World!'); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body.text).toBe('Hello, World!'); - }); - - it('should type text with delay', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - timestamp: '2023-01-01T00:00:00Z' - }), - { status: 200 } - ) - ); - - await client.type('slow typing', { delayMs: 50 }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body.text).toBe('slow typing'); - expect(body.delayMs).toBe(50); - }); - - it('should press a key', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - timestamp: '2023-01-01T00:00:00Z' - }), - { status: 200 } - ) - ); - - await client.press('Enter'); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body.key).toBe('Enter'); - }); - - it('should press a key combination', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - timestamp: '2023-01-01T00:00:00Z' - }), - { status: 200 } - ) - ); - - await client.press('ctrl+c'); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body.key).toBe('ctrl+c'); - }); - - it('should hold a key down', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - timestamp: '2023-01-01T00:00:00Z' - }), - { status: 200 } - ) - ); - - await client.keyDown('Shift'); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body.key).toBe('Shift'); - }); - - it('should release a held key', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - timestamp: '2023-01-01T00:00:00Z' - }), - { status: 200 } - ) - ); - - await client.keyUp('Shift'); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body as string); - expect(body.key).toBe('Shift'); - }); - }); - - describe('screen information', () => { - it('should get screen size', async () => { - const mockResponse: ScreenSizeResponse = { - success: true, - width: 1920, - height: 1080, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.getScreenSize(); - - expect(result.width).toBe(1920); - expect(result.height).toBe(1080); - }); - - it('should get desktop process status', async () => { - const mockResponse = { - success: true, - running: true, - pid: 12345, - uptime: 7200, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.getProcessStatus('xvfb'); - - expect(result.running).toBe(true); - expect(result.pid).toBe(12345); - expect(result.uptime).toBe(7200); - - const url = mockFetch.mock.calls[0][0]; - expect(url).toContain('/api/desktop/process/xvfb/status'); - }); - - it('should encode process names in the URL', async () => { - const mockResponse = { - success: true, - running: false, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - await client.getProcessStatus('my process'); - - const url = mockFetch.mock.calls[0][0]; - expect(url).toContain('/api/desktop/process/my%20process/status'); - }); - }); - - describe('error handling', () => { - it('should handle container-level errors with proper error mapping', async () => { - const errorResponse = { - code: 'DESKTOP_NOT_STARTED', - message: 'Desktop environment is not running', - context: {}, - httpStatus: 409, - timestamp: new Date().toISOString() - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 409 }) - ); - - await expect(client.screenshot()).rejects.toThrow(SandboxError); - }); - - it('should handle network failures gracefully', async () => { - mockFetch.mockRejectedValue(new Error('Network connection failed')); - - await expect(client.click(0, 0)).rejects.toThrow( - 'Network connection failed' - ); - }); - - it('should handle malformed server responses', async () => { - mockFetch.mockResolvedValue( - new Response('invalid json {', { - status: 200, - headers: { 'Content-Type': 'application/json' } - }) - ); - - await expect(client.status()).rejects.toThrow(SandboxError); - }); - - it('should handle server errors', async () => { - const errorResponse = { - code: 'INTERNAL_ERROR', - message: 'Xvfb crashed unexpectedly', - context: {}, - httpStatus: 500, - timestamp: new Date().toISOString() - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 500 }) - ); - - await expect(client.start()).rejects.toThrow(SandboxError); - expect(onError).toHaveBeenCalled(); - }); - }); - - describe('constructor options', () => { - it('should initialize with minimal options', () => { - const minimalClient = new DesktopClient(); - expect(minimalClient).toBeDefined(); - }); - - it('should initialize with full options', () => { - const fullClient = new DesktopClient({ - baseUrl: 'http://custom.com', - port: 8080, - onError: vi.fn() - }); - expect(fullClient).toBeDefined(); - }); - - it('should work without onError callback', async () => { - const clientWithoutCallbacks = new DesktopClient({ - baseUrl: 'http://test.com', - port: 3000 - }); - - mockFetch.mockRejectedValue(new Error('Connection refused')); - - await expect(clientWithoutCallbacks.start()).rejects.toThrow( - 'Connection refused' - ); - }); - }); -}); diff --git a/packages/sandbox/tests/file-client.test.ts b/packages/sandbox/tests/file-client.test.ts deleted file mode 100644 index ff266cab4..000000000 --- a/packages/sandbox/tests/file-client.test.ts +++ /dev/null @@ -1,831 +0,0 @@ -import type { - DeleteFileResult, - FileExistsResult, - ListFilesResult, - MkdirResult, - MoveFileResult, - ReadFileResult, - RenameFileResult, - WriteFileResult -} from '@repo/shared'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { FileClient } from '../src/clients/file-client'; -import { - FileExistsError, - FileNotFoundError, - FileSystemError, - PermissionDeniedError, - SandboxError -} from '../src/errors'; - -describe('FileClient', () => { - let client: FileClient; - let mockFetch: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - - mockFetch = vi.fn(); - global.fetch = mockFetch as unknown as typeof fetch; - - client = new FileClient({ - baseUrl: 'http://test.com', - port: 3000 - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('mkdir', () => { - it('should create directories successfully', async () => { - const mockResponse: MkdirResult = { - success: true, - exitCode: 0, - path: '/app/new-directory', - recursive: false, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.mkdir('/app/new-directory', 'session-mkdir'); - - expect(result.success).toBe(true); - expect(result.path).toBe('/app/new-directory'); - expect(result.recursive).toBe(false); - expect(result.exitCode).toBe(0); - }); - - it('should create directories recursively', async () => { - const mockResponse: MkdirResult = { - success: true, - exitCode: 0, - path: '/app/deep/nested/directory', - recursive: true, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.mkdir( - '/app/deep/nested/directory', - 'session-mkdir', - { recursive: true } - ); - - expect(result.success).toBe(true); - expect(result.recursive).toBe(true); - expect(result.path).toBe('/app/deep/nested/directory'); - }); - - it('should handle permission denied errors', async () => { - const errorResponse = { - error: 'Permission denied: cannot create directory /root/secure', - code: 'PERMISSION_DENIED', - path: '/root/secure' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 403 }) - ); - - await expect( - client.mkdir('/root/secure', 'session-mkdir') - ).rejects.toThrow(PermissionDeniedError); - }); - - it('should handle directory already exists errors', async () => { - const errorResponse = { - error: 'Directory already exists: /app/existing', - code: 'FILE_EXISTS', - path: '/app/existing' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 409 }) - ); - - await expect( - client.mkdir('/app/existing', 'session-mkdir') - ).rejects.toThrow(FileExistsError); - }); - }); - - describe('writeFile', () => { - it('should write files successfully', async () => { - const mockResponse: WriteFileResult = { - success: true, - exitCode: 0, - path: '/app/config.json', - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const content = '{"setting": "value", "enabled": true}'; - const result = await client.writeFile( - '/app/config.json', - content, - 'session-write' - ); - - expect(result.success).toBe(true); - expect(result.path).toBe('/app/config.json'); - expect(result.exitCode).toBe(0); - }); - - it('should write files with different encodings', async () => { - const mockResponse: WriteFileResult = { - success: true, - exitCode: 0, - path: '/app/image.png', - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const binaryData = - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jYlkKQAAAABJRU5ErkJggg=='; - const result = await client.writeFile( - '/app/image.png', - binaryData, - 'session-write', - { encoding: 'base64' } - ); - - expect(result.success).toBe(true); - expect(result.path).toBe('/app/image.png'); - }); - - it('should handle write permission errors', async () => { - const errorResponse = { - error: 'Permission denied: cannot write to /system/readonly.txt', - code: 'PERMISSION_DENIED', - path: '/system/readonly.txt' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 403 }) - ); - - await expect( - client.writeFile('/system/readonly.txt', 'content', 'session-err') - ).rejects.toThrow(PermissionDeniedError); - }); - - it('should handle disk space errors', async () => { - const errorResponse = { - error: 'No space left on device', - code: 'NO_SPACE', - path: '/app/largefile.dat' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 507 }) - ); - - await expect( - client.writeFile( - '/app/largefile.dat', - 'x'.repeat(1000000), - 'session-err' - ) - ).rejects.toThrow(FileSystemError); - }); - }); - - describe('readFile', () => { - it('should read text files successfully with metadata', async () => { - const fileContent = `# Configuration File -server: - port: 3000 - host: localhost -database: - url: postgresql://localhost/app`; - - const mockResponse: ReadFileResult = { - success: true, - exitCode: 0, - path: '/app/config.yaml', - content: fileContent, - timestamp: '2023-01-01T00:00:00Z', - encoding: 'utf-8', - isBinary: false, - mimeType: 'text/yaml', - size: 100 - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.readFile('/app/config.yaml', 'session-read'); - - expect(result.success).toBe(true); - expect(result.path).toBe('/app/config.yaml'); - expect(result.content).toContain('port: 3000'); - expect(result.content).toContain('postgresql://localhost/app'); - expect(result.exitCode).toBe(0); - expect(result.encoding).toBe('utf-8'); - expect(result.isBinary).toBe(false); - expect(result.mimeType).toBe('text/yaml'); - expect(result.size).toBe(100); - }); - - it('should read binary files with base64 encoding and metadata', async () => { - const binaryContent = - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jYlkKQAAAABJRU5ErkJggg=='; - const mockResponse: ReadFileResult = { - success: true, - exitCode: 0, - path: '/app/logo.png', - content: binaryContent, - timestamp: '2023-01-01T00:00:00Z', - encoding: 'base64', - isBinary: true, - mimeType: 'image/png', - size: 95 - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.readFile('/app/logo.png', 'session-read', { - encoding: 'base64' - }); - - expect(result.success).toBe(true); - expect(result.content).toBe(binaryContent); - expect(result.content.startsWith('iVBORw0K')).toBe(true); - expect(result.encoding).toBe('base64'); - expect(result.isBinary).toBe(true); - expect(result.mimeType).toBe('image/png'); - expect(result.size).toBe(95); - }); - - it('should handle file not found errors', async () => { - const errorResponse = { - error: 'File not found: /app/missing.txt', - code: 'FILE_NOT_FOUND', - path: '/app/missing.txt' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 404 }) - ); - - await expect( - client.readFile('/app/missing.txt', 'session-read') - ).rejects.toThrow(FileNotFoundError); - }); - - it('should handle directory read attempts', async () => { - const errorResponse = { - error: 'Is a directory: /app/logs', - code: 'IS_DIRECTORY', - path: '/app/logs' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 400 }) - ); - - await expect( - client.readFile('/app/logs', 'session-read') - ).rejects.toThrow(FileSystemError); - }); - }); - - describe('readFileStream', () => { - it('should stream file successfully', async () => { - const mockStream = new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"metadata","mimeType":"text/plain","size":100,"isBinary":false,"encoding":"utf-8"}\n\n' - ) - ); - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"chunk","data":"Hello"}\n\n' - ) - ); - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"complete","bytesRead":5}\n\n' - ) - ); - controller.close(); - } - }); - - mockFetch.mockResolvedValue( - new Response(mockStream, { - status: 200, - headers: { 'Content-Type': 'text/event-stream' } - }) - ); - - const result = await client.readFileStream( - '/app/test.txt', - 'session-stream' - ); - - expect(result).toBeInstanceOf(ReadableStream); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('/api/read/stream'), - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ - path: '/app/test.txt', - sessionId: 'session-stream' - }) - }) - ); - }); - - it('should handle binary file streams', async () => { - const mockStream = new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"metadata","mimeType":"image/png","size":1024,"isBinary":true,"encoding":"base64"}\n\n' - ) - ); - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"chunk","data":"iVBORw0K"}\n\n' - ) - ); - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"complete","bytesRead":1024}\n\n' - ) - ); - controller.close(); - } - }); - - mockFetch.mockResolvedValue( - new Response(mockStream, { - status: 200, - headers: { 'Content-Type': 'text/event-stream' } - }) - ); - - const result = await client.readFileStream( - '/app/image.png', - 'session-stream' - ); - - expect(result).toBeInstanceOf(ReadableStream); - }); - - it('should handle stream errors', async () => { - const errorResponse = { - error: 'File not found: /app/missing.txt', - code: 'FILE_NOT_FOUND', - path: '/app/missing.txt' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 404 }) - ); - - await expect( - client.readFileStream('/app/missing.txt', 'session-stream') - ).rejects.toThrow(FileNotFoundError); - }); - - it('should handle network errors during streaming', async () => { - mockFetch.mockRejectedValue(new Error('Network timeout')); - - await expect( - client.readFileStream('/app/file.txt', 'session-stream') - ).rejects.toThrow('Network timeout'); - }); - }); - - describe('deleteFile', () => { - it('should delete files successfully', async () => { - const mockResponse: DeleteFileResult = { - success: true, - exitCode: 0, - path: '/app/temp.txt', - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.deleteFile('/app/temp.txt', 'session-delete'); - - expect(result.success).toBe(true); - expect(result.path).toBe('/app/temp.txt'); - expect(result.exitCode).toBe(0); - }); - - it('should handle delete non-existent file', async () => { - const errorResponse = { - error: 'File not found: /app/nonexistent.txt', - code: 'FILE_NOT_FOUND', - path: '/app/nonexistent.txt' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 404 }) - ); - - await expect( - client.deleteFile('/app/nonexistent.txt', 'session-delete') - ).rejects.toThrow(FileNotFoundError); - }); - - it('should handle delete permission errors', async () => { - const errorResponse = { - error: 'Permission denied: cannot delete /system/important.conf', - code: 'PERMISSION_DENIED', - path: '/system/important.conf' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 403 }) - ); - - await expect( - client.deleteFile('/system/important.conf', 'session-delete') - ).rejects.toThrow(PermissionDeniedError); - }); - }); - - describe('renameFile', () => { - it('should rename files successfully', async () => { - const mockResponse: RenameFileResult = { - success: true, - exitCode: 0, - path: '/app/old-name.txt', - newPath: '/app/new-name.txt', - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.renameFile( - '/app/old-name.txt', - '/app/new-name.txt', - 'session-rename' - ); - - expect(result.success).toBe(true); - expect(result.path).toBe('/app/old-name.txt'); - expect(result.newPath).toBe('/app/new-name.txt'); - expect(result.exitCode).toBe(0); - }); - - it('should handle rename to existing file', async () => { - const errorResponse = { - error: 'Target file already exists: /app/existing.txt', - code: 'FILE_EXISTS', - path: '/app/existing.txt' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 409 }) - ); - - await expect( - client.renameFile( - '/app/source.txt', - '/app/existing.txt', - 'session-rename' - ) - ).rejects.toThrow(FileExistsError); - }); - }); - - describe('moveFile', () => { - it('should move files successfully', async () => { - const mockResponse: MoveFileResult = { - success: true, - exitCode: 0, - path: '/src/document.pdf', - newPath: '/dest/document.pdf', - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.moveFile( - '/src/document.pdf', - '/dest/document.pdf', - 'session-move' - ); - - expect(result.success).toBe(true); - expect(result.path).toBe('/src/document.pdf'); - expect(result.newPath).toBe('/dest/document.pdf'); - expect(result.exitCode).toBe(0); - }); - - it('should handle move to non-existent directory', async () => { - const errorResponse = { - error: 'Destination directory does not exist: /nonexistent/', - code: 'NOT_DIRECTORY', - path: '/nonexistent/' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 404 }) - ); - - await expect( - client.moveFile( - '/app/file.txt', - '/nonexistent/file.txt', - 'session-move' - ) - ).rejects.toThrow(FileSystemError); - }); - }); - - describe('listFiles', () => { - const createMockFile = (overrides: Partial = {}) => ({ - name: 'test.txt', - absolutePath: '/workspace/test.txt', - relativePath: 'test.txt', - type: 'file' as const, - size: 1024, - modifiedAt: '2023-01-01T00:00:00Z', - mode: 'rw-r--r--', - permissions: { readable: true, writable: true, executable: false }, - ...overrides - }); - - it('should list files with correct structure', async () => { - const mockResponse: ListFilesResult = { - success: true, - path: '/workspace', - files: [ - createMockFile({ name: 'file.txt' }), - createMockFile({ name: 'dir', type: 'directory', mode: 'rwxr-xr-x' }) - ], - count: 2, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.listFiles('/workspace', 'session-list'); - - expect(result.success).toBe(true); - expect(result.count).toBe(2); - expect(result.files[0].name).toBe('file.txt'); - expect(result.files[1].name).toBe('dir'); - expect(result.files[1].type).toBe('directory'); - }); - - it('should pass options correctly', async () => { - const mockResponse: ListFilesResult = { - success: true, - path: '/workspace', - files: [createMockFile({ name: '.hidden', relativePath: '.hidden' })], - count: 1, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - await client.listFiles('/workspace', 'session-list', { - recursive: true, - includeHidden: true - }); - - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('/api/list-files'), - expect.objectContaining({ - body: JSON.stringify({ - path: '/workspace', - sessionId: 'session-list', - options: { recursive: true, includeHidden: true } - }) - }) - ); - }); - - it('should handle empty directories', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - success: true, - path: '/empty', - files: [], - count: 0, - timestamp: '2023-01-01T00:00:00Z' - }), - { status: 200 } - ) - ); - - const result = await client.listFiles('/empty', 'session-list'); - - expect(result.count).toBe(0); - expect(result.files).toHaveLength(0); - }); - - it('should handle error responses', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - error: 'Directory not found', - code: 'FILE_NOT_FOUND' - }), - { status: 404 } - ) - ); - - await expect( - client.listFiles('/nonexistent', 'session-list') - ).rejects.toThrow(FileNotFoundError); - }); - }); - - describe('exists', () => { - it('should return true when file exists', async () => { - const mockResponse: FileExistsResult = { - success: true, - path: '/workspace/test.txt', - exists: true, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.exists( - '/workspace/test.txt', - 'session-exists' - ); - - expect(result.success).toBe(true); - expect(result.exists).toBe(true); - expect(result.path).toBe('/workspace/test.txt'); - }); - - it('should return false when file does not exist', async () => { - const mockResponse: FileExistsResult = { - success: true, - path: '/workspace/nonexistent.txt', - exists: false, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.exists( - '/workspace/nonexistent.txt', - 'session-exists' - ); - - expect(result.success).toBe(true); - expect(result.exists).toBe(false); - }); - - it('should return true when directory exists', async () => { - const mockResponse: FileExistsResult = { - success: true, - path: '/workspace/some-dir', - exists: true, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.exists( - '/workspace/some-dir', - 'session-exists' - ); - - expect(result.success).toBe(true); - expect(result.exists).toBe(true); - }); - - it('should send correct request payload', async () => { - const mockResponse: FileExistsResult = { - success: true, - path: '/test/path', - exists: true, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - await client.exists('/test/path', 'session-test'); - - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('/api/exists'), - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ - path: '/test/path', - sessionId: 'session-test' - }) - }) - ); - }); - }); - - describe('error handling', () => { - it('should handle network failures gracefully', async () => { - mockFetch.mockRejectedValue(new Error('Network connection failed')); - - await expect( - client.readFile('/app/file.txt', 'session-read') - ).rejects.toThrow('Network connection failed'); - }); - - it('should handle malformed server responses', async () => { - mockFetch.mockResolvedValue( - new Response('invalid json {', { - status: 200, - headers: { 'Content-Type': 'application/json' } - }) - ); - - await expect( - client.writeFile('/app/file.txt', 'content', 'session-err') - ).rejects.toThrow(SandboxError); - }); - - it('should handle server errors with proper mapping', async () => { - const serverErrorScenarios = [ - { status: 400, code: 'FILESYSTEM_ERROR', error: FileSystemError }, - { - status: 403, - code: 'PERMISSION_DENIED', - error: PermissionDeniedError - }, - { status: 404, code: 'FILE_NOT_FOUND', error: FileNotFoundError }, - { status: 409, code: 'FILE_EXISTS', error: FileExistsError }, - { status: 500, code: 'INTERNAL_ERROR', error: SandboxError } - ]; - - for (const scenario of serverErrorScenarios) { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ - error: 'Test error', - code: scenario.code - }), - { status: scenario.status } - ) - ); - - await expect( - client.readFile('/app/test.txt', 'session-read') - ).rejects.toThrow(scenario.error); - } - }); - }); - - describe('constructor options', () => { - it('should initialize with minimal options', () => { - const minimalClient = new FileClient(); - expect(minimalClient).toBeDefined(); - }); - - it('should initialize with full options', () => { - const fullOptionsClient = new FileClient({ - baseUrl: 'http://custom.com', - port: 8080 - }); - expect(fullOptionsClient).toBeDefined(); - }); - }); -}); diff --git a/packages/sandbox/tests/file-stream.test.ts b/packages/sandbox/tests/file-stream.test.ts deleted file mode 100644 index 26973b3ac..000000000 --- a/packages/sandbox/tests/file-stream.test.ts +++ /dev/null @@ -1,310 +0,0 @@ -import type { FileMetadata } from '@repo/shared'; -import { describe, expect, it } from 'vitest'; -import { collectFile, streamFile } from '../src/file-stream'; - -describe('File Streaming Utilities', () => { - /** - * Helper to create a mock SSE stream for testing - */ - function createMockSSEStream(events: string[]): ReadableStream { - return new ReadableStream({ - start(controller) { - for (const event of events) { - controller.enqueue(new TextEncoder().encode(event)); - } - controller.close(); - } - }); - } - - describe('streamFile', () => { - it('should stream text file chunks and return metadata', async () => { - const stream = createMockSSEStream([ - 'data: {"type":"metadata","mimeType":"text/plain","size":11,"isBinary":false,"encoding":"utf-8"}\n\n', - 'data: {"type":"chunk","data":"Hello"}\n\n', - 'data: {"type":"chunk","data":" World"}\n\n', - 'data: {"type":"complete","bytesRead":11}\n\n' - ]); - - const chunks: string[] = []; - const generator = streamFile(stream); - let result = await generator.next(); - - // Collect chunks - while (!result.done) { - chunks.push(result.value as string); - result = await generator.next(); - } - - // Metadata is the return value - const metadata = result.value; - - expect(chunks).toEqual(['Hello', ' World']); - expect(metadata).toEqual({ - mimeType: 'text/plain', - size: 11, - isBinary: false, - encoding: 'utf-8' - }); - }); - - it('should stream binary file with base64 decoding', async () => { - // Base64 encoded "test" = "dGVzdA==" - const stream = createMockSSEStream([ - 'data: {"type":"metadata","mimeType":"image/png","size":4,"isBinary":true,"encoding":"base64"}\n\n', - 'data: {"type":"chunk","data":"dGVzdA=="}\n\n', - 'data: {"type":"complete","bytesRead":4}\n\n' - ]); - - const chunks: (string | Uint8Array)[] = []; - const generator = streamFile(stream); - let result = await generator.next(); - - // Collect chunks - while (!result.done) { - chunks.push(result.value); - result = await generator.next(); - } - - const metadata = result.value; - - // For binary files, chunks should be Uint8Array - expect(chunks.length).toBeGreaterThan(0); - expect(chunks[0]).toBeInstanceOf(Uint8Array); - - // Verify we can reconstruct the original data - const allBytes = new Uint8Array( - chunks.reduce((acc, chunk) => { - if (chunk instanceof Uint8Array) { - return acc + chunk.length; - } - return acc; - }, 0) - ); - - let offset = 0; - for (const chunk of chunks) { - if (chunk instanceof Uint8Array) { - allBytes.set(chunk, offset); - offset += chunk.length; - } - } - - const decoded = new TextDecoder().decode(allBytes); - expect(decoded).toBe('test'); - - expect(metadata?.isBinary).toBe(true); - expect(metadata?.encoding).toBe('base64'); - expect(metadata?.mimeType).toBe('image/png'); - }); - - it('should handle empty files', async () => { - const stream = createMockSSEStream([ - 'data: {"type":"metadata","mimeType":"text/plain","size":0,"isBinary":false,"encoding":"utf-8"}\n\n', - 'data: {"type":"complete","bytesRead":0}\n\n' - ]); - - const chunks: string[] = []; - const generator = streamFile(stream); - let result = await generator.next(); - - while (!result.done) { - chunks.push(result.value as string); - result = await generator.next(); - } - - const metadata = result.value; - - expect(chunks).toEqual([]); - expect(metadata?.size).toBe(0); - }); - - it('should handle error events', async () => { - const stream = createMockSSEStream([ - 'data: {"type":"metadata","mimeType":"text/plain","size":100,"isBinary":false,"encoding":"utf-8"}\n\n', - 'data: {"type":"chunk","data":"Hello"}\n\n', - 'data: {"type":"error","error":"Read error: Permission denied"}\n\n' - ]); - - const generator = streamFile(stream); - - try { - let result = await generator.next(); - while (!result.done) { - result = await generator.next(); - } - // Should have thrown - expect(true).toBe(false); - } catch (error) { - expect((error as Error).message).toContain( - 'Read error: Permission denied' - ); - } - }); - }); - - describe('collectFile', () => { - it('should collect entire text file into string', async () => { - const stream = createMockSSEStream([ - 'data: {"type":"metadata","mimeType":"text/plain","size":11,"isBinary":false,"encoding":"utf-8"}\n\n', - 'data: {"type":"chunk","data":"Hello"}\n\n', - 'data: {"type":"chunk","data":" World"}\n\n', - 'data: {"type":"complete","bytesRead":11}\n\n' - ]); - - const result = await collectFile(stream); - - expect(result.content).toBe('Hello World'); - expect(result.metadata).toEqual({ - mimeType: 'text/plain', - size: 11, - isBinary: false, - encoding: 'utf-8' - }); - }); - - it('should collect entire binary file into Uint8Array', async () => { - // Base64 encoded "test" = "dGVzdA==" - const stream = createMockSSEStream([ - 'data: {"type":"metadata","mimeType":"image/png","size":4,"isBinary":true,"encoding":"base64"}\n\n', - 'data: {"type":"chunk","data":"dGVzdA=="}\n\n', - 'data: {"type":"complete","bytesRead":4}\n\n' - ]); - - const result = await collectFile(stream); - - expect(result.content).toBeInstanceOf(Uint8Array); - expect(result.metadata.isBinary).toBe(true); - - // Decode to verify content - const decoded = new TextDecoder().decode(result.content as Uint8Array); - expect(decoded).toBe('test'); - }); - - it('should handle empty files', async () => { - const stream = createMockSSEStream([ - 'data: {"type":"metadata","mimeType":"text/plain","size":0,"isBinary":false,"encoding":"utf-8"}\n\n', - 'data: {"type":"complete","bytesRead":0}\n\n' - ]); - - const result = await collectFile(stream); - - expect(result.content).toBe(''); - expect(result.metadata.size).toBe(0); - }); - - it('should propagate errors from stream', async () => { - const stream = createMockSSEStream([ - 'data: {"type":"metadata","mimeType":"text/plain","size":100,"isBinary":false,"encoding":"utf-8"}\n\n', - 'data: {"type":"chunk","data":"Hello"}\n\n', - 'data: {"type":"error","error":"File not found"}\n\n' - ]); - - await expect(collectFile(stream)).rejects.toThrow('File not found'); - }); - - it('should handle large text files efficiently', async () => { - // Create a stream with many chunks - const chunkCount = 100; - const events = [ - 'data: {"type":"metadata","mimeType":"text/plain","size":500,"isBinary":false,"encoding":"utf-8"}\n\n' - ]; - - for (let i = 0; i < chunkCount; i++) { - events.push(`data: {"type":"chunk","data":"chunk${i}"}\n\n`); - } - - events.push('data: {"type":"complete","bytesRead":500}\n\n'); - - const stream = createMockSSEStream(events); - const result = await collectFile(stream); - - expect(typeof result.content).toBe('string'); - expect(result.content).toContain('chunk0'); - expect(result.content).toContain('chunk99'); - expect(result.metadata.encoding).toBe('utf-8'); - }); - - it('should handle large binary files efficiently', async () => { - // Create a stream with many base64 chunks - const chunkCount = 100; - const events = [ - 'data: {"type":"metadata","mimeType":"application/octet-stream","size":400,"isBinary":true,"encoding":"base64"}\n\n' - ]; - - for (let i = 0; i < chunkCount; i++) { - // Each "AAAA" base64 chunk decodes to 3 bytes (0x00, 0x00, 0x00) - events.push('data: {"type":"chunk","data":"AAAA"}\n\n'); - } - - events.push('data: {"type":"complete","bytesRead":400}\n\n'); - - const stream = createMockSSEStream(events); - const result = await collectFile(stream); - - expect(result.content).toBeInstanceOf(Uint8Array); - expect((result.content as Uint8Array).length).toBeGreaterThan(0); - expect(result.metadata.isBinary).toBe(true); - }); - }); - - describe('edge cases', () => { - it('should handle streams with no metadata event', async () => { - const stream = createMockSSEStream([ - 'data: {"type":"chunk","data":"Hello"}\n\n', - 'data: {"type":"complete","bytesRead":5}\n\n' - ]); - - // Without metadata, we don't know if it's binary or text - // The implementation should throw - const generator = streamFile(stream); - - try { - let result = await generator.next(); - while (!result.done) { - result = await generator.next(); - } - // Should have thrown - expect(true).toBe(false); - } catch (error) { - expect((error as Error).message).toContain( - 'Received chunk before metadata' - ); - } - }); - - it('should handle malformed JSON in SSE events', async () => { - const stream = createMockSSEStream([ - 'data: {"type":"metadata","mimeType":"text/plain","size":5,"isBinary":false,"encoding":"utf-8"}\n\n', - 'data: {invalid json\n\n', - 'data: {"type":"complete","bytesRead":5}\n\n' - ]); - - // Malformed JSON is logged but doesn't break the stream - // It should complete successfully but with no chunks - const result = await collectFile(stream); - expect(result.content).toBe(''); - }); - - it('should handle base64 padding correctly', async () => { - // Test various base64 strings with different padding - const testCases = [ - { input: 'YQ==', expected: 'a' }, // 1 byte, 2 padding - { input: 'YWI=', expected: 'ab' }, // 2 bytes, 1 padding - { input: 'YWJj', expected: 'abc' } // 3 bytes, no padding - ]; - - for (const testCase of testCases) { - const stream = createMockSSEStream([ - `data: {"type":"metadata","mimeType":"application/octet-stream","size":${testCase.expected.length},"isBinary":true,"encoding":"base64"}\n\n`, - `data: {"type":"chunk","data":"${testCase.input}"}\n\n`, - `data: {"type":"complete","bytesRead":${testCase.expected.length}}\n\n` - ]); - - const result = await collectFile(stream); - const decoded = new TextDecoder().decode(result.content as Uint8Array); - expect(decoded).toBe(testCase.expected); - } - }); - }); -}); diff --git a/packages/sandbox/tests/git-client.test.ts b/packages/sandbox/tests/git-client.test.ts deleted file mode 100644 index 7a21d64a2..000000000 --- a/packages/sandbox/tests/git-client.test.ts +++ /dev/null @@ -1,477 +0,0 @@ -import type { GitCheckoutResult } from '@repo/shared'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { GitClient } from '../src/clients/git-client'; -import { - GitAuthenticationError, - GitBranchNotFoundError, - GitCheckoutError, - GitCloneError, - GitError, - GitNetworkError, - GitRepositoryNotFoundError, - InvalidGitUrlError, - SandboxError -} from '../src/errors'; - -describe('GitClient', () => { - let client: GitClient; - let mockFetch: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - - mockFetch = vi.fn(); - global.fetch = mockFetch as unknown as typeof fetch; - - client = new GitClient({ - baseUrl: 'http://test.com', - port: 3000 - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('repository cloning', () => { - it('should clone public repositories successfully', async () => { - const mockResponse: GitCheckoutResult = { - success: true, - repoUrl: 'https://github.com/facebook/react.git', - branch: 'main', - targetDir: 'react', - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.checkout( - 'https://github.com/facebook/react.git', - 'test-session' - ); - - expect(result.success).toBe(true); - expect(result.repoUrl).toBe('https://github.com/facebook/react.git'); - expect(result.branch).toBe('main'); - }); - - it('should clone repositories to specific branches', async () => { - const mockResponse: GitCheckoutResult = { - success: true, - repoUrl: 'https://github.com/company/project.git', - branch: 'development', - targetDir: 'project', - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.checkout( - 'https://github.com/company/project.git', - 'test-session', - { branch: 'development' } - ); - - expect(result.success).toBe(true); - expect(result.branch).toBe('development'); - }); - - it('should clone repositories to custom directories', async () => { - const mockResponse: GitCheckoutResult = { - success: true, - repoUrl: 'https://github.com/user/my-app.git', - branch: 'main', - targetDir: 'workspace/my-app', - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.checkout( - 'https://github.com/user/my-app.git', - 'test-session', - { targetDir: 'workspace/my-app' } - ); - - expect(result.success).toBe(true); - expect(result.targetDir).toBe('workspace/my-app'); - }); - - it('should handle large repository clones with warnings', async () => { - const mockResponse: GitCheckoutResult = { - success: true, - repoUrl: 'https://github.com/torvalds/linux.git', - branch: 'master', - targetDir: 'linux', - timestamp: '2023-01-01T00:05:30Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.checkout( - 'https://github.com/torvalds/linux.git', - 'test-session' - ); - - expect(result.success).toBe(true); - }); - - it('should clone repositories with shallow depth option', async () => { - const mockResponse: GitCheckoutResult = { - success: true, - repoUrl: 'https://github.com/torvalds/linux.git', - branch: 'master', - targetDir: '/workspace/linux', - timestamp: '2023-01-01T00:00:30Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.checkout( - 'https://github.com/torvalds/linux.git', - 'test-session', - { depth: 1 } - ); - - expect(result.success).toBe(true); - - // Verify the request included depth - const fetchCall = mockFetch.mock.calls[0]; - const requestBody = JSON.parse(fetchCall[1].body as string); - expect(requestBody.depth).toBe(1); - }); - - it('should clone repositories with branch and depth options combined', async () => { - const mockResponse: GitCheckoutResult = { - success: true, - repoUrl: 'https://github.com/company/project.git', - branch: 'develop', - targetDir: '/workspace/project', - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.checkout( - 'https://github.com/company/project.git', - 'test-session', - { branch: 'develop', depth: 10 } - ); - - expect(result.success).toBe(true); - expect(result.branch).toBe('develop'); - - // Verify the request included both branch and depth - const fetchCall = mockFetch.mock.calls[0]; - const requestBody = JSON.parse(fetchCall[1].body as string); - expect(requestBody.branch).toBe('develop'); - expect(requestBody.depth).toBe(10); - }); - - it('should reject depth of zero', async () => { - await expect( - client.checkout('https://github.com/user/repo.git', 'test-session', { - depth: 0 - }) - ).rejects.toThrow('Invalid depth value: 0'); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('should reject negative depth values', async () => { - await expect( - client.checkout('https://github.com/user/repo.git', 'test-session', { - depth: -5 - }) - ).rejects.toThrow('Invalid depth value: -5'); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('should reject non-integer depth values', async () => { - await expect( - client.checkout('https://github.com/user/repo.git', 'test-session', { - depth: 1.5 - }) - ).rejects.toThrow('Invalid depth value: 1.5'); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('should handle SSH repository URLs', async () => { - const mockResponse: GitCheckoutResult = { - success: true, - repoUrl: 'git@github.com:company/private-project.git', - branch: 'main', - targetDir: 'private-project', - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.checkout( - 'git@github.com:company/private-project.git', - 'test-session' - ); - - expect(result.success).toBe(true); - expect(result.repoUrl).toBe('git@github.com:company/private-project.git'); - }); - - it('should handle concurrent repository operations', async () => { - mockFetch.mockImplementation((url: string, options: RequestInit) => { - const body = JSON.parse(options.body as string); - const repoName = body.repoUrl.split('/').pop().replace('.git', ''); - - return Promise.resolve( - new Response( - JSON.stringify({ - success: true, - stdout: `Cloning into '${repoName}'...\nDone.`, - repoUrl: body.repoUrl, - branch: body.branch || 'main', - targetDir: body.targetDir || repoName, - timestamp: new Date().toISOString() - }) - ) - ); - }); - - const operations = await Promise.all([ - client.checkout('https://github.com/facebook/react.git', 'session-1'), - client.checkout('https://github.com/microsoft/vscode.git', 'session-2'), - client.checkout('https://github.com/nodejs/node.git', 'session-3', { - branch: 'v18.x' - }) - ]); - - expect(operations).toHaveLength(3); - operations.forEach((result) => { - expect(result.success).toBe(true); - }); - expect(mockFetch).toHaveBeenCalledTimes(3); - }); - }); - - describe('repository error handling', () => { - it('should handle repository not found errors', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - error: 'Repository not found', - code: 'GIT_REPOSITORY_NOT_FOUND' - }), - { status: 404 } - ) - ); - - await expect( - client.checkout( - 'https://github.com/user/nonexistent.git', - 'test-session' - ) - ).rejects.toThrow(GitRepositoryNotFoundError); - }); - - it('should handle authentication failures', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - error: 'Authentication failed', - code: 'GIT_AUTH_FAILED' - }), - { status: 401 } - ) - ); - - await expect( - client.checkout( - 'https://github.com/company/private.git', - 'test-session' - ) - ).rejects.toThrow(GitAuthenticationError); - }); - - it('should handle branch not found errors', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - error: 'Branch not found', - code: 'GIT_BRANCH_NOT_FOUND' - }), - { status: 404 } - ) - ); - - await expect( - client.checkout('https://github.com/user/repo.git', 'test-session', { - branch: 'nonexistent-branch' - }) - ).rejects.toThrow(GitBranchNotFoundError); - }); - - it('should handle network errors', async () => { - // Note: 503 triggers container retry loop, so we use 500 for permanent errors - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ error: 'Network error', code: 'GIT_NETWORK_ERROR' }), - { status: 500 } - ) - ); - - await expect( - client.checkout('https://github.com/user/repo.git', 'test-session') - ).rejects.toThrow(GitNetworkError); - }); - - it('should handle clone failures', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ error: 'Clone failed', code: 'GIT_CLONE_FAILED' }), - { status: 507 } - ) - ); - - await expect( - client.checkout( - 'https://github.com/large/repository.git', - 'test-session' - ) - ).rejects.toThrow(GitCloneError); - }); - - it('should handle checkout failures', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ - error: 'Checkout failed', - code: 'GIT_CHECKOUT_FAILED' - }), - { status: 409 } - ) - ); - - await expect( - client.checkout('https://github.com/user/repo.git', 'test-session', { - branch: 'feature-branch' - }) - ).rejects.toThrow(GitCheckoutError); - }); - - it('should handle invalid Git URLs', async () => { - mockFetch.mockResolvedValue( - new Response( - JSON.stringify({ error: 'Invalid Git URL', code: 'INVALID_GIT_URL' }), - { status: 400 } - ) - ); - - await expect( - client.checkout('not-a-valid-url', 'test-session') - ).rejects.toThrow(InvalidGitUrlError); - }); - - it('should handle partial clone failures', async () => { - const mockResponse: GitCheckoutResult = { - success: false, - repoUrl: 'https://github.com/problematic/repo.git', - branch: 'main', - targetDir: 'repo', - timestamp: '2023-01-01T00:01:30Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.checkout( - 'https://github.com/problematic/repo.git', - 'test-session' - ); - - expect(result.success).toBe(false); - }); - }); - - describe('error handling edge cases', () => { - it('should handle network failures', async () => { - mockFetch.mockRejectedValue(new Error('Network connection failed')); - - await expect( - client.checkout('https://github.com/user/repo.git', 'test-session') - ).rejects.toThrow('Network connection failed'); - }); - - it('should handle malformed server responses', async () => { - mockFetch.mockResolvedValue( - new Response('invalid json {', { status: 200 }) - ); - - await expect( - client.checkout('https://github.com/user/repo.git', 'test-session') - ).rejects.toThrow(SandboxError); - }); - - it('should map server errors to client errors', async () => { - const serverErrorScenarios = [ - { status: 400, code: 'INVALID_GIT_URL', error: InvalidGitUrlError }, - { status: 401, code: 'GIT_AUTH_FAILED', error: GitAuthenticationError }, - { - status: 404, - code: 'GIT_REPOSITORY_NOT_FOUND', - error: GitRepositoryNotFoundError - }, - { - status: 404, - code: 'GIT_BRANCH_NOT_FOUND', - error: GitBranchNotFoundError - }, - { status: 500, code: 'GIT_OPERATION_FAILED', error: GitError }, - // Note: 503 triggers container retry loop, so we use 500 for permanent git errors - { status: 500, code: 'GIT_NETWORK_ERROR', error: GitNetworkError } - ]; - - for (const scenario of serverErrorScenarios) { - mockFetch.mockResolvedValueOnce( - new Response( - JSON.stringify({ error: 'Test error', code: scenario.code }), - { status: scenario.status } - ) - ); - - await expect( - client.checkout('https://github.com/test/repo.git', 'test-session') - ).rejects.toThrow(scenario.error); - } - }); - }); - - describe('constructor options', () => { - it('should initialize with minimal options', () => { - const minimalClient = new GitClient(); - expect(minimalClient).toBeInstanceOf(GitClient); - }); - - it('should initialize with full options', () => { - const fullOptionsClient = new GitClient({ - baseUrl: 'http://custom.com', - port: 8080 - }); - expect(fullOptionsClient).toBeInstanceOf(GitClient); - }); - }); -}); diff --git a/packages/sandbox/tests/port-client.test.ts b/packages/sandbox/tests/port-client.test.ts deleted file mode 100644 index e01a02bc7..000000000 --- a/packages/sandbox/tests/port-client.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -import type { - PortCloseResult, - PortExposeResult, - PortListResult -} from '@repo/shared'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { PortClient } from '../src/clients/port-client'; -import { - InvalidPortError, - PortAlreadyExposedError, - PortError, - PortInUseError, - PortNotExposedError, - SandboxError, - ServiceNotRespondingError -} from '../src/errors'; - -describe('PortClient', () => { - let client: PortClient; - let mockFetch: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - - mockFetch = vi.fn(); - global.fetch = mockFetch as unknown as typeof fetch; - - client = new PortClient({ - baseUrl: 'http://test.com', - port: 3000 - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('service exposure', () => { - it('should expose web services successfully', async () => { - const mockResponse: PortExposeResult = { - success: true, - port: 3001, - url: 'https://preview-abc123.workers.dev', - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.exposePort(3001, 'session-123', 'web-server'); - - expect(result.success).toBe(true); - expect(result.port).toBe(3001); - expect(result.url).toBe('https://preview-abc123.workers.dev'); - expect(result.url.startsWith('https://')).toBe(true); - }); - - it('should expose API services on different ports', async () => { - const mockResponse: PortExposeResult = { - success: true, - port: 8080, - url: 'https://api-def456.workers.dev', - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.exposePort(8080, 'session-456', 'api-server'); - - expect(result.success).toBe(true); - expect(result.port).toBe(8080); - expect(result.url).toContain('api-'); - }); - - it('should expose services without explicit names', async () => { - const mockResponse: PortExposeResult = { - success: true, - port: 5000, - url: 'https://service-ghi789.workers.dev', - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.exposePort(5000, 'session-789'); - - expect(result.success).toBe(true); - expect(result.port).toBe(5000); - expect(result.url).toBeDefined(); - }); - }); - - describe('service management', () => { - it('should list all exposed services', async () => { - const mockResponse: PortListResult = { - success: true, - ports: [ - { - port: 3000, - url: 'https://frontend-abc123.workers.dev', - status: 'active' - }, - { - port: 4000, - url: 'https://api-def456.workers.dev', - status: 'active' - }, - { - port: 5432, - url: 'https://db-ghi789.workers.dev', - status: 'active' - } - ], - timestamp: '2023-01-01T00:10:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.getExposedPorts('session-list'); - - expect(result.success).toBe(true); - expect(result.ports).toHaveLength(3); - - result.ports.forEach((service) => { - expect(service.url).toContain('.workers.dev'); - expect(service.port).toBeGreaterThan(0); - }); - }); - - it('should handle empty exposed ports list', async () => { - const mockResponse: PortListResult = { - success: true, - ports: [], - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.getExposedPorts('session-empty'); - - expect(result.success).toBe(true); - expect(result.ports).toHaveLength(0); - }); - - it('should unexpose services cleanly', async () => { - const mockResponse: PortCloseResult = { - success: true, - port: 3001, - timestamp: '2023-01-01T00:15:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.unexposePort(3001, 'session-unexpose'); - - expect(result.success).toBe(true); - expect(result.port).toBe(3001); - }); - }); - - describe('port validation and error handling', () => { - it('should handle port already exposed errors', async () => { - const errorResponse = { - error: 'Port already exposed: 3000', - code: 'PORT_ALREADY_EXPOSED' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 409 }) - ); - - await expect(client.exposePort(3000, 'session-err')).rejects.toThrow( - PortAlreadyExposedError - ); - }); - - it('should handle invalid port numbers', async () => { - const errorResponse = { - error: 'Invalid port number: 0', - code: 'INVALID_PORT_NUMBER' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 400 }) - ); - - await expect(client.exposePort(0, 'session-err')).rejects.toThrow( - InvalidPortError - ); - }); - - it('should handle port in use errors', async () => { - const errorResponse = { - error: 'Port in use: 3000 is already bound by another process', - code: 'PORT_IN_USE' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 409 }) - ); - - await expect(client.exposePort(3000, 'session-err')).rejects.toThrow( - PortInUseError - ); - }); - - it('should handle service not responding errors', async () => { - const errorResponse = { - error: 'Service not responding on port 8080', - code: 'SERVICE_NOT_RESPONDING' - }; - - // Note: 503 triggers container retry loop, so we use 500 for permanent errors - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 500 }) - ); - - await expect(client.exposePort(8080, 'session-err')).rejects.toThrow( - ServiceNotRespondingError - ); - }); - - it('should handle unexpose non-existent port', async () => { - const errorResponse = { - error: 'Port not exposed: 9999', - code: 'PORT_NOT_EXPOSED' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 404 }) - ); - - await expect(client.unexposePort(9999, 'session-err')).rejects.toThrow( - PortNotExposedError - ); - }); - - it('should handle port operation failures', async () => { - const errorResponse = { - error: 'Port operation failed: unable to setup proxy', - code: 'PORT_OPERATION_ERROR' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 500 }) - ); - - await expect(client.exposePort(3000, 'session-err')).rejects.toThrow( - PortError - ); - }); - }); - - describe('edge cases and resilience', () => { - it('should handle network failures gracefully', async () => { - mockFetch.mockRejectedValue(new Error('Network connection failed')); - - await expect(client.exposePort(3000, 'session-net')).rejects.toThrow( - 'Network connection failed' - ); - }); - - it('should handle malformed server responses', async () => { - mockFetch.mockResolvedValue( - new Response('invalid json {', { status: 200 }) - ); - - await expect(client.exposePort(3000, 'session-malform')).rejects.toThrow( - SandboxError - ); - }); - }); -}); diff --git a/packages/sandbox/tests/process-client.test.ts b/packages/sandbox/tests/process-client.test.ts deleted file mode 100644 index 1cb344da5..000000000 --- a/packages/sandbox/tests/process-client.test.ts +++ /dev/null @@ -1,649 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { - ProcessCleanupResult, - ProcessInfoResult, - ProcessKillResult, - ProcessListResult, - ProcessLogsResult, - ProcessStartResult -} from '../src/clients'; -import { ProcessClient } from '../src/clients/process-client'; -import { - CommandNotFoundError, - ProcessError, - ProcessNotFoundError, - SandboxError -} from '../src/errors'; - -describe('ProcessClient', () => { - let client: ProcessClient; - let mockFetch: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - - mockFetch = vi.fn(); - global.fetch = mockFetch as unknown as typeof fetch; - - client = new ProcessClient({ - baseUrl: 'http://test.com', - port: 3000 - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('process lifecycle management', () => { - it('should start background processes successfully', async () => { - const mockResponse: ProcessStartResult = { - success: true, - processId: 'proc-web-server', - command: 'npm run dev', - pid: 12345, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.startProcess('npm run dev', 'session-123'); - - expect(result.success).toBe(true); - expect(result.command).toBe('npm run dev'); - expect(result.pid).toBe(12345); - expect(result.processId).toBe('proc-web-server'); - }); - - it('should start processes with custom process IDs', async () => { - const mockResponse: ProcessStartResult = { - success: true, - processId: 'my-api-server', - command: 'python app.py', - pid: 54321, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.startProcess('python app.py', 'session-456', { - processId: 'my-api-server' - }); - - expect(result.success).toBe(true); - expect(result.processId).toBe('my-api-server'); - expect(result.command).toBe('python app.py'); - }); - - it('should handle long-running process startup', async () => { - const mockResponse: ProcessStartResult = { - success: true, - processId: 'proc-database', - command: 'docker run postgres', - pid: 99999, - timestamp: '2023-01-01T00:00:05Z' - }; - - mockFetch.mockImplementation( - () => - new Promise((resolve) => - setTimeout( - () => - resolve( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ), - 100 - ) - ) - ); - - const result = await client.startProcess( - 'docker run postgres', - 'session-789' - ); - - expect(result.success).toBe(true); - expect(result.processId).toBe('proc-database'); - expect(result.command).toBe('docker run postgres'); - }); - - it('should handle command not found errors', async () => { - const errorResponse = { - error: 'Command not found: invalidcmd', - code: 'COMMAND_NOT_FOUND' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 404 }) - ); - - await expect( - client.startProcess('invalidcmd', 'session-err') - ).rejects.toThrow(CommandNotFoundError); - }); - - it('should handle process startup failures', async () => { - const errorResponse = { - error: 'Process failed to start: permission denied', - code: 'PROCESS_ERROR' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 500 }) - ); - - await expect( - client.startProcess('sudo privileged-command', 'session-err') - ).rejects.toThrow(ProcessError); - }); - }); - - describe('process monitoring and inspection', () => { - it('should list running processes', async () => { - const mockResponse: ProcessListResult = { - success: true, - processes: [ - { - id: 'proc-web', - command: 'npm run dev', - status: 'running', - pid: 12345, - startTime: '2023-01-01T00:00:00Z' - }, - { - id: 'proc-api', - command: 'python api.py', - status: 'running', - pid: 12346, - startTime: '2023-01-01T00:00:30Z' - }, - { - id: 'proc-worker', - command: 'node worker.js', - status: 'completed', - pid: 12347, - exitCode: 0, - startTime: '2023-01-01T00:01:00Z', - endTime: '2023-01-01T00:05:00Z' - } - ], - timestamp: '2023-01-01T00:05:30Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.listProcesses(); - - expect(result.success).toBe(true); - expect(result.processes).toHaveLength(3); - - const runningProcesses = result.processes.filter( - (p) => p.status === 'running' - ); - expect(runningProcesses).toHaveLength(2); - expect(runningProcesses[0].pid).toBeDefined(); - expect(runningProcesses[1].pid).toBeDefined(); - - const completedProcess = result.processes.find( - (p) => p.status === 'completed' - ); - expect(completedProcess?.exitCode).toBe(0); - expect(completedProcess?.endTime).toBeDefined(); - }); - - it('should get specific process details', async () => { - const mockResponse: ProcessInfoResult = { - success: true, - process: { - id: 'proc-analytics', - command: 'python analytics.py --batch-size=1000', - status: 'running', - pid: 98765, - startTime: '2023-01-01T00:00:00Z' - }, - timestamp: '2023-01-01T00:10:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.getProcess('proc-analytics'); - - expect(result.success).toBe(true); - expect(result.process.id).toBe('proc-analytics'); - expect(result.process.command).toContain('--batch-size=1000'); - expect(result.process.status).toBe('running'); - expect(result.process.pid).toBe(98765); - }); - - it('should handle process not found error', async () => { - const errorResponse = { - error: 'Process not found: nonexistent-proc', - code: 'PROCESS_NOT_FOUND' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 404 }) - ); - - await expect(client.getProcess('nonexistent-proc')).rejects.toThrow( - ProcessNotFoundError - ); - }); - - it('should handle empty process list', async () => { - const mockResponse: ProcessListResult = { - success: true, - processes: [], - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.listProcesses(); - - expect(result.success).toBe(true); - expect(result.processes).toHaveLength(0); - }); - }); - - describe('process termination', () => { - it('should kill individual processes', async () => { - const mockResponse: ProcessKillResult = { - success: true, - processId: 'test-process', - timestamp: '2023-01-01T00:10:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.killProcess('proc-web'); - - expect(result.success).toBe(true); - }); - - it('should handle kill non-existent process', async () => { - const errorResponse = { - error: 'Process not found: already-dead-proc', - code: 'PROCESS_NOT_FOUND' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 404 }) - ); - - await expect(client.killProcess('already-dead-proc')).rejects.toThrow( - ProcessNotFoundError - ); - }); - - it('should kill all processes at once', async () => { - const mockResponse: ProcessCleanupResult = { - success: true, - cleanedCount: 0, - timestamp: '2023-01-01T00:15:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.killAllProcesses(); - - expect(result.success).toBe(true); - }); - - it('should handle kill all when no processes running', async () => { - const mockResponse: ProcessCleanupResult = { - success: true, - cleanedCount: 0, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.killAllProcesses(); - - expect(result.success).toBe(true); - }); - - it('should handle kill failures', async () => { - const errorResponse = { - error: 'Failed to kill process: process is protected', - code: 'PROCESS_ERROR' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 500 }) - ); - - await expect(client.killProcess('protected-proc')).rejects.toThrow( - ProcessError - ); - }); - }); - - describe('process log management', () => { - it('should retrieve process logs', async () => { - const mockResponse: ProcessLogsResult = { - success: true, - processId: 'proc-server', - stdout: `Server starting... -✓ Database connected -✓ Routes loaded -✓ Server listening on port 3000 -[INFO] Request: GET /api/health -[INFO] Response: 200 OK`, - stderr: `[WARN] Deprecated function used in auth.js:45 -[WARN] High memory usage: 85%`, - timestamp: '2023-01-01T00:10:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.getProcessLogs('proc-server'); - - expect(result.success).toBe(true); - expect(result.processId).toBe('proc-server'); - expect(result.stdout).toContain('Server listening on port 3000'); - expect(result.stdout).toContain('Request: GET /api/health'); - expect(result.stderr).toContain('Deprecated function used'); - expect(result.stderr).toContain('High memory usage'); - }); - - it('should handle logs for non-existent process', async () => { - const errorResponse = { - error: 'Process not found: missing-proc', - code: 'PROCESS_NOT_FOUND' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 404 }) - ); - - await expect(client.getProcessLogs('missing-proc')).rejects.toThrow( - ProcessNotFoundError - ); - }); - - it('should retrieve logs for processes with large output', async () => { - const largeStdout = 'Log entry with details\n'.repeat(10000); - const largeStderr = 'Error trace line\n'.repeat(1000); - - const mockResponse: ProcessLogsResult = { - success: true, - processId: 'proc-batch', - stdout: largeStdout, - stderr: largeStderr, - timestamp: '2023-01-01T00:30:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.getProcessLogs('proc-batch'); - - expect(result.success).toBe(true); - expect(result.stdout.length).toBeGreaterThan(200000); - expect(result.stderr.length).toBeGreaterThan(15000); - expect(result.stdout.split('\n')).toHaveLength(10001); - expect(result.stderr.split('\n')).toHaveLength(1001); - }); - - it('should handle empty process logs', async () => { - const mockResponse: ProcessLogsResult = { - success: true, - processId: 'proc-silent', - stdout: '', - stderr: '', - timestamp: '2023-01-01T00:05:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.getProcessLogs('proc-silent'); - - expect(result.success).toBe(true); - expect(result.stdout).toBe(''); - expect(result.stderr).toBe(''); - expect(result.processId).toBe('proc-silent'); - }); - }); - - describe('log streaming', () => { - it('should stream process logs in real-time', async () => { - const logData = `data: {"type":"stdout","data":"Server starting...\\n","timestamp":"2023-01-01T00:00:01Z"} - -data: {"type":"stdout","data":"Database connected\\n","timestamp":"2023-01-01T00:00:02Z"} - -data: {"type":"stderr","data":"Warning: deprecated API\\n","timestamp":"2023-01-01T00:00:03Z"} - -data: {"type":"stdout","data":"Server ready on port 3000\\n","timestamp":"2023-01-01T00:00:04Z"} - -`; - - const mockStream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(logData)); - controller.close(); - } - }); - - mockFetch.mockResolvedValue( - new Response(mockStream, { - status: 200, - headers: { 'Content-Type': 'text/event-stream' } - }) - ); - - const stream = await client.streamProcessLogs('proc-realtime'); - - expect(stream).toBeInstanceOf(ReadableStream); - - const reader = stream.getReader(); - const decoder = new TextDecoder(); - let content = ''; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - content += decoder.decode(value); - } - } finally { - reader.releaseLock(); - } - - expect(content).toContain('Server starting'); - expect(content).toContain('Database connected'); - expect(content).toContain('Warning: deprecated API'); - expect(content).toContain('Server ready on port 3000'); - }); - - it('should handle streaming for non-existent process', async () => { - const errorResponse = { - error: 'Process not found: stream-missing', - code: 'PROCESS_NOT_FOUND' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 404 }) - ); - - await expect(client.streamProcessLogs('stream-missing')).rejects.toThrow( - ProcessNotFoundError - ); - }); - - it('should handle streaming setup failures', async () => { - const errorResponse = { - error: 'Failed to setup log stream: process not outputting logs', - code: 'PROCESS_ERROR' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 500 }) - ); - - await expect(client.streamProcessLogs('proc-no-logs')).rejects.toThrow( - ProcessError - ); - }); - - it('should handle missing stream body', async () => { - mockFetch.mockResolvedValue( - new Response(null, { - status: 200, - headers: { 'Content-Type': 'text/event-stream' } - }) - ); - - await expect( - client.streamProcessLogs('proc-empty-stream') - ).rejects.toThrow('No response body for streaming'); - }); - }); - - describe('session integration', () => { - it('should include session in process operations', async () => { - const mockResponse: ProcessStartResult = { - success: true, - processId: 'proc-session-test', - command: 'echo session-test', - pid: 11111, - timestamp: '2023-01-01T00:00:00Z' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockResponse), { status: 200 }) - ); - - const result = await client.startProcess( - 'echo session-test', - 'session-test' - ); - - expect(result.success).toBe(true); - - const [url, options] = mockFetch.mock.calls[0]; - const requestBody = JSON.parse(options.body); - expect(requestBody.sessionId).toBe('session-test'); - expect(requestBody.command).toBe('echo session-test'); - }); - }); - - describe('concurrent process operations', () => { - it('should handle multiple simultaneous process operations', async () => { - mockFetch.mockImplementation((url: string, options: RequestInit) => { - if (url.includes('/start')) { - return Promise.resolve( - new Response( - JSON.stringify({ - success: true, - process: { - id: `proc-${Date.now()}`, - command: JSON.parse(options.body as string).command, - status: 'running', - pid: Math.floor(Math.random() * 90000) + 10000, - startTime: new Date().toISOString() - }, - timestamp: new Date().toISOString() - }) - ) - ); - } else if (url.includes('/list')) { - return Promise.resolve( - new Response( - JSON.stringify({ - success: true, - processes: [], - timestamp: new Date().toISOString() - }) - ) - ); - } else if (url.includes('/logs')) { - return Promise.resolve( - new Response( - JSON.stringify({ - success: true, - processId: url.split('/')[4], - stdout: 'log output', - stderr: '', - timestamp: new Date().toISOString() - }) - ) - ); - } - return Promise.resolve(new Response('{}', { status: 200 })); - }); - - const operations = await Promise.all([ - client.startProcess('npm run dev', 'session-concurrent'), - client.startProcess('python api.py', 'session-concurrent'), - client.listProcesses(), - client.getProcessLogs('existing-proc'), - client.startProcess('node worker.js', 'session-concurrent') - ]); - - expect(operations).toHaveLength(5); - operations.forEach((result) => { - expect(result.success).toBe(true); - }); - - expect(mockFetch).toHaveBeenCalledTimes(5); - }); - }); - - describe('error handling', () => { - it('should handle network failures gracefully', async () => { - mockFetch.mockRejectedValue(new Error('Network connection failed')); - - await expect(client.listProcesses()).rejects.toThrow( - 'Network connection failed' - ); - }); - - it('should handle malformed server responses', async () => { - mockFetch.mockResolvedValue( - new Response('invalid json {', { status: 200 }) - ); - - await expect( - client.startProcess('test-command', 'session-err') - ).rejects.toThrow(SandboxError); - }); - }); - - describe('constructor options', () => { - it('should initialize with minimal options', () => { - const minimalClient = new ProcessClient(); - expect(minimalClient).toBeDefined(); - }); - - it('should initialize with full options', () => { - const fullOptionsClient = new ProcessClient({ - baseUrl: 'http://custom.com', - port: 8080 - }); - expect(fullOptionsClient).toBeDefined(); - }); - }); -}); diff --git a/packages/sandbox/tests/sse-parser.test.ts b/packages/sandbox/tests/sse-parser.test.ts deleted file mode 100644 index 0a6b4f02a..000000000 --- a/packages/sandbox/tests/sse-parser.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - asyncIterableToSSEStream, - parseSSEStream, - responseToAsyncIterable -} from '../src/sse-parser'; - -function createMockSSEStream(events: string[]): ReadableStream { - return new ReadableStream({ - start(controller) { - const encoder = new TextEncoder(); - for (const event of events) { - controller.enqueue(encoder.encode(event)); - } - controller.close(); - } - }); -} - -describe('SSE Parser', () => { - let consoleErrorSpy: ReturnType; - - beforeEach(() => { - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(() => { - consoleErrorSpy.mockRestore(); - }); - - describe('parseSSEStream', () => { - it('should parse valid SSE events', async () => { - const stream = createMockSSEStream([ - 'data: {"type":"start","command":"echo test"}\n\n', - 'data: {"type":"stdout","data":"test\\n"}\n\n', - 'data: {"type":"complete","exitCode":0}\n\n' - ]); - - const events: any[] = []; - for await (const event of parseSSEStream(stream)) { - events.push(event); - } - - expect(events).toHaveLength(3); - expect(events[0]).toEqual({ type: 'start', command: 'echo test' }); - expect(events[1]).toEqual({ type: 'stdout', data: 'test\n' }); - expect(events[2]).toEqual({ type: 'complete', exitCode: 0 }); - }); - - it('should handle empty data lines', async () => { - const stream = createMockSSEStream([ - 'data: \n\n', - 'data: {"type":"stdout","data":"valid"}\n\n' - ]); - - const events: any[] = []; - for await (const event of parseSSEStream(stream)) { - events.push(event); - } - - expect(events).toHaveLength(1); - expect(events[0]).toEqual({ type: 'stdout', data: 'valid' }); - }); - - it('should skip [DONE] markers', async () => { - const stream = createMockSSEStream([ - 'data: {"type":"start"}\n\n', - 'data: [DONE]\n\n', - 'data: {"type":"complete"}\n\n' - ]); - - const events: any[] = []; - for await (const event of parseSSEStream(stream)) { - events.push(event); - } - - expect(events).toHaveLength(2); - expect(events[0]).toEqual({ type: 'start' }); - expect(events[1]).toEqual({ type: 'complete' }); - }); - - it('should handle malformed JSON gracefully', async () => { - const stream = createMockSSEStream([ - 'data: invalid json\n\n', - 'data: {"type":"stdout","data":"valid"}\n\n', - 'data: {incomplete\n\n' - ]); - - const events: any[] = []; - for await (const event of parseSSEStream(stream)) { - events.push(event); - } - - expect(events).toHaveLength(1); - expect(events[0]).toEqual({ type: 'stdout', data: 'valid' }); - }); - - it('should handle empty lines and comments', async () => { - const stream = createMockSSEStream([ - '\n', - ' \n', - ': this is a comment\n', - 'data: {"type":"test"}\n\n', - '\n' - ]); - - const events: any[] = []; - for await (const event of parseSSEStream(stream)) { - events.push(event); - } - - expect(events).toHaveLength(1); - expect(events[0]).toEqual({ type: 'test' }); - }); - - it('should handle chunked data properly', async () => { - // Simulate chunked delivery where data arrives in parts - const stream = new ReadableStream({ - start(controller) { - const encoder = new TextEncoder(); - // Send partial data - controller.enqueue(encoder.encode('data: {"typ')); - controller.enqueue(encoder.encode('e":"start"}\n\n')); - controller.enqueue(encoder.encode('data: {"type":"end"}\n\n')); - controller.close(); - } - }); - - const events: any[] = []; - for await (const event of parseSSEStream(stream)) { - events.push(event); - } - - expect(events).toHaveLength(2); - expect(events[0]).toEqual({ type: 'start' }); - expect(events[1]).toEqual({ type: 'end' }); - }); - - it('should handle remaining buffer data after stream ends', async () => { - const stream = createMockSSEStream(['data: {"type":"complete"}']); - - const events: any[] = []; - for await (const event of parseSSEStream(stream)) { - events.push(event); - } - - expect(events).toHaveLength(1); - expect(events[0]).toEqual({ type: 'complete' }); - }); - - it('should support cancellation via AbortSignal', async () => { - const controller = new AbortController(); - const stream = createMockSSEStream(['data: {"type":"start"}\n\n']); - controller.abort(); - - await expect(async () => { - for await (const _event of parseSSEStream(stream, controller.signal)) { - // No-op - } - }).rejects.toThrow('Operation was aborted'); - }); - - it('should abort while blocked on reader.read()', async () => { - const controller = new AbortController(); - let cancelResolve: (() => void) | undefined; - const cancelCalled = new Promise((resolve) => { - cancelResolve = resolve; - }); - - const stream = new ReadableStream({ - pull() { - return new Promise(() => { - // Keep the stream idle until canceled. - }); - }, - cancel() { - cancelResolve?.(); - } - }); - - const consumePromise = (async () => { - for await (const _event of parseSSEStream(stream, controller.signal)) { - // No-op - } - })(); - - await Promise.resolve(); - controller.abort(); - - await expect(consumePromise).rejects.toThrow('Operation was aborted'); - await cancelCalled; - }); - - it('should remove abort listener after completion', async () => { - const controller = new AbortController(); - const addEventListenerSpy = vi.spyOn( - controller.signal, - 'addEventListener' - ); - const removeEventListenerSpy = vi.spyOn( - controller.signal, - 'removeEventListener' - ); - - const stream = createMockSSEStream(['data: {"type":"done"}\n\n']); - const events: unknown[] = []; - - for await (const event of parseSSEStream(stream, controller.signal)) { - events.push(event); - } - - expect(events).toEqual([{ type: 'done' }]); - expect(addEventListenerSpy).toHaveBeenCalledWith( - 'abort', - expect.any(Function) - ); - - const abortHandler = addEventListenerSpy.mock.calls.find( - ([eventName]) => eventName === 'abort' - )?.[1]; - expect(abortHandler).toBeDefined(); - expect(removeEventListenerSpy).toHaveBeenCalledWith( - 'abort', - abortHandler - ); - }); - - it('should handle non-data SSE lines', async () => { - const stream = createMockSSEStream([ - 'event: message\n', - 'id: 123\n', - 'retry: 3000\n', - 'data: {"type":"test"}\n\n' - ]); - - const events: any[] = []; - for await (const event of parseSSEStream(stream)) { - events.push(event); - } - - expect(events).toHaveLength(1); - expect(events[0]).toEqual({ type: 'test' }); - }); - }); - - describe('responseToAsyncIterable', () => { - it('should convert Response with SSE stream to AsyncIterable', async () => { - const mockBody = createMockSSEStream([ - 'data: {"type":"start"}\n\n', - 'data: {"type":"end"}\n\n' - ]); - - const mockResponse = { - ok: true, - body: mockBody - } as Response; - - const events: any[] = []; - for await (const event of responseToAsyncIterable(mockResponse)) { - events.push(event); - } - - expect(events).toHaveLength(2); - expect(events[0]).toEqual({ type: 'start' }); - expect(events[1]).toEqual({ type: 'end' }); - }); - - it('should throw error for non-ok response', async () => { - const mockResponse = { - ok: false, - status: 500, - statusText: 'Internal Server Error' - } as Response; - - await expect(async () => { - for await (const event of responseToAsyncIterable(mockResponse)) { - // Should not reach here - } - }).rejects.toThrow('Response not ok: 500 Internal Server Error'); - }); - - it('should throw error for response without body', async () => { - const mockResponse = { - ok: true, - body: null - } as Response; - - await expect(async () => { - for await (const event of responseToAsyncIterable(mockResponse)) { - // Should not reach here - } - }).rejects.toThrow('No response body'); - }); - }); - - describe('asyncIterableToSSEStream', () => { - it('should convert AsyncIterable to SSE-formatted ReadableStream', async () => { - async function* mockEvents() { - yield { type: 'start', command: 'test' }; - yield { type: 'stdout', data: 'output' }; - yield { type: 'complete', exitCode: 0 }; - } - - const stream = asyncIterableToSSEStream(mockEvents()); - const reader = stream.getReader(); - const decoder = new TextDecoder(); - - const chunks: string[] = []; - let done = false; - - while (!done) { - const { value, done: readerDone } = await reader.read(); - done = readerDone; - if (value) { - chunks.push(decoder.decode(value)); - } - } - - const fullOutput = chunks.join(''); - expect(fullOutput).toBe( - 'data: {"type":"start","command":"test"}\n\n' + - 'data: {"type":"stdout","data":"output"}\n\n' + - 'data: {"type":"complete","exitCode":0}\n\n' + - 'data: [DONE]\n\n' - ); - }); - - it('should use custom serializer when provided', async () => { - async function* mockEvents() { - yield { name: 'test', value: 123 }; - } - - const stream = asyncIterableToSSEStream(mockEvents(), { - serialize: (event) => `custom:${event.name}=${event.value}` - }); - - const reader = stream.getReader(); - const decoder = new TextDecoder(); - const { value } = await reader.read(); - - expect(decoder.decode(value!)).toBe('data: custom:test=123\n\n'); - }); - - it('should handle errors in async iterable', async () => { - async function* mockEvents() { - yield { type: 'start' }; - throw new Error('Async iterable error'); - } - - const stream = asyncIterableToSSEStream(mockEvents()); - const reader = stream.getReader(); - - await reader.read(); - await expect(reader.read()).rejects.toThrow('Async iterable error'); - }); - }); -}); diff --git a/packages/sandbox/tests/transport.test.ts b/packages/sandbox/tests/transport.test.ts deleted file mode 100644 index b21c6e045..000000000 --- a/packages/sandbox/tests/transport.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - createTransport, - HttpTransport, - WebSocketTransport -} 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 response = await transport.fetch('/api/test', { method: 'GET' }); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(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 response = await transport.fetch('/api/execute', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ command: 'echo hello' }) - }); - - expect(response.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 response = await transport.fetch('/api/missing', { method: 'GET' }); - - expect(response.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.fetchStream('/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, fetch: vi.fn() }, - port: 3000 - }); - - await transport.fetch('/api/test', { method: 'GET' }); - - 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.isConnected()).toBe(false); - }); - - it('should throw error when wsUrl is missing', () => { - // When wsUrl is missing, WebSocket transport throws an error - expect(() => { - createTransport({ - mode: 'websocket' - // wsUrl missing - should throw - }); - }).toThrow('wsUrl is required for WebSocket transport'); - }); - }); - - describe('createTransport factory', () => { - it('should create HTTP transport with minimal options', () => { - const transport = createTransport({ - mode: 'http', - baseUrl: 'http://localhost:3000' - }); - - expect(transport).toBeInstanceOf(HttpTransport); - 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(WebSocketTransport); - expect(transport.getMode()).toBe('websocket'); - }); - }); -}); diff --git a/packages/sandbox/tests/utility-client.test.ts b/packages/sandbox/tests/utility-client.test.ts deleted file mode 100644 index 20f6196f1..000000000 --- a/packages/sandbox/tests/utility-client.test.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { - CommandsResponse, - PingResponse, - VersionResponse -} from '../src/clients'; -import { UtilityClient } from '../src/clients/utility-client'; -import { SandboxError } from '../src/errors'; - -// Mock data factory for creating test responses -const mockPingResponse = ( - overrides: Partial = {} -): PingResponse => ({ - success: true, - message: 'pong', - uptime: 12345, - timestamp: '2023-01-01T00:00:00Z', - ...overrides -}); - -const mockCommandsResponse = ( - commands: string[], - overrides: Partial = {} -): CommandsResponse => ({ - success: true, - availableCommands: commands, - count: commands.length, - timestamp: '2023-01-01T00:00:00Z', - ...overrides -}); - -const mockVersionResponse = ( - version: string = '0.4.5', - overrides: Partial = {} -): VersionResponse => ({ - success: true, - version, - timestamp: '2023-01-01T00:00:00Z', - ...overrides -}); - -describe('UtilityClient', () => { - let client: UtilityClient; - let mockFetch: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - - mockFetch = vi.fn(); - global.fetch = mockFetch as unknown as typeof fetch; - - client = new UtilityClient({ - baseUrl: 'http://test.com', - port: 3000 - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('health checking', () => { - it('should check sandbox health successfully', async () => { - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockPingResponse()), { status: 200 }) - ); - - const result = await client.ping(); - - expect(result).toBe('pong'); - }); - - it('should handle different health messages', async () => { - const messages = ['pong', 'alive', 'ok']; - - for (const message of messages) { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify(mockPingResponse({ message })), { - status: 200 - }) - ); - - const result = await client.ping(); - expect(result).toBe(message); - } - }); - - it('should handle concurrent health checks', async () => { - mockFetch.mockImplementation(() => - Promise.resolve(new Response(JSON.stringify(mockPingResponse()))) - ); - - const healthChecks = await Promise.all([ - client.ping(), - client.ping(), - client.ping() - ]); - - expect(healthChecks).toHaveLength(3); - healthChecks.forEach((result) => { - expect(result).toBe('pong'); - }); - - expect(mockFetch).toHaveBeenCalledTimes(3); - }); - - it('should detect unhealthy sandbox conditions', async () => { - const errorResponse = { - error: 'Service Unavailable', - code: 'HEALTH_CHECK_FAILED' - }; - - // Use 500 for permanent errors (503 triggers retry) - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 500 }) - ); - - await expect(client.ping()).rejects.toThrow(); - }); - - it('should handle network failures during health checks', async () => { - mockFetch.mockRejectedValue(new Error('Network connection failed')); - - await expect(client.ping()).rejects.toThrow('Network connection failed'); - }); - }); - - describe('command discovery', () => { - it('should discover available system commands', async () => { - const systemCommands = ['ls', 'cat', 'echo', 'grep', 'find']; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockCommandsResponse(systemCommands)), { - status: 200 - }) - ); - - const result = await client.getCommands(); - - expect(result).toEqual(systemCommands); - expect(result).toContain('ls'); - expect(result).toContain('grep'); - expect(result).toHaveLength(systemCommands.length); - }); - - it('should handle minimal command environments', async () => { - const minimalCommands = ['sh', 'echo', 'cat']; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockCommandsResponse(minimalCommands)), { - status: 200 - }) - ); - - const result = await client.getCommands(); - - expect(result).toEqual(minimalCommands); - expect(result).toHaveLength(3); - }); - - it('should handle large command environments', async () => { - const richCommands = Array.from({ length: 150 }, (_, i) => `cmd_${i}`); - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockCommandsResponse(richCommands)), { - status: 200 - }) - ); - - const result = await client.getCommands(); - - expect(result).toEqual(richCommands); - expect(result).toHaveLength(150); - }); - - it('should handle empty command environments', async () => { - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockCommandsResponse([])), { status: 200 }) - ); - - const result = await client.getCommands(); - - expect(result).toEqual([]); - expect(result).toHaveLength(0); - }); - - it('should handle command discovery failures', async () => { - const errorResponse = { - error: 'Access denied to command list', - code: 'PERMISSION_DENIED' - }; - - mockFetch.mockResolvedValue( - new Response(JSON.stringify(errorResponse), { status: 403 }) - ); - - await expect(client.getCommands()).rejects.toThrow(); - }); - }); - - describe('error handling and resilience', () => { - it('should handle malformed server responses gracefully', async () => { - mockFetch.mockResolvedValue( - new Response('invalid json {', { status: 200 }) - ); - - await expect(client.ping()).rejects.toThrow(SandboxError); - }); - - it('should handle network timeouts and connectivity issues', async () => { - const networkError = new Error('Network timeout'); - mockFetch.mockRejectedValue(networkError); - - await expect(client.ping()).rejects.toThrow(networkError.message); - }); - - it('should handle partial service failures', async () => { - // First call (ping) succeeds - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify(mockPingResponse()), { status: 200 }) - ); - - // Second call (getCommands) fails with permanent error - // Use 500 for permanent errors (503 triggers retry) - const errorResponse = { - error: 'Command enumeration service unavailable', - code: 'SERVICE_UNAVAILABLE' - }; - - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify(errorResponse), { status: 500 }) - ); - - const pingResult = await client.ping(); - expect(pingResult).toBe('pong'); - - await expect(client.getCommands()).rejects.toThrow(); - }); - - it('should handle concurrent operations with mixed success', async () => { - let callCount = 0; - mockFetch.mockImplementation(() => { - callCount++; - if (callCount % 2 === 0) { - return Promise.reject(new Error('Intermittent failure')); - } else { - return Promise.resolve( - new Response(JSON.stringify(mockPingResponse())) - ); - } - }); - - const results = await Promise.allSettled([ - client.ping(), // Should succeed (call 1) - client.ping(), // Should fail (call 2) - client.ping(), // Should succeed (call 3) - client.ping() // Should fail (call 4) - ]); - - expect(results[0].status).toBe('fulfilled'); - expect(results[1].status).toBe('rejected'); - expect(results[2].status).toBe('fulfilled'); - expect(results[3].status).toBe('rejected'); - }); - }); - - describe('version checking', () => { - it('should get container version successfully', async () => { - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockVersionResponse('0.4.5')), { - status: 200 - }) - ); - - const result = await client.getVersion(); - - expect(result).toBe('0.4.5'); - }); - - it('should handle different version formats', async () => { - const versions = ['1.0.0', '2.5.3-beta', '0.0.1', '10.20.30']; - - for (const version of versions) { - mockFetch.mockResolvedValueOnce( - new Response(JSON.stringify(mockVersionResponse(version)), { - status: 200 - }) - ); - - const result = await client.getVersion(); - expect(result).toBe(version); - } - }); - - it('should return "unknown" when version endpoint does not exist (backward compatibility)', async () => { - // Simulate 404 or other error for old containers - mockFetch.mockResolvedValue( - new Response(JSON.stringify({ error: 'Not Found' }), { status: 404 }) - ); - - const result = await client.getVersion(); - - expect(result).toBe('unknown'); - }); - - it('should return "unknown" on network failure (backward compatibility)', async () => { - mockFetch.mockRejectedValue(new Error('Network connection failed')); - - const result = await client.getVersion(); - - expect(result).toBe('unknown'); - }); - - it('should handle version response with unknown value', async () => { - mockFetch.mockResolvedValue( - new Response(JSON.stringify(mockVersionResponse('unknown')), { - status: 200 - }) - ); - - const result = await client.getVersion(); - - expect(result).toBe('unknown'); - }); - }); - - describe('constructor options', () => { - it('should initialize with minimal options', () => { - const minimalClient = new UtilityClient(); - expect(minimalClient).toBeInstanceOf(UtilityClient); - }); - - it('should initialize with full options', () => { - const fullOptionsClient = new UtilityClient({ - baseUrl: 'http://custom.com', - port: 8080 - }); - expect(fullOptionsClient).toBeInstanceOf(UtilityClient); - }); - }); -}); diff --git a/packages/sandbox/tests/ws-transport.test.ts b/packages/sandbox/tests/ws-transport.test.ts deleted file mode 100644 index c41894252..000000000 --- a/packages/sandbox/tests/ws-transport.test.ts +++ /dev/null @@ -1,812 +0,0 @@ -import type { - WSError, - WSRequest, - WSResponse, - WSStreamChunk -} from '@repo/shared'; -import { - generateRequestId, - isWSError, - isWSRequest, - isWSResponse, - isWSStreamChunk -} from '@repo/shared'; -import { describe, expect, it, vi } from 'vitest'; -import { WebSocketTransport } from '../src/clients/transport'; - -/** - * Tests for WebSocket protocol types and the WebSocketTransport class. - * - * Testing Strategy: - * - Protocol tests (type guards, serialization): Full unit test coverage here - * - WebSocketTransport class tests: Limited unit tests for non-connection behavior, - * plus comprehensive E2E tests in tests/e2e/websocket-transport.test.ts - * - * Why limited WebSocketTransport unit tests: - * - Tests run in Workers runtime (vitest-pool-workers) where mocking WebSocket - * is complex and error-prone - * - The WebSocketTransport class is tightly coupled to WebSocket - most methods - * require an active connection - * - E2E tests verify the complete request/response cycle, error handling, - * streaming, and cleanup against a real container - */ -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('request headers', () => { - it('should include headers in websocket requests', async () => { - const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:3000/ws', - requestTimeoutMs: 1000 - }); - - (transport as any).connect = vi.fn().mockResolvedValue(undefined); - const wsSend = vi.fn(); - (transport as any).ws = { - readyState: WebSocket.OPEN, - send: wsSend, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - close: vi.fn() - }; - - const requestPromise = (transport as any).request( - 'GET', - '/api/health', - undefined, - { - 'X-Sandbox-Id': 'sandbox-123', - 'X-Custom-Header': 'custom-value' - } - ) as Promise<{ status: number; body: { ok: boolean } }>; - - await Promise.resolve(); - - expect(wsSend).toHaveBeenCalledTimes(1); - const request = JSON.parse(wsSend.mock.calls[0]![0]) as WSRequest; - expect(request.headers).toEqual({ - 'X-Custom-Header': 'custom-value', - 'X-Sandbox-Id': 'sandbox-123' - }); - - const pendingIds = Array.from( - ((transport as any).pendingRequests as Map).keys() - ); - const requestId = pendingIds[0]!; - - (transport as any).handleResponse({ - type: 'response', - id: requestId, - status: 200, - body: { ok: true }, - done: true - }); - - const response = await requestPromise; - expect(response.status).toBe(200); - }); - - it('should include headers in websocket streaming requests', async () => { - vi.useFakeTimers(); - try { - const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:3000/ws', - requestTimeoutMs: 1000 - }); - - (transport as any).connect = vi.fn().mockResolvedValue(undefined); - const wsSend = vi.fn(); - (transport as any).ws = { - readyState: WebSocket.OPEN, - send: wsSend, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - close: vi.fn() - }; - - const streamPromise = transport.fetchStream( - '/api/watch', - { path: '/workspace' }, - 'POST', - { - 'X-Sandbox-Id': 'sandbox-stream', - 'Content-Type': 'application/json' - } - ); - - await Promise.resolve(); - - expect(wsSend).toHaveBeenCalledTimes(1); - const request = JSON.parse(wsSend.mock.calls[0]![0]) as WSRequest; - expect(request.headers).toEqual({ - 'Content-Type': 'application/json', - 'X-Sandbox-Id': 'sandbox-stream' - }); - - const pendingIds = Array.from( - ((transport as any).pendingRequests as Map).keys() - ); - const requestId = pendingIds[0]!; - - (transport as any).handleStreamChunk({ - type: 'stream', - id: requestId, - data: '{"type":"watching"}' - }); - - const stream = await streamPromise; - const reader = stream.getReader(); - const readResult = await reader.read(); - expect(readResult.done).toBe(false); - } finally { - vi.useRealTimers(); - } - }); - }); - - describe('stream request first-message handling', () => { - it('should reject before returning stream when first message is an error response', async () => { - vi.useFakeTimers(); - try { - const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:3000/ws', - requestTimeoutMs: 1000 - }); - - (transport as any).connect = vi.fn().mockResolvedValue(undefined); - const wsSend = vi.fn(); - (transport as any).ws = { - readyState: WebSocket.OPEN, - send: wsSend, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - close: vi.fn() - }; - - const streamPromise = (transport as any).requestStream( - 'POST', - '/api/watch', - { path: '/workspace' } - ) as Promise>; - - await Promise.resolve(); - - expect(wsSend).toHaveBeenCalledTimes(1); - - const pendingIds = Array.from( - ((transport as any).pendingRequests as Map).keys() - ); - expect(pendingIds).toHaveLength(1); - const requestId = pendingIds[0]!; - - (transport as any).handleResponse({ - type: 'response', - id: requestId, - status: 500, - body: { error: 'failed' }, - done: true - }); - - await expect(streamPromise).rejects.toThrow('Stream error: 500'); - expect((transport as any).pendingRequests.size).toBe(0); - - vi.advanceTimersByTime(5000); - expect((transport as any).pendingRequests.size).toBe(0); - } finally { - vi.useRealTimers(); - } - }); - - it('should resolve stream on first chunk and clean up after done response', async () => { - vi.useFakeTimers(); - try { - const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:3000/ws', - requestTimeoutMs: 1000 - }); - - (transport as any).connect = vi.fn().mockResolvedValue(undefined); - const wsSend = vi.fn(); - (transport as any).ws = { - readyState: WebSocket.OPEN, - send: wsSend, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - close: vi.fn() - }; - - const streamPromise = (transport as any).requestStream( - 'POST', - '/api/watch', - { path: '/workspace' } - ) as Promise>; - - await Promise.resolve(); - - const pendingIds = Array.from( - ((transport as any).pendingRequests as Map).keys() - ); - expect(pendingIds).toHaveLength(1); - const requestId = pendingIds[0]!; - - (transport as any).handleStreamChunk({ - type: 'stream', - id: requestId, - data: '{"type":"watching"}' - }); - - const stream = await streamPromise; - const reader = stream.getReader(); - const firstRead = await reader.read(); - expect(firstRead.done).toBe(false); - - const firstChunk = new TextDecoder().decode(firstRead.value); - expect(firstChunk).toContain('data: {"type":"watching"}'); - - (transport as any).handleResponse({ - type: 'response', - id: requestId, - status: 200, - done: true - }); - - const secondRead = await reader.read(); - expect(secondRead.done).toBe(true); - expect((transport as any).pendingRequests.size).toBe(0); - - vi.advanceTimersByTime(5000); - expect((transport as any).pendingRequests.size).toBe(0); - } finally { - vi.useRealTimers(); - } - }); - - it('should reject and clean up when timeout fires before first message', async () => { - vi.useFakeTimers(); - try { - const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:3000/ws', - streamIdleTimeoutMs: 1000 - }); - - (transport as any).connect = vi.fn().mockResolvedValue(undefined); - (transport as any).ws = { - readyState: WebSocket.OPEN, - send: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - close: vi.fn() - }; - - const streamPromise = (transport as any).requestStream( - 'POST', - '/api/watch', - { path: '/workspace' } - ) as Promise>; - - await Promise.resolve(); - expect((transport as any).pendingRequests.size).toBe(1); - - vi.advanceTimersByTime(1001); - - await expect(streamPromise).rejects.toThrow( - 'Stream idle timeout after 1000ms' - ); - expect((transport as any).pendingRequests.size).toBe(0); - - vi.advanceTimersByTime(5000); - expect((transport as any).pendingRequests.size).toBe(0); - } finally { - vi.useRealTimers(); - } - }); - - it('should error stream and clean up when timeout fires after first chunk', async () => { - vi.useFakeTimers(); - try { - const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:3000/ws', - streamIdleTimeoutMs: 1000 - }); - - (transport as any).connect = vi.fn().mockResolvedValue(undefined); - (transport as any).ws = { - readyState: WebSocket.OPEN, - send: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - close: vi.fn() - }; - - const streamPromise = (transport as any).requestStream( - 'POST', - '/api/watch', - { path: '/workspace' } - ) as Promise>; - - await Promise.resolve(); - - const pendingIds = Array.from( - ((transport as any).pendingRequests as Map).keys() - ); - expect(pendingIds).toHaveLength(1); - const requestId = pendingIds[0]!; - - (transport as any).handleStreamChunk({ - type: 'stream', - id: requestId, - data: '{"type":"watching"}' - }); - - const stream = await streamPromise; - const reader = stream.getReader(); - - const firstRead = await reader.read(); - expect(firstRead.done).toBe(false); - - vi.advanceTimersByTime(1001); - - await expect(reader.read()).rejects.toThrow( - 'Stream idle timeout after 1000ms' - ); - expect((transport as any).pendingRequests.size).toBe(0); - } finally { - vi.useRealTimers(); - } - }); - - it('should reset idle timeout when chunk arrives', async () => { - vi.useFakeTimers(); - try { - const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:3000/ws', - streamIdleTimeoutMs: 100 - }); - - (transport as any).connect = vi.fn().mockResolvedValue(undefined); - (transport as any).ws = { - readyState: WebSocket.OPEN, - send: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - close: vi.fn() - }; - - const streamPromise = (transport as any).requestStream( - 'POST', - '/api/watch', - { path: '/workspace' } - ) as Promise>; - - await Promise.resolve(); - - const pendingIds = Array.from( - ((transport as any).pendingRequests as Map).keys() - ); - expect(pendingIds).toHaveLength(1); - const requestId = pendingIds[0]!; - - // Send first chunk to establish stream - (transport as any).handleStreamChunk({ - type: 'stream', - id: requestId, - data: '{"type":"watching"}' - }); - - const stream = await streamPromise; - const reader = stream.getReader(); - - const firstRead = await reader.read(); - expect(firstRead.done).toBe(false); - - // Advance 80ms (before 100ms timeout) - vi.advanceTimersByTime(80); - - // Send second chunk - this should reset the idle timer - (transport as any).handleStreamChunk({ - type: 'stream', - id: requestId, - data: '{"type":"update"}' - }); - - // Advance 80ms more (total 160ms from start, but only 80ms from last chunk) - vi.advanceTimersByTime(80); - - // Stream should still be alive - no timeout yet - const secondRead = await reader.read(); - expect(secondRead.done).toBe(false); - expect((transport as any).pendingRequests.size).toBe(1); - - // Now advance past the idle timeout from the last chunk (100ms+) - vi.advanceTimersByTime(101); - - // Stream should now timeout - await expect(reader.read()).rejects.toThrow( - 'Stream idle timeout after 100ms' - ); - expect((transport as any).pendingRequests.size).toBe(0); - } finally { - vi.useRealTimers(); - } - }); - }); -}); - -describe('WebSocketTransport', () => { - describe('initial state', () => { - it('should not be connected after construction', () => { - const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:3000/ws' - }); - expect(transport.isConnected()).toBe(false); - }); - - it('should accept custom options', () => { - const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:3000/ws', - connectTimeoutMs: 5000, - requestTimeoutMs: 60000 - }); - expect(transport.isConnected()).toBe(false); - }); - - it('should throw if wsUrl is missing', () => { - expect(() => { - new WebSocketTransport({}); - }).toThrow('wsUrl is required for WebSocket transport'); - }); - }); - - describe('disconnect', () => { - it('should be safe to call disconnect when not connected', () => { - const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:3000/ws' - }); - // Should not throw - transport.disconnect(); - expect(transport.isConnected()).toBe(false); - }); - - it('should be safe to call disconnect multiple times', () => { - const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:3000/ws' - }); - transport.disconnect(); - transport.disconnect(); - transport.disconnect(); - expect(transport.isConnected()).toBe(false); - }); - - it('should reconnect after a socket close', async () => { - const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:3000/ws' - }); - const transportInternals = transport as unknown as { - ws: { - readyState: number; - send: ReturnType; - addEventListener: ReturnType; - removeEventListener: ReturnType; - close: ReturnType; - } | null; - state: 'disconnected' | 'connecting' | 'connected' | 'error'; - handleClose: (event: CloseEvent) => void; - doConnect: () => Promise; - }; - - const createSocket = () => ({ - readyState: WebSocket.OPEN, - send: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - close: vi.fn() - }); - - const doConnect = vi - .spyOn(transportInternals, 'doConnect') - .mockImplementation(async () => { - transportInternals.ws = createSocket(); - transportInternals.state = 'connected'; - }); - - await transport.connect(); - expect(doConnect).toHaveBeenCalledTimes(1); - - transportInternals.handleClose({ - code: 1006, - reason: '', - wasClean: false - } as CloseEvent); - - await transport.connect(); - expect(doConnect).toHaveBeenCalledTimes(2); - }); - }); - - describe('fetch without connection', () => { - it('retries 503 upgrade responses with updated retry budget', async () => { - vi.useFakeTimers(); - - try { - const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:8671/ws', - retryTimeoutMs: 1_000 - }); - transport.setRetryTimeoutMs(20_000); - - const attemptUpgrade = vi - .fn<() => Promise>() - .mockResolvedValueOnce( - new Response('Container is starting.', { - status: 503, - statusText: 'Service Unavailable' - }) - ) - .mockResolvedValueOnce( - new Response(null, { - status: 200, - statusText: 'OK' - }) - ); - - const connectPromise = ( - transport as unknown as { - fetchUpgradeWithRetry: ( - attemptUpgrade: () => Promise - ) => Promise; - } - ).fetchUpgradeWithRetry(attemptUpgrade); - await vi.advanceTimersByTimeAsync(0); - - expect(attemptUpgrade).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(3_000); - const response = await connectPromise; - - expect(attemptUpgrade).toHaveBeenCalledTimes(2); - expect(response.status).toBe(200); - } finally { - vi.useRealTimers(); - } - }); - - it('creates a fresh request for each connectViaFetch retry', async () => { - vi.useFakeTimers(); - - try { - const ws = { - accept: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - close: vi.fn(), - readyState: WebSocket.OPEN - } as unknown as WebSocket; - - const requests: Request[] = []; - const stub = { - containerFetch: vi.fn(), - fetch: vi - .fn<(request: Request) => Promise>() - .mockImplementationOnce(async (request) => { - requests.push(request); - return new Response('Container is starting.', { - status: 503, - statusText: 'Service Unavailable' - }); - }) - .mockImplementationOnce(async (request) => { - requests.push(request); - - if (request.signal.aborted) { - throw new Error('retry request reused an aborted signal'); - } - - return { - status: 101, - statusText: 'Switching Protocols', - webSocket: ws - } as Response; - }) - }; - - const transport = new WebSocketTransport({ - wsUrl: 'ws://localhost:8671/ws', - stub, - connectTimeoutMs: 1, - retryTimeoutMs: 20_000 - }); - - const connectPromise = ( - transport as unknown as { connectViaFetch: () => Promise } - ).connectViaFetch(); - - await vi.advanceTimersByTimeAsync(0); - expect(stub.fetch).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(3_000); - await connectPromise; - - expect(stub.fetch).toHaveBeenCalledTimes(2); - expect(requests).toHaveLength(2); - expect(requests[0]).not.toBe(requests[1]); - expect(ws.accept).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - - it('should attempt to connect when making a fetch request', async () => { - const transport = new WebSocketTransport({ - wsUrl: 'ws://invalid-url:9999/ws', - connectTimeoutMs: 100 - }); - - // Fetch should fail because connection fails - await expect(transport.fetch('/test')).rejects.toThrow(); - }); - - it('should attempt to connect when making a stream request', async () => { - const transport = new WebSocketTransport({ - wsUrl: 'ws://invalid-url:9999/ws', - connectTimeoutMs: 100 - }); - - // Stream request should fail because connection fails - await expect(transport.fetchStream('/test')).rejects.toThrow(); - }); - }); -});