diff --git a/.changeset/mighty-squids-count.md b/.changeset/mighty-squids-count.md new file mode 100644 index 00000000..d99943c7 --- /dev/null +++ b/.changeset/mighty-squids-count.md @@ -0,0 +1,15 @@ +--- +'@cloudflare/sandbox': patch +--- + +Add process readiness detection with port and log pattern waiting +The `Process` object returned by `startProcess()` now includes readiness methods: + +- `process.waitForPort(port, options?)`: Wait for process to listen on a port + - Supports HTTP mode (default): checks endpoint returns expected status (200-399) + - Supports TCP mode: checks port accepts connections + - Configurable timeout, interval, path, and expected status + +- `process.waitForLog(pattern, options?)`: Wait for pattern in process output + - Supports string or RegExp patterns + - Returns matching line and capture groups diff --git a/package-lock.json b/package-lock.json index 4b8603b4..ed9afb07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -401,7 +401,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -423,7 +422,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -563,7 +561,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1509,8 +1506,7 @@ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20251126.0.tgz", "integrity": "sha512-DSeI1Q7JYmh5/D/tw5eZCjrKY34v69rwj63hHt60nSQW5QLwWCbj/lLtNz9f2EPa+JCACwpLXHgCXfzJ29x66w==", "devOptional": true, - "license": "MIT OR Apache-2.0", - "peer": true + "license": "MIT OR Apache-2.0" }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", @@ -2824,7 +2820,6 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -4304,7 +4299,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4320,7 +4314,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4330,7 +4323,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4457,7 +4449,6 @@ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", @@ -4473,7 +4464,6 @@ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", @@ -4502,7 +4492,6 @@ "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "fflate": "^0.8.2", @@ -4650,7 +4639,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5396,7 +5384,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5690,7 +5677,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -7625,7 +7611,6 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -7890,7 +7875,6 @@ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "devOptional": true, "license": "MPL-2.0", - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -7927,6 +7911,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7947,6 +7932,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7967,6 +7953,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7987,6 +7974,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8007,6 +7995,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8027,6 +8016,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8047,6 +8037,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8067,6 +8058,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8087,6 +8079,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8107,6 +8100,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8127,6 +8121,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8176,6 +8171,7 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -9744,7 +9740,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9961,7 +9956,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9971,7 +9965,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9983,7 +9976,8 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-katex": { "version": "3.1.0", @@ -10357,7 +10351,6 @@ "integrity": "sha512-ZRLgPlS91l4JztLYEZnmMcd3Umcla1hkXJgiEiR4HloRJBBoeaX8qogTu5Jfu36rRMVLndzqYv0h+M5gJAkUfg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/types": "=0.98.0", "@rolldown/pluginutils": "1.0.0-beta.51" @@ -11173,7 +11166,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11373,7 +11365,6 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -11533,7 +11524,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11603,7 +11593,6 @@ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "license": "MIT", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -12045,7 +12034,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -12160,7 +12148,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12193,7 +12180,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -12619,7 +12605,6 @@ "integrity": "sha512-Om5ns0Lyx/LKtYI04IV0bjIrkBgoFNg0p6urzr2asekJlfP18RqFzyqMFZKf0i9Gnjtz/JfAS/Ol6tjCe5JJsQ==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -13383,7 +13368,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/packages/sandbox-container/src/handlers/port-handler.ts b/packages/sandbox-container/src/handlers/port-handler.ts index fb2bd651..2b040e40 100644 --- a/packages/sandbox-container/src/handlers/port-handler.ts +++ b/packages/sandbox-container/src/handlers/port-handler.ts @@ -1,6 +1,7 @@ // Port Handler import type { Logger, + PortCheckRequest, PortCloseResult, PortExposeResult, PortListResult @@ -25,6 +26,8 @@ export class PortHandler extends BaseHandler { if (pathname === '/api/expose-port') { return await this.handleExpose(request, context); + } else if (pathname === '/api/port-check') { + return await this.handlePortCheck(request, context); } else if (pathname === '/api/exposed-ports') { return await this.handleList(request, context); } else if (pathname.startsWith('/api/exposed-ports/')) { @@ -51,6 +54,23 @@ export class PortHandler extends BaseHandler { ); } + private async handlePortCheck( + request: Request, + context: RequestContext + ): Promise { + const body = await this.parseRequestBody(request); + + const result = await this.portService.checkPortReady(body); + + return new Response(JSON.stringify(result), { + status: 200, + headers: { + 'Content-Type': 'application/json', + ...context.corsHeaders + } + }); + } + private async handleExpose( request: Request, context: RequestContext diff --git a/packages/sandbox-container/src/routes/setup.ts b/packages/sandbox-container/src/routes/setup.ts index ee10a19a..c3bc6458 100644 --- a/packages/sandbox-container/src/routes/setup.ts +++ b/packages/sandbox-container/src/routes/setup.ts @@ -118,6 +118,13 @@ export function setupRoutes(router: Router, container: Container): void { middleware: [container.get('loggingMiddleware')] }); + router.register({ + method: 'POST', + path: '/api/port-check', + handler: async (req, ctx) => container.get('portHandler').handle(req, ctx), + middleware: [container.get('loggingMiddleware')] + }); + router.register({ method: 'GET', path: '/api/exposed-ports', diff --git a/packages/sandbox-container/src/services/port-service.ts b/packages/sandbox-container/src/services/port-service.ts index 3063a38b..35246632 100644 --- a/packages/sandbox-container/src/services/port-service.ts +++ b/packages/sandbox-container/src/services/port-service.ts @@ -1,6 +1,6 @@ // Port Management Service -import type { Logger } from '@repo/shared'; +import type { Logger, PortCheckRequest, PortCheckResponse } from '@repo/shared'; import type { InvalidPortContext, PortAlreadyExposedContext, @@ -414,6 +414,93 @@ export class PortService { } } + /** + * Check if a port is ready to accept connections + * Supports both TCP and HTTP modes + */ + async checkPortReady(request: PortCheckRequest): Promise { + const { + port, + mode, + path = '/', + statusMin = 200, + statusMax = 399 + } = request; + + if (mode === 'tcp') { + return this.checkTcpReady(port); + } else { + return this.checkHttpReady(port, path, statusMin, statusMax); + } + } + + private async checkTcpReady(port: number): Promise { + const TCP_TIMEOUT_MS = 5000; // 5 second timeout matching HTTP check + + try { + const timeoutPromise = new Promise((_, reject) => { + setTimeout( + () => reject(new Error('TCP connection timeout')), + TCP_TIMEOUT_MS + ); + }); + + const connectPromise = Bun.connect({ + hostname: 'localhost', + port, + socket: { + data() {}, + open(socket) { + socket.end(); + }, + error() {}, + close() {} + } + }); + + const socket = await Promise.race([connectPromise, timeoutPromise]); + // Connection succeeded + socket.end(); + return { ready: true }; + } catch (error) { + return { + ready: false, + error: error instanceof Error ? error.message : 'TCP connection failed' + }; + } + } + + private async checkHttpReady( + port: number, + path: string, + statusMin: number, + statusMax: number + ): Promise { + try { + const url = `http://localhost:${port}${path.startsWith('/') ? path : `/${path}`}`; + const response = await fetch(url, { + method: 'GET', + signal: AbortSignal.timeout(5000) // 5 second timeout for individual check + }); + + const statusCode = response.status; + const ready = statusCode >= statusMin && statusCode <= statusMax; + + return { + ready, + statusCode, + error: ready + ? undefined + : `HTTP status ${statusCode} not in expected range ${statusMin}-${statusMax}` + }; + } catch (error) { + return { + ready: false, + error: error instanceof Error ? error.message : 'HTTP request failed' + }; + } + } + private startCleanupProcess(): void { this.cleanupInterval = setInterval( async () => { diff --git a/packages/sandbox/src/clients/port-client.ts b/packages/sandbox/src/clients/port-client.ts index f56e2f51..15572ac3 100644 --- a/packages/sandbox/src/clients/port-client.ts +++ b/packages/sandbox/src/clients/port-client.ts @@ -1,4 +1,6 @@ import type { + PortCheckRequest, + PortCheckResponse, PortCloseResult, PortExposeResult, PortListResult @@ -7,7 +9,12 @@ import { BaseHttpClient } from './base-client'; import type { HttpClientOptions } from './types'; // Re-export for convenience -export type { PortExposeResult, PortCloseResult, PortListResult }; +export type { + PortExposeResult, + PortCloseResult, + PortListResult, + PortCheckResponse +}; /** * Request interface for exposing ports @@ -102,4 +109,24 @@ export class PortClient extends BaseHttpClient { throw error; } } + + /** + * Check if a port is ready to accept connections + * @param request - Port check configuration + */ + async checkPortReady(request: PortCheckRequest): Promise { + try { + const response = await this.post( + '/api/port-check', + request + ); + return response; + } catch (error) { + // On error (e.g., container not ready), return not ready + return { + ready: false, + error: error instanceof Error ? error.message : 'Port check failed' + }; + } + } } diff --git a/packages/sandbox/src/errors/classes.ts b/packages/sandbox/src/errors/classes.ts index b36ea05d..caad715b 100644 --- a/packages/sandbox/src/errors/classes.ts +++ b/packages/sandbox/src/errors/classes.ts @@ -25,7 +25,9 @@ import type { PortErrorContext, PortNotExposedContext, ProcessErrorContext, + ProcessExitedBeforeReadyContext, ProcessNotFoundContext, + ProcessReadyTimeoutContext, ValidationFailedContext } from '@repo/shared/errors'; @@ -592,3 +594,55 @@ export class ValidationFailedError extends SandboxError return this.context.validationErrors; } } + +// ============================================================================ +// Process Readiness Errors +// ============================================================================ + +/** + * Error thrown when a process does not become ready within the timeout period + */ +export class ProcessReadyTimeoutError extends SandboxError { + constructor(errorResponse: ErrorResponse) { + super(errorResponse); + this.name = 'ProcessReadyTimeoutError'; + } + + // Type-safe accessors + get processId() { + return this.context.processId; + } + get command() { + return this.context.command; + } + get condition() { + return this.context.condition; + } + get timeout() { + return this.context.timeout; + } +} + +/** + * Error thrown when a process exits before becoming ready + */ +export class ProcessExitedBeforeReadyError extends SandboxError { + constructor(errorResponse: ErrorResponse) { + super(errorResponse); + this.name = 'ProcessExitedBeforeReadyError'; + } + + // Type-safe accessors + get processId() { + return this.context.processId; + } + get command() { + return this.context.command; + } + get condition() { + return this.context.condition; + } + get exitCode() { + return this.context.exitCode; + } +} diff --git a/packages/sandbox/src/errors/index.ts b/packages/sandbox/src/errors/index.ts index f8f34041..98936962 100644 --- a/packages/sandbox/src/errors/index.ts +++ b/packages/sandbox/src/errors/index.ts @@ -61,7 +61,9 @@ export type { PortErrorContext, PortNotExposedContext, ProcessErrorContext, + ProcessExitedBeforeReadyContext, ProcessNotFoundContext, + ProcessReadyTimeoutContext, ValidationFailedContext } from '@repo/shared/errors'; // Re-export shared types and constants @@ -100,8 +102,11 @@ export { PortInUseError, PortNotExposedError, ProcessError, + // Process Readiness Errors + ProcessExitedBeforeReadyError, // Process Errors ProcessNotFoundError, + ProcessReadyTimeoutError, SandboxError, ServiceNotRespondingError, // Validation Errors diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts index 5f9a56c7..ea964d0e 100644 --- a/packages/sandbox/src/index.ts +++ b/packages/sandbox/src/index.ts @@ -40,7 +40,10 @@ export type { RunCodeOptions, SandboxOptions, SessionOptions, - StreamOptions + StreamOptions, + // Process readiness types + WaitForLogResult, + WaitForPortOptions } from '@repo/shared'; // Export type guards for runtime validation export { isExecResult, isProcess, isProcessStatus } from '@repo/shared'; @@ -96,6 +99,11 @@ export type { ExecutionCallbacks, InterpreterClient } from './clients/interpreter-client.js'; +// Export process readiness errors +export { + ProcessExitedBeforeReadyError, + ProcessReadyTimeoutError +} from './errors'; // Export file streaming utilities for binary file support export { collectFile, streamFile } from './file-stream'; // Export interpreter functionality diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index b5014691..a1902f92 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -10,25 +10,35 @@ import type { ExecutionResult, ExecutionSession, ISandbox, + LogEvent, MountBucketOptions, + PortCheckRequest, Process, ProcessOptions, ProcessStatus, RunCodeOptions, SandboxOptions, SessionOptions, - StreamOptions + StreamOptions, + WaitForLogResult, + WaitForPortOptions } from '@repo/shared'; import { createLogger, getEnvString, + isTerminalStatus, type SessionDeleteResult, shellEscape, TraceContext } from '@repo/shared'; import { type ExecuteResponse, SandboxClient } from './clients'; import type { ErrorResponse } from './errors'; -import { CustomDomainRequiredError, ErrorCode } from './errors'; +import { + CustomDomainRequiredError, + ErrorCode, + ProcessExitedBeforeReadyError, + ProcessReadyTimeoutError +} from './errors'; import { CodeInterpreter } from './interpreter'; import { isLocalhostPattern } from './request-handler'; import { SecurityError, sanitizeSandboxId, validatePort } from './security'; @@ -1239,10 +1249,376 @@ export class Sandbox extends Container implements ISandbox { getLogs: async () => { const logs = await this.getProcessLogs(data.id); return { stdout: logs.stdout, stderr: logs.stderr }; + }, + + waitForLog: async ( + pattern: string | RegExp, + timeout?: number + ): Promise => { + return this.waitForLogPattern(data.id, data.command, pattern, timeout); + }, + + waitForPort: async ( + port: number, + options?: WaitForPortOptions + ): Promise => { + await this.waitForPortReady(data.id, data.command, port, options); } }; } + /** + * Wait for a log pattern to appear in process output + */ + private async waitForLogPattern( + processId: string, + command: string, + pattern: string | RegExp, + timeout?: number + ): Promise { + const startTime = Date.now(); + const conditionStr = this.conditionToString(pattern); + let collectedStdout = ''; + let collectedStderr = ''; + + // First check existing logs + try { + const existingLogs = await this.getProcessLogs(processId); + // Ensure existing logs end with newline for proper line separation from streamed output + collectedStdout = existingLogs.stdout; + if (collectedStdout && !collectedStdout.endsWith('\n')) { + collectedStdout += '\n'; + } + collectedStderr = existingLogs.stderr; + if (collectedStderr && !collectedStderr.endsWith('\n')) { + collectedStderr += '\n'; + } + + // Check stdout + const stdoutResult = this.matchPattern(existingLogs.stdout, pattern); + if (stdoutResult) { + return stdoutResult; + } + + // Check stderr + const stderrResult = this.matchPattern(existingLogs.stderr, pattern); + if (stderrResult) { + return stderrResult; + } + } catch (error) { + // Process might have already exited, continue to streaming + this.logger.debug('Could not get existing logs, will stream', { + processId, + error: error instanceof Error ? error.message : String(error) + }); + } + + // Stream new logs and check for pattern + const stream = await this.streamProcessLogs(processId); + + // Set up timeout if specified + let timeoutId: ReturnType | undefined; + let timeoutPromise: Promise | undefined; + + if (timeout !== undefined) { + const remainingTime = timeout - (Date.now() - startTime); + if (remainingTime <= 0) { + throw this.createReadyTimeoutError( + processId, + command, + conditionStr, + timeout + ); + } + + timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject( + this.createReadyTimeoutError( + processId, + command, + conditionStr, + timeout + ) + ); + }, remainingTime); + }); + } + + try { + // Process stream + const streamProcessor = async (): Promise => { + const DEBOUNCE_MS = 50; + let lastCheckTime = 0; + let pendingCheck = false; + + const checkPattern = (): WaitForLogResult | null => { + // Check both stdout and stderr buffers + const stdoutResult = this.matchPattern(collectedStdout, pattern); + if (stdoutResult) return stdoutResult; + const stderrResult = this.matchPattern(collectedStderr, pattern); + if (stderrResult) return stderrResult; + return null; + }; + + for await (const event of parseSSEStream(stream)) { + // Handle different event types + if (event.type === 'stdout' || event.type === 'stderr') { + const data = event.data || ''; + + if (event.type === 'stdout') { + collectedStdout += data; + } else { + collectedStderr += data; + } + pendingCheck = true; + + // Debounce pattern matching - check at most every 50ms + const now = Date.now(); + if (now - lastCheckTime >= DEBOUNCE_MS) { + lastCheckTime = now; + pendingCheck = false; + const result = checkPattern(); + if (result) return result; + } + } + + // Process exited - do final check before throwing + if (event.type === 'exit') { + if (pendingCheck) { + const result = checkPattern(); + if (result) return result; + } + throw this.createExitedBeforeReadyError( + processId, + command, + conditionStr, + event.exitCode ?? 1 + ); + } + } + + // Stream ended - do final check before throwing + if (pendingCheck) { + const result = checkPattern(); + if (result) return result; + } + // Stream ended without finding pattern - this indicates process exited + throw this.createExitedBeforeReadyError( + processId, + command, + conditionStr, + 0 + ); + }; + + // Race with timeout if specified, otherwise just run stream processor + if (timeoutPromise) { + return await Promise.race([streamProcessor(), timeoutPromise]); + } + return await streamProcessor(); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } + + /** + * Wait for a port to become available (for process readiness checking) + */ + private async waitForPortReady( + processId: string, + command: string, + port: number, + options?: WaitForPortOptions + ): Promise { + const { + mode = 'http', + path = '/', + status = { min: 200, max: 399 }, + timeout, + interval = 500 + } = options ?? {}; + + const startTime = Date.now(); + const conditionStr = + mode === 'http' ? `port ${port} (HTTP ${path})` : `port ${port} (TCP)`; + const targetInterval = interval; + let checkCount = 0; + + // Normalize status to min/max + const statusMin = typeof status === 'number' ? status : status.min; + const statusMax = typeof status === 'number' ? status : status.max; + + // Build the port check request + const checkRequest: PortCheckRequest = { + port, + mode, + path, + statusMin, + statusMax + }; + + while (true) { + // Check timeout if specified + if (timeout !== undefined) { + const elapsed = Date.now() - startTime; + const remaining = timeout - elapsed; + + // Exit if we've exceeded timeout + if (remaining <= 0) { + throw this.createReadyTimeoutError( + processId, + command, + conditionStr, + timeout + ); + } + } + + // Track total operation time for accurate sleep calculation + const iterationStart = Date.now(); + + // Check process status less frequently (every 3rd iteration) to reduce latency + if (checkCount % 3 === 0) { + const processInfo = await this.getProcess(processId); + if (!processInfo || isTerminalStatus(processInfo.status)) { + throw this.createExitedBeforeReadyError( + processId, + command, + conditionStr, + processInfo?.exitCode ?? 1 + ); + } + } + + // Check port readiness via container endpoint + try { + const result = await this.client.ports.checkPortReady(checkRequest); + if (result.ready) { + return; // Port is ready + } + } catch { + // Port not ready yet, continue polling + } + + checkCount++; + + // Calculate sleep time accounting for total iteration duration (process check + port check) + const iterationDuration = Date.now() - iterationStart; + const sleepTime = Math.max(0, targetInterval - iterationDuration); + + // Sleep between checks (skip if timeout would be exceeded) + if (sleepTime > 0) { + if ( + timeout === undefined || + Date.now() - startTime + sleepTime < timeout + ) { + await new Promise((resolve) => setTimeout(resolve, sleepTime)); + } + } + } + } + + /** + * Match a pattern against text + */ + private matchPattern( + text: string, + pattern: string | RegExp + ): WaitForLogResult | null { + if (typeof pattern === 'string') { + // Simple substring match + if (text.includes(pattern)) { + // Find the line containing the pattern + const lines = text.split('\n'); + for (const line of lines) { + if (line.includes(pattern)) { + return { line }; + } + } + return { line: pattern }; + } + } else { + const safePattern = new RegExp( + pattern.source, + pattern.flags.replace('g', '') + ); + const match = text.match(safePattern); + if (match) { + // Find the full line containing the match + const lines = text.split('\n'); + for (const line of lines) { + const lineMatch = line.match(safePattern); + if (lineMatch) { + return { line, match: lineMatch }; + } + } + return { line: match[0], match }; + } + } + return null; + } + + /** + * Convert a log pattern to a human-readable string + */ + private conditionToString(pattern: string | RegExp): string { + if (typeof pattern === 'string') { + return `"${pattern}"`; + } + return pattern.toString(); + } + + /** + * Create a ProcessReadyTimeoutError + */ + private createReadyTimeoutError( + processId: string, + command: string, + condition: string, + timeout: number + ): ProcessReadyTimeoutError { + return new ProcessReadyTimeoutError({ + code: ErrorCode.PROCESS_READY_TIMEOUT, + message: `Process did not become ready within ${timeout}ms. Waiting for: ${condition}`, + context: { + processId, + command, + condition, + timeout + }, + httpStatus: 408, + timestamp: new Date().toISOString(), + suggestion: `Check if your process outputs ${condition}. You can increase the timeout parameter.` + }); + } + + /** + * Create a ProcessExitedBeforeReadyError + */ + private createExitedBeforeReadyError( + processId: string, + command: string, + condition: string, + exitCode: number + ): ProcessExitedBeforeReadyError { + return new ProcessExitedBeforeReadyError({ + code: ErrorCode.PROCESS_EXITED_BEFORE_READY, + message: `Process exited with code ${exitCode} before becoming ready. Waiting for: ${condition}`, + context: { + processId, + command, + condition, + exitCode + }, + httpStatus: 500, + timestamp: new Date().toISOString(), + suggestion: 'Check process logs with getLogs() for error messages' + }); + } + // Background process management async startProcess( command: string, diff --git a/packages/sandbox/tests/process-readiness.test.ts b/packages/sandbox/tests/process-readiness.test.ts new file mode 100644 index 00000000..70ed7e84 --- /dev/null +++ b/packages/sandbox/tests/process-readiness.test.ts @@ -0,0 +1,770 @@ +/** + * Unit tests for process readiness feature + * + * Tests the waitForLog() and waitForPort() functionality + */ + +import type { DurableObjectState } from '@cloudflare/workers-types'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ProcessExitedBeforeReadyError, + ProcessReadyTimeoutError +} from '../src/errors'; +import { Sandbox } from '../src/sandbox'; + +// Mock dependencies +vi.mock('./interpreter', () => ({ + CodeInterpreter: vi.fn().mockImplementation(() => ({})) +})); + +vi.mock('@cloudflare/containers', () => { + const MockContainer = class Container { + ctx: any; + env: any; + constructor(ctx: any, env: any) { + this.ctx = ctx; + this.env = env; + } + async fetch(): Promise { + return new Response('Mock Container fetch'); + } + async containerFetch(): Promise { + return new Response('Mock Container HTTP fetch'); + } + async getState() { + return { status: 'healthy' }; + } + }; + + return { + Container: MockContainer, + getContainer: vi.fn(), + switchPort: vi.fn() + }; +}); + +describe('Process Readiness Feature', () => { + let sandbox: Sandbox; + let mockCtx: Partial>; + let mockEnv: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + mockCtx = { + storage: { + get: vi.fn().mockResolvedValue(null), + put: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue(new Map()) + } as any, + blockConcurrencyWhile: vi + .fn() + .mockImplementation( + (callback: () => Promise): Promise => callback() + ), + waitUntil: vi.fn(), + id: { + toString: () => 'test-sandbox-id', + equals: vi.fn(), + name: 'test-sandbox' + } as any + }; + + mockEnv = {}; + + sandbox = new Sandbox(mockCtx as DurableObjectState<{}>, mockEnv); + + await vi.waitFor(() => { + expect(mockCtx.blockConcurrencyWhile).toHaveBeenCalled(); + }); + + // Mock session creation + vi.spyOn(sandbox.client.utils, 'createSession').mockResolvedValue({ + success: true, + id: 'sandbox-default', + message: 'Created' + } as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('waitForLog() method', () => { + describe('string pattern matching', () => { + it('should resolve when string pattern found in existing logs', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: + 'Compiling...\nServer listening on port 3000\nReady to accept connections', + stderr: '', + timestamp: new Date().toISOString() + } as any); + + const proc = await sandbox.startProcess('npm start'); + const result = await proc.waitForLog('Server listening on port 3000'); + + expect(result.line).toContain('Server listening on port 3000'); + }); + + it('should find pattern via streaming when not in historical logs', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + // First call returns logs without the pattern + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: 'Starting server...', + stderr: '', + timestamp: new Date().toISOString() + } as any); + + // Mock streaming to emit the pattern + const sseData = `data: {"type":"stdout","data":"Server ready on port 3000\\n","timestamp":"${new Date().toISOString()}"} + +`; + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(sseData)); + controller.close(); + } + }); + + vi.spyOn( + sandbox.client.processes, + 'streamProcessLogs' + ).mockResolvedValue(mockStream); + + const proc = await sandbox.startProcess('npm start'); + const result = await proc.waitForLog('Server ready on port 3000'); + + expect(result.line).toBe('Server ready on port 3000'); + }); + + it('should find pattern that spans multiple SSE chunks', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + // Mock empty historical logs + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: '', + stderr: '', + timestamp: new Date().toISOString() + } as any); + + // Simulate pattern split across multiple SSE chunks: + // "Server listen" in chunk 1, "ing on port 3000" in chunk 2 + const sseChunk1 = `data: {"type":"stdout","data":"Server listen","timestamp":"${new Date().toISOString()}"}\n\n`; + const sseChunk2 = `data: {"type":"stdout","data":"ing on port 3000\\n","timestamp":"${new Date().toISOString()}"}\n\n`; + + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(sseChunk1)); + controller.enqueue(new TextEncoder().encode(sseChunk2)); + controller.close(); + } + }); + + vi.spyOn( + sandbox.client.processes, + 'streamProcessLogs' + ).mockResolvedValue(mockStream); + + const proc = await sandbox.startProcess('npm start'); + const result = await proc.waitForLog('Server listening on port 3000'); + + // Should find the pattern even though it was split across chunks + expect(result.line).toBe('Server listening on port 3000'); + }); + }); + + describe('regex pattern matching', () => { + it('should resolve with match details when regex matches', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: 'Server listening on port 8080', + stderr: '', + timestamp: new Date().toISOString() + } as any); + + const proc = await sandbox.startProcess('npm start'); + const result = await proc.waitForLog(/port (\d+)/); + + expect(result.match).toBeDefined(); + expect(result.match![0]).toBe('port 8080'); + expect(result.match![1]).toBe('8080'); + }); + }); + + describe('timeout handling', () => { + it('should throw ProcessReadyTimeoutError when pattern not found within timeout', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: 'Starting...', + stderr: 'Warning: something', + timestamp: new Date().toISOString() + } as any); + + // Create a stream that stays open longer than the timeout + // This ensures timeout fires before stream ends + const mockStream = new ReadableStream({ + start(controller) { + // Keep stream open - never close it + // The timeout will fire before this stream ends + } + }); + + vi.spyOn( + sandbox.client.processes, + 'streamProcessLogs' + ).mockResolvedValue(mockStream); + + const proc = await sandbox.startProcess('npm start'); + + await expect(proc.waitForLog('never-appears', 100)).rejects.toThrow( + ProcessReadyTimeoutError + ); + }); + + it('should include process info in timeout error', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: 'Output line 1\nOutput line 2', + stderr: 'Error occurred', + timestamp: new Date().toISOString() + } as any); + + // Create a stream that stays open longer than the timeout + const mockStream = new ReadableStream({ + start(controller) { + // Keep stream open - timeout will fire first + } + }); + + vi.spyOn( + sandbox.client.processes, + 'streamProcessLogs' + ).mockResolvedValue(mockStream); + + const proc = await sandbox.startProcess('npm start'); + + try { + await proc.waitForLog('never-found', 100); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ProcessReadyTimeoutError); + const readyError = error as ProcessReadyTimeoutError; + expect(readyError.processId).toBe('proc-server'); + expect(readyError.command).toBe('npm start'); + } + }); + }); + + describe('process exit handling', () => { + it('should throw ProcessExitedBeforeReadyError when process exits', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: 'Starting...', + stderr: 'Error: port in use', + timestamp: new Date().toISOString() + } as any); + + // Mock stream to emit an exit event + const sseData = `data: {"type":"stdout","data":"Starting...\\n","timestamp":"${new Date().toISOString()}"} + +data: {"type":"exit","exitCode":1,"timestamp":"${new Date().toISOString()}"} + +`; + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(sseData)); + controller.close(); + } + }); + + vi.spyOn( + sandbox.client.processes, + 'streamProcessLogs' + ).mockResolvedValue(mockStream); + + const proc = await sandbox.startProcess('npm start'); + + await expect(proc.waitForLog('Server ready')).rejects.toThrow( + ProcessExitedBeforeReadyError + ); + }); + + it('should include exit code and logs in exit error', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: '', + stderr: 'command not found: npm', + timestamp: new Date().toISOString() + } as any); + + // Mock stream to emit exit event with specific exit code + const sseData = `data: {"type":"stderr","data":"command not found: npm\\n","timestamp":"${new Date().toISOString()}"} + +data: {"type":"exit","exitCode":127,"timestamp":"${new Date().toISOString()}"} + +`; + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(sseData)); + controller.close(); + } + }); + + vi.spyOn( + sandbox.client.processes, + 'streamProcessLogs' + ).mockResolvedValue(mockStream); + + const proc = await sandbox.startProcess('npm start'); + + try { + await proc.waitForLog('Server ready'); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ProcessExitedBeforeReadyError); + const exitError = error as ProcessExitedBeforeReadyError; + expect(exitError.exitCode).toBe(127); + expect(exitError.processId).toBe('proc-server'); + } + }); + }); + }); + + describe('waitForPort() method', () => { + it('should wait for port to become available with HTTP mode (default)', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.ports, 'checkPortReady').mockResolvedValue({ + ready: true, + statusCode: 200 + }); + + const proc = await sandbox.startProcess('npm start'); + await proc.waitForPort(3000); + + expect(sandbox.client.ports.checkPortReady).toHaveBeenCalledWith({ + port: 3000, + mode: 'http', + path: '/', + statusMin: 200, + statusMax: 399 + }); + }); + + it('should support TCP mode for non-HTTP services', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-db', + pid: 12345, + command: 'postgres', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-db', + pid: 12345, + command: 'postgres', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.ports, 'checkPortReady').mockResolvedValue({ + ready: true + }); + + const proc = await sandbox.startProcess('postgres'); + await proc.waitForPort(5432, { mode: 'tcp' }); + + expect(sandbox.client.ports.checkPortReady).toHaveBeenCalledWith({ + port: 5432, + mode: 'tcp', + path: '/', + statusMin: 200, + statusMax: 399 + }); + }); + + it('should support custom health check path', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.ports, 'checkPortReady').mockResolvedValue({ + ready: true, + statusCode: 200 + }); + + const proc = await sandbox.startProcess('npm start'); + await proc.waitForPort(3000, { path: '/health', status: 200 }); + + expect(sandbox.client.ports.checkPortReady).toHaveBeenCalledWith({ + port: 3000, + mode: 'http', + path: '/health', + statusMin: 200, + statusMax: 200 + }); + }); + + it('should throw ProcessExitedBeforeReadyError when process exits before port is ready', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'completed', + exitCode: 1, + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: '', + stderr: '', + timestamp: new Date().toISOString() + } as any); + + const proc = await sandbox.startProcess('npm start'); + + try { + await proc.waitForPort(3000); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ProcessExitedBeforeReadyError); + const exitError = error as ProcessExitedBeforeReadyError; + expect(exitError.condition).toBe('port 3000 (HTTP /)'); + } + }); + + it('should throw ProcessReadyTimeoutError when port does not become ready', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + // Port never becomes ready + vi.spyOn(sandbox.client.ports, 'checkPortReady').mockResolvedValue({ + ready: false, + error: 'Connection refused' + }); + + const proc = await sandbox.startProcess('npm start'); + + try { + await proc.waitForPort(3000, { timeout: 100, interval: 50 }); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ProcessReadyTimeoutError); + const timeoutError = error as ProcessReadyTimeoutError; + expect(timeoutError.processId).toBe('proc-server'); + expect(timeoutError.condition).toBe('port 3000 (HTTP /)'); + } + }); + }); + + describe('conditionToString helper', () => { + it('should format string conditions as quoted strings', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: 'no match here', + stderr: '', + timestamp: new Date().toISOString() + } as any); + + // Stream that closes immediately (simulates process exit) + const mockStream = new ReadableStream({ + start(controller) { + controller.close(); + } + }); + + vi.spyOn(sandbox.client.processes, 'streamProcessLogs').mockResolvedValue( + mockStream + ); + + const proc = await sandbox.startProcess('npm start'); + + try { + await proc.waitForLog('Server ready'); + expect.fail('Should have thrown'); + } catch (error) { + // Stream ending means process exited + expect(error).toBeInstanceOf(ProcessExitedBeforeReadyError); + const exitError = error as ProcessExitedBeforeReadyError; + expect(exitError.condition).toBe('"Server ready"'); + } + }); + + it('should format regex conditions with regex syntax', async () => { + vi.spyOn(sandbox.client.processes, 'startProcess').mockResolvedValue({ + success: true, + processId: 'proc-server', + pid: 12345, + command: 'npm start', + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcess').mockResolvedValue({ + success: true, + process: { + id: 'proc-server', + pid: 12345, + command: 'npm start', + status: 'running', + startTime: new Date().toISOString() + }, + timestamp: new Date().toISOString() + } as any); + + vi.spyOn(sandbox.client.processes, 'getProcessLogs').mockResolvedValue({ + success: true, + processId: 'proc-server', + stdout: 'no match here', + stderr: '', + timestamp: new Date().toISOString() + } as any); + + // Stream that closes immediately (simulates process exit) + const mockStream = new ReadableStream({ + start(controller) { + controller.close(); + } + }); + + vi.spyOn(sandbox.client.processes, 'streamProcessLogs').mockResolvedValue( + mockStream + ); + + const proc = await sandbox.startProcess('npm start'); + + try { + await proc.waitForLog(/port \d+/); + expect.fail('Should have thrown'); + } catch (error) { + // Stream ending means process exited + expect(error).toBeInstanceOf(ProcessExitedBeforeReadyError); + const exitError = error as ProcessExitedBeforeReadyError; + expect(exitError.condition).toBe('/port \\d+/'); + } + }); + }); +}); diff --git a/packages/shared/src/errors/codes.ts b/packages/shared/src/errors/codes.ts index db98c095..d2b2bba3 100644 --- a/packages/shared/src/errors/codes.ts +++ b/packages/shared/src/errors/codes.ts @@ -97,6 +97,10 @@ export const ErrorCode = { // Code Interpreter Errors (501) - Feature not available in image variant PYTHON_NOT_AVAILABLE: 'PYTHON_NOT_AVAILABLE', + // Process Readiness Errors (408/500) + PROCESS_READY_TIMEOUT: 'PROCESS_READY_TIMEOUT', + PROCESS_EXITED_BEFORE_READY: 'PROCESS_EXITED_BEFORE_READY', + // Validation Errors (400) VALIDATION_FAILED: 'VALIDATION_FAILED', diff --git a/packages/shared/src/errors/contexts.ts b/packages/shared/src/errors/contexts.ts index 2b0ed705..354f6e88 100644 --- a/packages/shared/src/errors/contexts.ts +++ b/packages/shared/src/errors/contexts.ts @@ -48,6 +48,23 @@ export interface ProcessErrorContext { stderr?: string; } +/** + * Process readiness error contexts + */ +export interface ProcessReadyTimeoutContext { + processId: string; + command: string; + condition: string; + timeout: number; +} + +export interface ProcessExitedBeforeReadyContext { + processId: string; + command: string; + condition: string; + exitCode: number; +} + /** * Port error contexts */ diff --git a/packages/shared/src/errors/index.ts b/packages/shared/src/errors/index.ts index 84d9c666..b294aff8 100644 --- a/packages/shared/src/errors/index.ts +++ b/packages/shared/src/errors/index.ts @@ -54,7 +54,9 @@ export type { PortErrorContext, PortNotExposedContext, ProcessErrorContext, + ProcessExitedBeforeReadyContext, ProcessNotFoundContext, + ProcessReadyTimeoutContext, ValidationFailedContext } from './contexts'; // Export utility functions diff --git a/packages/shared/src/errors/status-map.ts b/packages/shared/src/errors/status-map.ts index 7e9dd8ca..d71f1ab6 100644 --- a/packages/shared/src/errors/status-map.ts +++ b/packages/shared/src/errors/status-map.ts @@ -53,7 +53,11 @@ export const ERROR_STATUS_MAP: Record = { // 503 Service Unavailable [ErrorCode.INTERPRETER_NOT_READY]: 503, + // 408 Request Timeout + [ErrorCode.PROCESS_READY_TIMEOUT]: 408, + // 500 Internal Server Error + [ErrorCode.PROCESS_EXITED_BEFORE_READY]: 500, [ErrorCode.NO_SPACE]: 500, [ErrorCode.TOO_MANY_FILES]: 500, [ErrorCode.TOO_MANY_LINKS]: 500, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d4367084..2e0362e3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -78,6 +78,8 @@ export type { MkdirResult, MountBucketOptions, MoveFileResult, + PortCheckRequest, + PortCheckResponse, PortCloseResult, // Port management result types PortExposeResult, @@ -103,6 +105,14 @@ export type { SessionOptions, ShutdownResult, StreamOptions, + // Process readiness types + WaitForLogResult, + WaitForPortOptions, WriteFileResult } from './types.js'; -export { isExecResult, isProcess, isProcessStatus } from './types.js'; +export { + isExecResult, + isProcess, + isProcessStatus, + isTerminalStatus +} from './types.js'; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 50238211..f35941d7 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -100,6 +100,77 @@ export interface ExecResult { sessionId?: string; } +/** + * Result from waiting for a log pattern + */ +export interface WaitForLogResult { + /** The log line that matched */ + line: string; + /** Regex capture groups (if condition was a RegExp) */ + match?: RegExpMatchArray; +} + +/** + * Options for waiting for a port to become ready + */ +export interface WaitForPortOptions { + /** + * Check mode + * - 'http': Make an HTTP request and check for success status (default) + * - 'tcp': Just check if TCP connection succeeds + * @default 'http' + */ + mode?: 'http' | 'tcp'; + + /** + * HTTP path to check (only used when mode is 'http') + * @default '/' + */ + path?: string; + + /** + * Expected HTTP status code or range (only used when mode is 'http') + * - Single number: exact match (e.g., 200) + * - Object with min/max: range match (e.g., { min: 200, max: 399 }) + * @default { min: 200, max: 399 } + */ + status?: number | { min: number; max: number }; + + /** + * Maximum time to wait in milliseconds + * @default no timeout + */ + timeout?: number; + + /** + * Interval between checks in milliseconds + * @default 500 + */ + interval?: number; +} + +/** + * Request body for port readiness check endpoint + */ +export interface PortCheckRequest { + port: number; + mode: 'http' | 'tcp'; + path?: string; + statusMin?: number; + statusMax?: number; +} + +/** + * Response from port readiness check endpoint + */ +export interface PortCheckResponse { + ready: boolean; + /** HTTP status code received (only for http mode) */ + statusCode?: number; + /** Error message if check failed */ + error?: string; +} + // Background process types export interface ProcessOptions extends BaseExecOptions { /** @@ -142,6 +213,18 @@ export type ProcessStatus = | 'killed' // Process was terminated by signal | 'error'; // Process failed to start or encountered error +/** + * Check if a process status indicates the process has terminated + */ +export function isTerminalStatus(status: ProcessStatus): boolean { + return ( + status === 'completed' || + status === 'failed' || + status === 'killed' || + status === 'error' + ); +} + export interface Process { /** * Unique process identifier @@ -197,6 +280,37 @@ export interface Process { * Get accumulated logs */ getLogs(): Promise<{ stdout: string; stderr: string }>; + + /** + * Wait for a log pattern to appear in process output + * + * @example + * const proc = await sandbox.startProcess("python train.py"); + * await proc.waitForLog("Epoch 1 complete"); + * await proc.waitForLog(/Epoch (\d+) complete/); + */ + waitForLog( + pattern: string | RegExp, + timeout?: number + ): Promise; + + /** + * Wait for a port to become ready + * + * @example + * // Wait for HTTP endpoint to return 200-399 + * const proc = await sandbox.startProcess("npm run dev"); + * await proc.waitForPort(3000); + * + * @example + * // Wait for specific health endpoint + * await proc.waitForPort(3000, { path: '/health', status: 200 }); + * + * @example + * // TCP-only check (just verify port is accepting connections) + * await proc.waitForPort(5432, { mode: 'tcp' }); + */ + waitForPort(port: number, options?: WaitForPortOptions): Promise; } // Streaming event types diff --git a/tests/e2e/process-readiness-workflow.test.ts b/tests/e2e/process-readiness-workflow.test.ts new file mode 100644 index 00000000..aa9cff02 --- /dev/null +++ b/tests/e2e/process-readiness-workflow.test.ts @@ -0,0 +1,494 @@ +import { describe, test, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { getTestWorkerUrl, WranglerDevRunner } from './helpers/wrangler-runner'; +import { + createSandboxId, + createTestHeaders, + cleanupSandbox +} from './helpers/test-fixtures'; +import type { Process, WaitForLogResult, PortExposeResult } from '@repo/shared'; + +// Port exposure tests require custom domain with wildcard DNS routing +const skipPortExposureTests = + process.env.TEST_WORKER_URL?.endsWith('.workers.dev') ?? false; + +/** + * Process Readiness Workflow Integration Tests + * + * Tests the process readiness feature including: + * - waitForLog() method with string and regex patterns + * - waitForPort() method for port checking + */ +describe('Process Readiness Workflow', () => { + describe('local', () => { + let runner: WranglerDevRunner | null = null; + let workerUrl: string; + let currentSandboxId: string | null = null; + + beforeAll(async () => { + const result = await getTestWorkerUrl(); + workerUrl = result.url; + runner = result.runner; + }); + + afterEach(async () => { + if (currentSandboxId) { + await cleanupSandbox(workerUrl, currentSandboxId); + currentSandboxId = null; + } + }); + + afterAll(async () => { + if (runner) { + await runner.stop(); + } + }); + + test('should wait for string pattern in process output', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Write a script that outputs a specific message after a delay + const scriptCode = ` +console.log("Starting up..."); +await Bun.sleep(500); +console.log("Server ready on port 8080"); +await Bun.sleep(60000); // Keep running + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/server.js', + content: scriptCode + }) + }); + + // Start the process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'bun run /workspace/server.js' + }) + }); + + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; + + // Wait for the log pattern + const waitResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForLog`, + { + method: 'POST', + headers, + body: JSON.stringify({ + pattern: 'Server ready on port 8080', + timeout: 10000 + }) + } + ); + + expect(waitResponse.status).toBe(200); + const waitData = (await waitResponse.json()) as WaitForLogResult; + expect(waitData.line).toContain('Server ready on port 8080'); + + // Cleanup + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, 60000); + + test('should wait for port to become available', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Write a Bun server that listens on a port + const serverCode = ` +const server = Bun.serve({ + hostname: "0.0.0.0", + port: 9090, + fetch(req) { + return new Response("OK"); + }, +}); +console.log("Server started on " + server.hostname + ":" + server.port); +// Keep process alive +await Bun.sleep(60000); + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/portserver.js', + content: serverCode + }) + }); + + // Start the process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'bun run /workspace/portserver.js' + }) + }); + + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; + + // Wait for port 9090 to be available + const waitResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForPort`, + { + method: 'POST', + headers, + body: JSON.stringify({ + port: 9090, + timeout: 15000 + }) + } + ); + + expect(waitResponse.status).toBe(200); + + // Verify the port is actually listening by trying to curl it + const verifyResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'curl -s http://localhost:9090' + }) + }); + + const verifyData = (await verifyResponse.json()) as { stdout: string }; + expect(verifyData.stdout).toBe('OK'); + + // Cleanup + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, 60000); + + test('should chain waitForLog and waitForPort for multiple conditions', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Write a script with delayed ready message and a server + const scriptCode = ` +console.log("Initializing..."); +await Bun.sleep(500); +console.log("Database connected"); +await Bun.sleep(500); +const server = Bun.serve({ + hostname: "0.0.0.0", + port: 9091, + fetch(req) { return new Response("Ready"); }, +}); +console.log("Ready to serve requests"); +await Bun.sleep(60000); + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/app.js', + content: scriptCode + }) + }); + + // Start the process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'bun run /workspace/app.js' + }) + }); + + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; + + // Wait for log pattern first + const waitLogResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForLog`, + { + method: 'POST', + headers, + body: JSON.stringify({ + pattern: 'Database connected', + timeout: 10000 + }) + } + ); + expect(waitLogResponse.status).toBe(200); + + // Then wait for port + const waitPortResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForPort`, + { + method: 'POST', + headers, + body: JSON.stringify({ + port: 9091, + timeout: 10000 + }) + } + ); + expect(waitPortResponse.status).toBe(200); + + // Cleanup + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, 60000); + + test('should fail with timeout error if pattern never appears', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Write a script that never outputs the expected pattern + const scriptCode = ` +console.log("Starting..."); +console.log("Still starting..."); +await Bun.sleep(60000); + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/slow.js', + content: scriptCode + }) + }); + + // Start the process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'bun run /workspace/slow.js' + }) + }); + + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; + + // Wait for pattern with short timeout - should fail + const waitResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForLog`, + { + method: 'POST', + headers, + body: JSON.stringify({ + pattern: 'Server ready', + timeout: 2000 + }) + } + ); + + // Should fail with timeout + expect(waitResponse.status).toBe(500); + const errorData = (await waitResponse.json()) as { error: string }; + expect(errorData.error).toMatch(/timeout|did not become ready/i); + + // Cleanup + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, 60000); + + test('should fail with error if process exits before pattern appears', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Start a process that exits immediately + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "quick exit"' + }) + }); + + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; + + // Wait for pattern - should fail because process exits + const waitResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForLog`, + { + method: 'POST', + headers, + body: JSON.stringify({ + pattern: 'Server ready', + timeout: 10000 + }) + } + ); + + // Should fail because process exits before pattern appears + expect(waitResponse.status).toBe(500); + const errorData = (await waitResponse.json()) as { error: string }; + expect(errorData.error).toMatch( + /exited|exit|timeout|did not become ready/i + ); + }, 60000); + + test('should detect pattern in stderr as well as stdout', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Write a script that outputs to stderr + const scriptCode = ` +console.error("Starting up in stderr..."); +await Bun.sleep(300); +console.error("Ready (stderr)"); +await Bun.sleep(60000); + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/stderr.js', + content: scriptCode + }) + }); + + // Start the process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'bun run /workspace/stderr.js' + }) + }); + + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; + + // Wait for the pattern (which appears in stderr) + const waitResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForLog`, + { + method: 'POST', + headers, + body: JSON.stringify({ + pattern: 'Ready (stderr)', + timeout: 10000 + }) + } + ); + + expect(waitResponse.status).toBe(200); + const waitData = (await waitResponse.json()) as WaitForLogResult; + expect(waitData.line).toContain('Ready (stderr)'); + + // Cleanup + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, 60000); + + test.skipIf(skipPortExposureTests)( + 'should start server, wait for port, and expose it', + async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Write a simple HTTP server + const serverCode = ` +const server = Bun.serve({ + port: 8080, + fetch(req) { + return new Response(JSON.stringify({ message: "Hello!" }), { + headers: { "Content-Type": "application/json" } + }); + }, +}); +console.log("Server listening on port 8080"); + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/http-server.js', + content: serverCode + }) + }); + + // Start the process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'bun run /workspace/http-server.js' + }) + }); + + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; + + // Wait for port + const waitPortResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForPort`, + { + method: 'POST', + headers, + body: JSON.stringify({ + port: 8080, + timeout: 30000 + }) + } + ); + expect(waitPortResponse.status).toBe(200); + + // Expose the port + const exposeResponse = await fetch(`${workerUrl}/api/port/expose`, { + method: 'POST', + headers, + body: JSON.stringify({ + port: 8080 + }) + }); + + expect(exposeResponse.status).toBe(200); + const exposeData = (await exposeResponse.json()) as PortExposeResult; + expect(exposeData.url).toBeTruthy(); + + // Make a request to the exposed URL + const apiResponse = await fetch(exposeData.url); + expect(apiResponse.status).toBe(200); + const apiData = (await apiResponse.json()) as { message: string }; + expect(apiData.message).toBe('Hello!'); + + // Cleanup + await fetch(`${workerUrl}/api/exposed-ports/8080`, { + method: 'DELETE' + }); + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, + 90000 + ); + }); +}); diff --git a/tests/e2e/test-worker/index.ts b/tests/e2e/test-worker/index.ts index 6110a0f0..d8f5b776 100644 --- a/tests/e2e/test-worker/index.ts +++ b/tests/e2e/test-worker/index.ts @@ -510,6 +510,65 @@ console.log('Terminal server on port ' + port); }); } + // Process waitForLog - waits for a log pattern + if ( + url.pathname.startsWith('/api/process/') && + url.pathname.endsWith('/waitForLog') && + request.method === 'POST' + ) { + const pathParts = url.pathname.split('/'); + const processId = pathParts[3]; + const process = await executor.getProcess(processId); + if (!process) { + return new Response(JSON.stringify({ error: 'Process not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' } + }); + } + // pattern can be string or regex pattern (as string starting with /) + let pattern = body.pattern; + if ( + typeof pattern === 'string' && + pattern.startsWith('/') && + pattern.endsWith('/') + ) { + // Convert regex string to RegExp + pattern = new RegExp(pattern.slice(1, -1)); + } + const result = await process.waitForLog(pattern, body.timeout); + return new Response(JSON.stringify(result), { + headers: { 'Content-Type': 'application/json' } + }); + } + + // Process waitForPort - waits for a port to be available + if ( + url.pathname.startsWith('/api/process/') && + url.pathname.endsWith('/waitForPort') && + request.method === 'POST' + ) { + const pathParts = url.pathname.split('/'); + const processId = pathParts[3]; + const process = await executor.getProcess(processId); + if (!process) { + return new Response(JSON.stringify({ error: 'Process not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' } + }); + } + // Build WaitForPortOptions from request body + await process.waitForPort(body.port, { + mode: body.mode, + path: body.path, + status: body.status, + timeout: body.timeout, + interval: body.interval + }); + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json' } + }); + } + // Process list if (url.pathname === '/api/process/list' && request.method === 'GET') { const processes = await executor.listProcesses();