diff --git a/CLAUDE.md b/CLAUDE.md index 67e76768..e6a66f4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ npm run build:clean # Force rebuild without cache # Unit tests (runs in Workers runtime with vitest-pool-workers) npm test -# E2E tests (requires Docker, runs sequentially due to container provisioning) +# E2E tests (requires Docker) npm run test:e2e # Run a single E2E test file @@ -74,7 +74,7 @@ npm run test:e2e -- -- tests/e2e/process-lifecycle-workflow.test.ts npm run test:e2e -- -- tests/e2e/git-clone-workflow.test.ts -t 'test name' ``` -**Important**: E2E tests (`tests/e2e/`) run sequentially (not in parallel) to avoid container resource contention. Each test spawns its own wrangler dev instance. +**Important**: E2E tests share a single sandbox container for performance. Tests run in parallel using unique sessions for isolation. ### Code Quality @@ -211,11 +211,12 @@ npm run test:e2e -- -- tests/e2e/git-clone-workflow.test.ts -t 'should handle cl **Architecture:** - Tests in `tests/e2e/` run against real Cloudflare Workers + Docker containers -- **In CI**: Tests deploy to actual Cloudflare infrastructure and run against deployed workers -- **Locally**: Each test file spawns its own `wrangler dev` instance +- **Shared sandbox**: All tests share ONE container, using sessions for isolation +- **In CI**: Tests deploy to actual Cloudflare infrastructure +- **Locally**: Global setup spawns wrangler dev once, all tests share it - Config: `vitest.e2e.config.ts` (root level) -- Sequential execution (`singleFork: true`) to prevent container resource contention -- Longer timeouts (2min per test) for container operations +- Parallel execution via thread pool (~30s for full suite) +- See `docs/E2E_TESTING.md` for writing tests **Build system trust:** The monorepo build system (turbo + npm workspaces) is robust and handles all package dependencies automatically. E2E tests always run against the latest built code - there's no need to manually rebuild or worry about stale builds unless explicitly working on the build setup itself. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 21be3cd2..44de173c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -165,12 +165,11 @@ Located in `tests/e2e/`: - Test full workflows against real Workers and containers - Require Docker -- Slower but comprehensive +- Share a single sandbox container for performance (~30s for full suite) +- Use sessions for test isolation Run with: `npm run test:e2e` -You can also run specific test files or individual tests: - ```bash # Run a single E2E test file npm run test:e2e -- -- tests/e2e/process-lifecycle-workflow.test.ts @@ -179,12 +178,15 @@ npm run test:e2e -- -- tests/e2e/process-lifecycle-workflow.test.ts npm run test:e2e -- -- tests/e2e/git-clone-workflow.test.ts -t 'should handle cloning to default directory' ``` +**See `docs/E2E_TESTING.md` for the complete guide on writing E2E tests.** + ### Writing Tests - Write tests for new features - Add regression tests for bug fixes - Ensure tests are deterministic (no flaky tests) - Use descriptive test names +- For E2E tests: use `getSharedSandbox()` and `createUniqueSession()` for isolation ## Documentation diff --git a/docs/E2E_TESTING.md b/docs/E2E_TESTING.md new file mode 100644 index 00000000..f33dd6b5 --- /dev/null +++ b/docs/E2E_TESTING.md @@ -0,0 +1,165 @@ +# E2E Testing Guide + +E2E tests validate full workflows against real Cloudflare Workers and Docker containers. + +## Architecture + +All E2E tests share a **single sandbox container** for performance. Test isolation is achieved through **sessions** - each test file gets a unique session that provides isolated shell state (env vars, working directory) within the shared container. + +``` +┌─────────────────────────────────────────────────────┐ +│ Shared Sandbox │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Session A │ │ Session B │ │ Session C │ │ +│ │ (test 1) │ │ (test 2) │ │ (test 3) │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +│ Shared filesystem & processes │ +└─────────────────────────────────────────────────────┘ +``` + +**Key files:** + +- `tests/e2e/global-setup.ts` - Creates sandbox before tests, warms containers +- `tests/e2e/helpers/global-sandbox.ts` - Provides `getSharedSandbox()` API +- `vitest.e2e.config.ts` - Configures parallel execution with global setup + +## Writing Tests + +### Basic Template + +```typescript +import { describe, test, expect, beforeAll } from 'vitest'; +import { + getSharedSandbox, + createUniqueSession +} from './helpers/global-sandbox'; + +describe('My Feature', () => { + let workerUrl: string; + let headers: Record; + + beforeAll(async () => { + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + headers = sandbox.createHeaders(createUniqueSession()); + }, 120000); + + test('should do something', async () => { + const response = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ command: 'echo hello' }) + }); + expect(response.status).toBe(200); + }, 60000); +}); +``` + +### Using Python Image + +For tests requiring Python (code interpreter, etc.): + +```typescript +beforeAll(async () => { + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + // Use createPythonHeaders instead of createHeaders + headers = sandbox.createPythonHeaders(createUniqueSession()); +}, 120000); +``` + +### File Isolation + +Since the filesystem is shared, use unique paths to avoid conflicts: + +```typescript +const sandbox = await getSharedSandbox(); +const testDir = sandbox.uniquePath('my-feature'); // /workspace/test-abc123/my-feature + +await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: `${testDir}/config.json`, + content: '{"key": "value"}' + }) +}); +``` + +### Port Usage + +Ports must be exposed in the Dockerfile. Currently exposed: + +- `8080` - General testing +- `9090`, `9091`, `9092` - Process readiness tests +- `9998` - Process lifecycle tests +- `9999` - WebSocket tests + +To use a new port: + +1. Add it to both `tests/e2e/test-worker/Dockerfile` and `Dockerfile.python` +2. Document which test uses it + +### Process Cleanup + +Always clean up background processes: + +```typescript +test('should start server', async () => { + const startRes = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ command: 'bun run server.js' }) + }); + const { id: processId } = await startRes.json(); + + // ... test logic ... + + // Cleanup + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); +}, 60000); +``` + +## Test Organization + +| File | Purpose | +| --------------------------------------- | ---------------------------- | +| `comprehensive-workflow.test.ts` | Happy path integration tests | +| `process-lifecycle-workflow.test.ts` | Error handling for processes | +| `process-readiness-workflow.test.ts` | waitForLog/waitForPort tests | +| `code-interpreter-workflow.test.ts` | Python/JS code execution | +| `file-operations-workflow.test.ts` | File read/write/list | +| `streaming-operations-workflow.test.ts` | Streaming command output | +| `websocket-workflow.test.ts` | WebSocket connections | +| `bucket-mounting.test.ts` | R2 bucket mounting (CI only) | + +## Running Tests + +```bash +# All E2E tests +npm run test:e2e + +# Single file +npm run test:e2e -- -- tests/e2e/process-lifecycle-workflow.test.ts + +# Single test by name +npm run test:e2e -- -- tests/e2e/git-clone-workflow.test.ts -t 'should clone repo' +``` + +## Debugging + +- Tests auto-retry once on failure (`retry: 1` in config) +- Global setup logs sandbox ID on startup - check for initialization errors +- If tests fail on first run only, the container might not be warmed (check global-setup.ts initializes the right image type) +- Port conflicts: check no other test uses the same port + +## What NOT to Do + +- **Don't create new sandboxes unless strictly necessary** - use `getSharedSandbox()` +- **Don't skip cleanup** - leaked processes affect other tests +- **Don't use hardcoded ports** without adding to Dockerfile +- **Don't rely on filesystem state** from other tests - use unique paths diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index acdeb064..f25241a6 100644 --- a/packages/sandbox/package.json +++ b/packages/sandbox/package.json @@ -36,7 +36,7 @@ "typecheck": "tsc --noEmit", "docker:local": "cd ../.. && docker build -f packages/sandbox/Dockerfile --target default --platform linux/amd64 --build-arg SANDBOX_VERSION=$npm_package_version -t cloudflare/sandbox-test:$npm_package_version . && docker build -f packages/sandbox/Dockerfile --target python --platform linux/amd64 --build-arg SANDBOX_VERSION=$npm_package_version -t cloudflare/sandbox-test:$npm_package_version-python .", "test": "vitest run --config vitest.config.ts \"$@\"", - "test:e2e": "cd ../.. && cd tests/e2e/test-worker && ./generate-config.sh && cd ../../.. && vitest run --config vitest.e2e.config.ts \"$@\"" + "test:e2e": "cd ../../tests/e2e/test-worker && ./generate-config.sh && cd ../../.. && vitest run --config vitest.e2e.config.ts \"$@\"" }, "exports": { ".": { diff --git a/tests/e2e/_smoke.test.ts b/tests/e2e/_smoke.test.ts index c5535110..cfe4df4d 100644 --- a/tests/e2e/_smoke.test.ts +++ b/tests/e2e/_smoke.test.ts @@ -1,6 +1,5 @@ -import { describe, test, expect, beforeAll, afterAll, afterEach } from 'vitest'; -import { getTestWorkerUrl, WranglerDevRunner } from './helpers/wrangler-runner'; -import { createSandboxId, cleanupSandbox } from './helpers/test-fixtures'; +import { describe, test, expect, beforeAll } from 'vitest'; +import { getSharedSandbox } from './helpers/global-sandbox'; import type { HealthResponse } from './test-worker/types'; /** @@ -9,50 +8,25 @@ import type { HealthResponse } from './test-worker/types'; * This test validates that: * 1. Can get worker URL (deployed in CI, wrangler dev locally) * 2. Worker is running and responding - * 3. Can cleanup properly + * 3. Shared sandbox initializes correctly * - * NOTE: This is just infrastructure validation. Real SDK integration - * tests will be in the workflow test suites. + * NOTE: This test runs first (sorted by name) and initializes the shared sandbox. */ describe('Integration Infrastructure Smoke Test', () => { - describe('local', () => { - let runner: WranglerDevRunner | null = null; - let workerUrl: string; - let currentSandboxId: string | null = null; + let workerUrl: string; - beforeAll(async () => { - const result = await getTestWorkerUrl(); - workerUrl = result.url; - runner = result.runner; - }); + beforeAll(async () => { + // Initialize shared sandbox - this will be reused by all other tests + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + }, 120000); - afterEach(async () => { - // Cleanup sandbox container after each test - if (currentSandboxId) { - await cleanupSandbox(workerUrl, currentSandboxId); - currentSandboxId = null; - } - }); + test('should verify worker is running with health check', async () => { + // Verify worker is running with health check + const response = await fetch(`${workerUrl}/health`); + expect(response.status).toBe(200); - afterAll(async () => { - if (runner) { - await runner.stop(); - } - }); - - test('should verify worker is running with health check', async () => { - // Verify worker is running with health check - const response = await fetch(`${workerUrl}/health`); - expect(response.status).toBe(200); - - const data = (await response.json()) as HealthResponse; - expect(data.status).toBe('ok'); - - // In local mode, verify stdout captured wrangler startup - if (runner) { - const stdout = runner.getStdout(); - expect(stdout).toContain('Ready on'); - } - }); + const data = (await response.json()) as HealthResponse; + expect(data.status).toBe('ok'); }); }); diff --git a/tests/e2e/bucket-mounting.test.ts b/tests/e2e/bucket-mounting.test.ts index 01b683b0..1a7152ba 100644 --- a/tests/e2e/bucket-mounting.test.ts +++ b/tests/e2e/bucket-mounting.test.ts @@ -1,13 +1,8 @@ -import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; +import { beforeAll, describe, expect, test } from 'vitest'; import { - cleanupSandbox, - createSandboxId, - createTestHeaders -} from './helpers/test-fixtures'; -import { - getTestWorkerUrl, - type WranglerDevRunner -} from './helpers/wrangler-runner'; + getSharedSandbox, + createUniqueSession +} from './helpers/global-sandbox'; import type { ExecResult } from '@repo/shared'; import type { SuccessResponse, BucketGetResponse } from './test-worker/types'; @@ -33,9 +28,8 @@ describe('Bucket Mounting E2E', () => { } describe('local', () => { - let runner: WranglerDevRunner | null; let workerUrl: string; - let currentSandboxId: string | null = null; + let headers: Record; const TEST_BUCKET = 'sandbox-e2e-test'; const MOUNT_PATH = '/mnt/test-data'; @@ -43,23 +37,10 @@ describe('Bucket Mounting E2E', () => { const TEST_CONTENT = `Bucket mounting E2E test - ${new Date().toISOString()}`; beforeAll(async () => { - const result = await getTestWorkerUrl(); - workerUrl = result.url; - runner = result.runner; - }, 30000); - - afterEach(async () => { - if (currentSandboxId) { - await cleanupSandbox(workerUrl, currentSandboxId); - currentSandboxId = null; - } - }); - - afterAll(async () => { - if (runner) { - await runner.stop(); - } - }); + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + headers = sandbox.createHeaders(createUniqueSession()); + }, 120000); test('should mount bucket and perform bidirectional file operations', async () => { // Verify required credentials are present @@ -76,9 +57,6 @@ describe('Bucket Mounting E2E', () => { ); } - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - const PRE_EXISTING_FILE = `pre-existing-${Date.now()}.txt`; const PRE_EXISTING_CONTENT = 'This file was created in R2 before mounting'; diff --git a/tests/e2e/build-test-workflow.test.ts b/tests/e2e/build-test-workflow.test.ts index 57d71668..7d3b8ed0 100644 --- a/tests/e2e/build-test-workflow.test.ts +++ b/tests/e2e/build-test-workflow.test.ts @@ -1,51 +1,27 @@ -import { describe, test, expect, beforeAll, afterAll, vi } from 'vitest'; -import { getTestWorkerUrl, WranglerDevRunner } from './helpers/wrangler-runner'; +import { describe, test, expect, beforeAll } from 'vitest'; import { - createSandboxId, - createTestHeaders, - cleanupSandbox -} from './helpers/test-fixtures'; + getSharedSandbox, + createUniqueSession +} from './helpers/global-sandbox'; import type { ExecResult, WriteFileResult, ReadFileResult } from '@repo/shared'; import type { ErrorResponse } from './test-worker/types'; /** * Build and Test Workflow Integration Tests * - * Tests the README "Build and Test Code" example (lines 343-362): - * - Clone a repository - * - Run tests - * - Build the project - * - * This validates the complete CI/CD workflow: - * Git → Install → Test → Build + * Tests the README "Build and Test Code" example. + * Uses the shared sandbox with a unique session. */ describe('Build and Test Workflow', () => { describe('local', () => { - let runner: WranglerDevRunner | null; let workerUrl: string; - let currentSandboxId: string; let headers: Record; beforeAll(async () => { - // Get test worker URL (CI: uses deployed URL, Local: spawns wrangler dev) - const result = await getTestWorkerUrl(); - workerUrl = result.url; - runner = result.runner; - - // Create a single sandbox for all tests in this suite - currentSandboxId = createSandboxId(); - headers = createTestHeaders(currentSandboxId); - }); - - afterAll(async () => { - // Cleanup sandbox container after all tests - await cleanupSandbox(workerUrl, currentSandboxId); - - // Cleanup wrangler process (only in local mode) - if (runner) { - await runner.stop(); - } - }); + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + headers = sandbox.createHeaders(createUniqueSession()); + }, 120000); test('should execute basic commands and verify file operations', async () => { // Step 1: Execute simple command diff --git a/tests/e2e/code-interpreter-workflow.test.ts b/tests/e2e/code-interpreter-workflow.test.ts index 80ae0044..22a76a48 100644 --- a/tests/e2e/code-interpreter-workflow.test.ts +++ b/tests/e2e/code-interpreter-workflow.test.ts @@ -12,631 +12,270 @@ * * These tests validate the README "Data Analysis with Code Interpreter" examples * and ensure the code interpreter works end-to-end in a real container environment. + * */ +import { beforeAll, describe, expect, test } from 'vitest'; import { - afterAll, - afterEach, - beforeAll, - describe, - expect, - test, - vi -} from 'vitest'; -import { getTestWorkerUrl, WranglerDevRunner } from './helpers/wrangler-runner'; -import { - createSandboxId, - createTestHeaders, - createPythonImageHeaders, - cleanupSandbox -} from './helpers/test-fixtures'; + getSharedSandbox, + createUniqueSession +} from './helpers/global-sandbox'; import type { CodeContext, ExecutionResult } from '@repo/shared'; import type { ErrorResponse } from './test-worker/types'; describe('Code Interpreter Workflow (E2E)', () => { - let runner: WranglerDevRunner | null; let workerUrl: string; - let currentSandboxId: string | null = null; + let headers: Record; beforeAll(async () => { - const result = await getTestWorkerUrl(); - workerUrl = result.url; - runner = result.runner; + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + headers = sandbox.createPythonHeaders(createUniqueSession()); }, 120000); - afterEach(async () => { - if (currentSandboxId) { - await cleanupSandbox(workerUrl, currentSandboxId); - currentSandboxId = null; - } - }); + // Helper to create context + async function createContext(language: 'python' | 'javascript') { + const res = await fetch(`${workerUrl}/api/code/context/create`, { + method: 'POST', + headers, + body: JSON.stringify({ language }) + }); + expect(res.status).toBe(200); + return (await res.json()) as CodeContext; + } - afterAll(async () => { - if (runner) { - await runner.stop(); - } - }); + // Helper to execute code + async function executeCode(context: CodeContext, code: string) { + const res = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ code, options: { context } }) + }); + expect(res.status).toBe(200); + return (await res.json()) as ExecutionResult; + } + + // Helper to delete context + async function deleteContext(contextId: string) { + return fetch(`${workerUrl}/api/code/context/${contextId}`, { + method: 'DELETE', + headers + }); + } // ============================================================================ - // Context Management + // Test 1: Context Lifecycle (create, list, delete) // ============================================================================ - test('should create and list code contexts', async () => { - currentSandboxId = createSandboxId(); - const headers = createPythonImageHeaders(currentSandboxId); - + test('context lifecycle: create, list, and delete contexts', async () => { // Create Python context - const pythonCtxResponse = await fetch( - `${workerUrl}/api/code/context/create`, - { - method: 'POST', - headers, - body: JSON.stringify({ language: 'python' }) - } - ); - - expect(pythonCtxResponse.status).toBe(200); - const pythonCtx = (await pythonCtxResponse.json()) as CodeContext; + const pythonCtx = await createContext('python'); expect(pythonCtx.id).toBeTruthy(); expect(pythonCtx.language).toBe('python'); // Create JavaScript context - const jsCtxResponse = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'javascript' }) - }); - - expect(jsCtxResponse.status).toBe(200); - const jsCtx = (await jsCtxResponse.json()) as CodeContext; + const jsCtx = await createContext('javascript'); expect(jsCtx.id).toBeTruthy(); expect(jsCtx.language).toBe('javascript'); - expect(jsCtx.id).not.toBe(pythonCtx.id); // Different contexts + expect(jsCtx.id).not.toBe(pythonCtx.id); - // List all contexts + // List all contexts - should contain both const listResponse = await fetch(`${workerUrl}/api/code/context/list`, { method: 'GET', headers }); - expect(listResponse.status).toBe(200); const contexts = (await listResponse.json()) as CodeContext[]; - expect(Array.isArray(contexts)).toBe(true); expect(contexts.length).toBeGreaterThanOrEqual(2); - const contextIds = contexts.map((ctx) => ctx.id); expect(contextIds).toContain(pythonCtx.id); expect(contextIds).toContain(jsCtx.id); - }, 120000); - - test('should delete code context', async () => { - currentSandboxId = createSandboxId(); - const headers = createPythonImageHeaders(currentSandboxId); - - // Create context - const createResponse = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'python' }) - }); - - const context = (await createResponse.json()) as CodeContext; - const contextId = context.id; - - // Delete context - const deleteResponse = await fetch( - `${workerUrl}/api/code/context/${contextId}`, - { - method: 'DELETE', - headers - } - ); + // Delete Python context + const deleteResponse = await deleteContext(pythonCtx.id); expect(deleteResponse.status).toBe(200); const deleteData = (await deleteResponse.json()) as { success: boolean }; expect(deleteData.success).toBe(true); // Verify context is removed from list - const listResponse = await fetch(`${workerUrl}/api/code/context/list`, { + const listAfterDelete = await fetch(`${workerUrl}/api/code/context/list`, { method: 'GET', headers }); + const contextsAfter = (await listAfterDelete.json()) as CodeContext[]; + expect(contextsAfter.map((c) => c.id)).not.toContain(pythonCtx.id); + expect(contextsAfter.map((c) => c.id)).toContain(jsCtx.id); - const contexts = (await listResponse.json()) as CodeContext[]; - const contextIds = contexts.map((ctx) => ctx.id); - expect(contextIds).not.toContain(contextId); + // Cleanup + await deleteContext(jsCtx.id); }, 120000); // ============================================================================ - // Python Code Execution + // Test 2: Python Workflow (execute, state persistence, errors) // ============================================================================ - test('should execute simple Python code', async () => { - currentSandboxId = createSandboxId(); - const headers = createPythonImageHeaders(currentSandboxId); - - // Create Python context - const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'python' }) - }); - - const context = await ctxResponse.json(); - - // Execute Python code - const execResponse = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: 'print("Hello from Python!")', - options: { context } - }) - }); - - expect(execResponse.status).toBe(200); - const execution = (await execResponse.json()) as ExecutionResult; - - expect(execution.code).toBe('print("Hello from Python!")'); - expect(execution.logs.stdout.join('')).toContain('Hello from Python!'); - expect(execution.error).toBeUndefined(); - }, 120000); - - test('should maintain Python state across executions', async () => { - currentSandboxId = createSandboxId(); - const headers = createPythonImageHeaders(currentSandboxId); - - // Create context - const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'python' }) - }); - - const context = await ctxResponse.json(); - - // Set variable in first execution - const exec1Response = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: 'x = 42\ny = 10', - options: { context } - }) - }); - - expect(exec1Response.status).toBe(200); - const execution1 = (await exec1Response.json()) as ExecutionResult; - expect(execution1.error).toBeUndefined(); - - // Use variable in second execution - const exec2Response = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: 'result = x + y\nprint(result)', - options: { context } - }) - }); - - expect(exec2Response.status).toBe(200); - const execution2 = (await exec2Response.json()) as ExecutionResult; - expect(execution2.logs.stdout.join('')).toContain('52'); - expect(execution2.error).toBeUndefined(); - }, 120000); - - test('should handle Python errors gracefully', async () => { - currentSandboxId = createSandboxId(); - const headers = createPythonImageHeaders(currentSandboxId); - - // Create context - const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'python' }) - }); - - const context = await ctxResponse.json(); - - // Execute code with division by zero error - const execResponse = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: 'x = 1 / 0', - options: { context } - }) - }); - - expect(execResponse.status).toBe(200); - const execution = (await execResponse.json()) as ExecutionResult; - - expect(execution.error).toBeDefined(); - if (!execution.error) throw new Error('Expected error to be defined'); - - expect(execution.error.name).toContain('Error'); - expect(execution.error.message || execution.error.traceback).toContain( + test('Python workflow: execute, maintain state, handle errors', async () => { + const ctx = await createContext('python'); + + // Simple execution + const exec1 = await executeCode(ctx, 'print("Hello from Python!")'); + expect(exec1.code).toBe('print("Hello from Python!")'); + expect(exec1.logs.stdout.join('')).toContain('Hello from Python!'); + expect(exec1.error).toBeUndefined(); + + // Set variables for state persistence + const exec2 = await executeCode(ctx, 'x = 42\ny = 10'); + expect(exec2.error).toBeUndefined(); + + // Verify state persists across executions + const exec3 = await executeCode(ctx, 'result = x + y\nprint(result)'); + expect(exec3.logs.stdout.join('')).toContain('52'); + expect(exec3.error).toBeUndefined(); + + // Error handling - division by zero + const exec4 = await executeCode(ctx, 'x = 1 / 0'); + expect(exec4.error).toBeDefined(); + expect(exec4.error!.name).toContain('Error'); + expect(exec4.error!.message || exec4.error!.traceback).toContain( 'division' ); - }, 120000); - - // ============================================================================ - // JavaScript Code Execution - // ============================================================================ - - test('should execute simple JavaScript code', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create JavaScript context - const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'javascript' }) - }); - - const context = await ctxResponse.json(); - - // Execute JavaScript code - const execResponse = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: 'console.log("Hello from JavaScript!");', - options: { context } - }) - }); - - expect(execResponse.status).toBe(200); - const execution = (await execResponse.json()) as ExecutionResult; - - expect(execution.logs.stdout.join('')).toContain('Hello from JavaScript!'); - expect(execution.error).toBeUndefined(); - }, 120000); - - test('should maintain JavaScript state across executions', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create context - const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'javascript' }) - }); - - const context = await ctxResponse.json(); - - // Set global variable - const exec1Response = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: 'global.counter = 0;', - options: { context } - }) - }); - - expect(exec1Response.status).toBe(200); - - // Increment and read variable - const exec2Response = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: 'console.log(++global.counter);', - options: { context } - }) - }); - - expect(exec2Response.status).toBe(200); - const execution2 = (await exec2Response.json()) as ExecutionResult; - expect(execution2.logs.stdout.join('')).toContain('1'); - }, 120000); - - test('should handle JavaScript errors gracefully', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create context - const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'javascript' }) - }); - - const context = await ctxResponse.json(); - - // Execute code with reference error - const execResponse = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: 'console.log(undefinedVariable);', - options: { context } - }) - }); - - expect(execResponse.status).toBe(200); - const execution = (await execResponse.json()) as ExecutionResult; - - expect(execution.error).toBeDefined(); - if (!execution.error) throw new Error('Expected error to be defined'); - expect(execution.error.name || execution.error.message).toMatch( - /Error|undefined/i - ); + // Cleanup + await deleteContext(ctx.id); }, 120000); // ============================================================================ - // Top-Level Await Support (JavaScript) + // Test 3: JavaScript Workflow (execute, state, top-level await, IIFE, errors) // ============================================================================ - test('should execute top-level await without IIFE wrapper', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); + test('JavaScript workflow: execute, state, top-level await, IIFE, errors', async () => { + const ctx = await createContext('javascript'); - // Create JavaScript context - const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'javascript' }) - }); - - const context = await ctxResponse.json(); - - // Execute code with top-level await - no IIFE needed! - const execResponse = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: 'const result = await Promise.resolve(42);\nresult', - options: { context } - }) - }); - - expect(execResponse.status).toBe(200); - const execution = (await execResponse.json()) as ExecutionResult; - - expect(execution.error).toBeUndefined(); - expect(execution.results).toBeDefined(); - expect(execution.results![0].text).toContain('42'); - }, 120000); - - test('should return last expression value from async code', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create JavaScript context - const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'javascript' }) - }); - - const context = await ctxResponse.json(); + // Simple execution + const exec1 = await executeCode( + ctx, + 'console.log("Hello from JavaScript!");' + ); + expect(exec1.logs.stdout.join('')).toContain('Hello from JavaScript!'); + expect(exec1.error).toBeUndefined(); + + // State persistence with global + await executeCode(ctx, 'global.counter = 0;'); + const exec2 = await executeCode(ctx, 'console.log(++global.counter);'); + expect(exec2.logs.stdout.join('')).toContain('1'); + + // Top-level await - basic + const exec3 = await executeCode( + ctx, + 'const result = await Promise.resolve(42);\nresult' + ); + expect(exec3.error).toBeUndefined(); + expect(exec3.results![0].text).toContain('42'); - // Execute multiple awaits, return last expression - const execResponse = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: ` + // Top-level await - multiple awaits returning last expression + const exec4 = await executeCode( + ctx, + ` const a = await Promise.resolve(10); const b = await Promise.resolve(20); a + b -`.trim(), - options: { context } - }) - }); - - expect(execResponse.status).toBe(200); - const execution = (await execResponse.json()) as ExecutionResult; - - expect(execution.error).toBeUndefined(); - expect(execution.results).toBeDefined(); - expect(execution.results![0].text).toContain('30'); - }, 120000); - - test('should handle async errors in top-level await', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create JavaScript context - const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'javascript' }) - }); - - const context = await ctxResponse.json(); - - // Execute code that rejects - const execResponse = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: 'await Promise.reject(new Error("async error"))', - options: { context } - }) - }); - - expect(execResponse.status).toBe(200); - const execution = (await execResponse.json()) as ExecutionResult; - - expect(execution.error).toBeDefined(); - expect( - execution.error!.message || execution.logs.stderr.join('') - ).toContain('async error'); - }, 120000); - - test('should support LLM-generated fetch pattern with top-level await', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create JavaScript context - const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'javascript' }) - }); +`.trim() + ); + expect(exec4.error).toBeUndefined(); + expect(exec4.results![0].text).toContain('30'); - const context = await ctxResponse.json(); + // Top-level await - async error handling + const exec5 = await executeCode( + ctx, + 'await Promise.reject(new Error("async error"))' + ); + expect(exec5.error).toBeDefined(); + expect(exec5.error!.message || exec5.logs.stderr.join('')).toContain( + 'async error' + ); - // Simulate typical LLM-generated code pattern using setTimeout as async operation - const execResponse = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: ` + // Top-level await - LLM-generated pattern with delay + const exec6 = await executeCode( + ctx, + ` const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); await delay(10); const data = { status: 'success', value: 123 }; data -`.trim(), - options: { context } - }) - }); - - expect(execResponse.status).toBe(200); - const execution = (await execResponse.json()) as ExecutionResult; - - expect(execution.error).toBeUndefined(); - expect(execution.results).toBeDefined(); - // Object results come back as JSON, not text - const result = execution.results![0]; - const resultData = result.json ?? result.text; - expect(resultData).toBeDefined(); +`.trim() + ); + expect(exec6.error).toBeUndefined(); + const resultData = exec6.results![0].json ?? exec6.results![0].text; expect(JSON.stringify(resultData)).toContain('success'); - expect(JSON.stringify(resultData)).toContain('123'); - }, 120000); - - test('should persist variables defined with await across executions', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create JavaScript context - const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'javascript' }) - }); - - const context = await ctxResponse.json(); - // First execution: define variable with await - const exec1Response = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: 'const result = await Promise.resolve(42);', - options: { context } - }) - }); + // Variable persistence with await across executions + await executeCode(ctx, 'const persistedValue = await Promise.resolve(99);'); + const exec7 = await executeCode(ctx, 'persistedValue'); + expect(exec7.results![0].text).toContain('99'); - expect(exec1Response.status).toBe(200); - const execution1 = (await exec1Response.json()) as ExecutionResult; - expect(execution1.error).toBeUndefined(); + // Promise auto-resolution without await keyword + const exec8 = await executeCode(ctx, 'Promise.resolve(123)'); + expect(exec8.error).toBeUndefined(); + expect(exec8.results![0].text).toContain('123'); - // Second execution: access the variable from previous execution - const exec2Response = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: 'result', - options: { context } - }) - }); - - expect(exec2Response.status).toBe(200); - const execution2 = (await exec2Response.json()) as ExecutionResult; - expect(execution2.error).toBeUndefined(); - expect(execution2.results).toBeDefined(); - expect(execution2.results![0].text).toContain('42'); - }, 120000); - - test('should resolve Promise without await keyword', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create JavaScript context - const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'javascript' }) - }); - - const context = await ctxResponse.json(); - - // Execute a Promise expression - should resolve automatically - const execResponse = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: 'Promise.resolve(123)', - options: { context } - }) - }); - - expect(execResponse.status).toBe(200); - const execution = (await execResponse.json()) as ExecutionResult; - expect(execution.error).toBeUndefined(); - expect(execution.results).toBeDefined(); - // The Promise should be automatically awaited and return 123 - expect(execution.results![0].text).toContain('123'); - }, 120000); - - test('should support IIFE pattern for backward compatibility', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create JavaScript context - const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'javascript' }) - }); - - const context = await ctxResponse.json(); - - // Execute code with IIFE pattern - const execResponse = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: `(async () => { + // IIFE pattern for backward compatibility + const exec9 = await executeCode( + ctx, + `(async () => { const value = await Promise.resolve('hello'); return value + ' world'; -})()`, - options: { context } - }) - }); +})()` + ); + expect(exec9.error).toBeUndefined(); + expect(exec9.results![0].text).toContain('hello world'); - expect(execResponse.status).toBe(200); - const execution = (await execResponse.json()) as ExecutionResult; - expect(execution.error).toBeUndefined(); - expect(execution.results).toBeDefined(); - expect(execution.results![0].text).toContain('hello world'); + // Error handling - reference error + const exec10 = await executeCode(ctx, 'console.log(undefinedVariable);'); + expect(exec10.error).toBeDefined(); + expect(exec10.error!.name || exec10.error!.message).toMatch( + /Error|undefined/i + ); + + // Cleanup + await deleteContext(ctx.id); }, 120000); // ============================================================================ - // Streaming Execution + // Test 4: Multi-language Workflow + Streaming // ============================================================================ - test('should stream Python execution output', async () => { - currentSandboxId = createSandboxId(); - const headers = createPythonImageHeaders(currentSandboxId); - - // Create context - const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'python' }) - }); + test('multi-language workflow: Python→JS data sharing + streaming', async () => { + // Create Python context and generate data + const pythonCtx = await createContext('python'); + const pythonExec = await executeCode( + pythonCtx, + ` +import json +data = {'values': [1, 2, 3, 4, 5]} +with open('/tmp/shared_data.json', 'w') as f: + json.dump(data, f) +print("Data saved") +`.trim() + ); + expect(pythonExec.error).toBeUndefined(); + expect(pythonExec.logs.stdout.join('')).toContain('Data saved'); - const context = await ctxResponse.json(); + // Create JavaScript context and consume data + const jsCtx = await createContext('javascript'); + const jsExec = await executeCode( + jsCtx, + ` +const fs = require('fs'); +const data = JSON.parse(fs.readFileSync('/tmp/shared_data.json', 'utf8')); +const sum = data.values.reduce((a, b) => a + b, 0); +console.log('Sum:', sum); +`.trim() + ); + expect(jsExec.error).toBeUndefined(); + expect(jsExec.logs.stdout.join('')).toContain('Sum: 15'); - // Execute code with streaming + // Test streaming execution + const streamCtx = await createContext('python'); const streamResponse = await fetch(`${workerUrl}/api/code/execute/stream`, { method: 'POST', headers, @@ -647,7 +286,7 @@ for i in range(3): print(f"Step {i}") time.sleep(0.1) `.trim(), - options: { context } + options: { context: streamCtx } }) }); @@ -660,451 +299,151 @@ for i in range(3): const reader = streamResponse.body?.getReader(); expect(reader).toBeDefined(); - if (!reader) return; - const decoder = new TextDecoder(); const events: any[] = []; let buffer = ''; - // Read entire stream while (true) { - const { done, value } = await reader.read(); + const { done, value } = await reader!.read(); if (done) break; - buffer += decoder.decode(value, { stream: true }); } // Parse SSE events - const lines = buffer.split('\n'); - for (const line of lines) { + for (const line of buffer.split('\n')) { if (line.startsWith('data: ')) { try { - const event = JSON.parse(line.slice(6)); - events.push(event); - } catch (e) { + events.push(JSON.parse(line.slice(6))); + } catch { // Ignore parse errors } } } - // Verify we received output events - expect(events.length).toBeGreaterThan(0); - - // Check for stdout events + // Verify streaming output const stdoutEvents = events.filter((e) => e.type === 'stdout'); expect(stdoutEvents.length).toBeGreaterThan(0); - - // Verify output content const allOutput = stdoutEvents.map((e) => e.text).join(''); expect(allOutput).toContain('Step 0'); expect(allOutput).toContain('Step 1'); expect(allOutput).toContain('Step 2'); - }, 120000); - - // ============================================================================ - // Multi-Language Workflow - // ============================================================================ - - test('should process data in Python and consume in JavaScript', async () => { - currentSandboxId = createSandboxId(); - const headers = createPythonImageHeaders(currentSandboxId); - - // Create Python context - const pythonCtxResponse = await fetch( - `${workerUrl}/api/code/context/create`, - { - method: 'POST', - headers, - body: JSON.stringify({ language: 'python' }) - } - ); - - const pythonCtx = (await pythonCtxResponse.json()) as CodeContext; - - // Generate data in Python and save to file - const pythonExecResponse = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: ` -import json -data = {'values': [1, 2, 3, 4, 5]} -with open('/tmp/shared_data.json', 'w') as f: - json.dump(data, f) -print("Data saved") -`.trim(), - options: { context: pythonCtx } - }) - }); - - expect(pythonExecResponse.status).toBe(200); - const pythonExec = (await pythonExecResponse.json()) as ExecutionResult; - expect(pythonExec.error).toBeUndefined(); - expect(pythonExec.logs.stdout.join('')).toContain('Data saved'); - // Create JavaScript context - const jsCtxResponse = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'javascript' }) - }); - - const jsCtx = (await jsCtxResponse.json()) as CodeContext; - - // Read and process data in JavaScript - const jsExecResponse = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: ` -const fs = require('fs'); -const data = JSON.parse(fs.readFileSync('/tmp/shared_data.json', 'utf8')); -const sum = data.values.reduce((a, b) => a + b, 0); -console.log('Sum:', sum); -`.trim(), - options: { context: jsCtx } - }) - }); - - expect(jsExecResponse.status).toBe(200); - const jsExec = (await jsExecResponse.json()) as ExecutionResult; - expect(jsExec.error).toBeUndefined(); - expect(jsExec.logs.stdout.join('')).toContain('Sum: 15'); + // Cleanup all contexts in parallel + await Promise.all([ + deleteContext(pythonCtx.id), + deleteContext(jsCtx.id), + deleteContext(streamCtx.id) + ]); }, 120000); // ============================================================================ - // Context Isolation + // Test 5: Context Isolation + Concurrency // ============================================================================ - test('should isolate variables between contexts', async () => { - currentSandboxId = createSandboxId(); - const headers = createPythonImageHeaders(currentSandboxId); + test('context isolation and concurrency: isolation, many contexts, mutex', async () => { + // Test basic isolation between two contexts + const ctx1 = await createContext('python'); + const ctx2 = await createContext('python'); - // Create two Python contexts - const ctx1Response = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'python' }) - }); - - const context1 = await ctx1Response.json(); - - const ctx2Response = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'python' }) - }); - - const context2 = await ctx2Response.json(); - - // Set variable in context 1 - const exec1Response = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: 'secret = "context1"', - options: { context: context1 } - }) - }); - - expect(exec1Response.status).toBe(200); - - // Try to access variable in context 2 - should fail - const exec2Response = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: 'print(secret)', - options: { context: context2 } - }) - }); - - expect(exec2Response.status).toBe(200); - const execution2 = (await exec2Response.json()) as ExecutionResult; - - // Should have error about undefined variable - expect(execution2.error).toBeDefined(); - if (!execution2.error) throw new Error('Expected error to be defined'); - - expect(execution2.error.name || execution2.error.message).toMatch( + await executeCode(ctx1, 'secret = "context1"'); + const isolationCheck = await executeCode(ctx2, 'print(secret)'); + expect(isolationCheck.error).toBeDefined(); + expect(isolationCheck.error!.name || isolationCheck.error!.message).toMatch( /NameError|not defined/i ); - }, 120000); - - test('should maintain isolation across many contexts (12+)', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create 12 contexts - const contexts: CodeContext[] = []; - for (let i = 0; i < 12; i++) { - const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'javascript' }) - }); - - expect(ctxResponse.status).toBe(200); - const context = (await ctxResponse.json()) as CodeContext; - contexts.push(context); - } - // Set unique state in each context using normal variable declarations - // With variable hoisting, const/let declarations persist across executions - for (let i = 0; i < contexts.length; i++) { - const execResponse = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: `const contextValue = ${i}; contextValue;`, - options: { context: contexts[i] } - }) - }); + // Cleanup basic isolation contexts sequentially + await deleteContext(ctx1.id); + await deleteContext(ctx2.id); - expect(execResponse.status).toBe(200); - const execution = (await execResponse.json()) as ExecutionResult; - expect( - execution.error, - `Context ${i} should not have error setting value` - ).toBeUndefined(); - expect(execution.results![0].text).toContain(String(i)); + // Test isolation across 3 contexts + const manyContexts: CodeContext[] = []; + for (let i = 0; i < 3; i++) { + manyContexts.push(await createContext('javascript')); } - // Verify each context still has its isolated state - for (let i = 0; i < contexts.length; i++) { - const execResponse = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: 'contextValue;', - options: { context: contexts[i] } - }) - }); - - expect(execResponse.status).toBe(200); - const execution = (await execResponse.json()) as ExecutionResult; - expect( - execution.error, - `Context ${i} should not have error reading value` - ).toBeUndefined(); - expect( - execution.results![0].text, - `Context ${i} should have its unique value ${i}` - ).toContain(String(i)); + // Set unique values in each context + for (let i = 0; i < manyContexts.length; i++) { + const exec = await executeCode( + manyContexts[i], + `const contextValue = ${i}; contextValue;` + ); + expect(exec.error, `Context ${i} set error`).toBeUndefined(); + expect(exec.results![0].text).toContain(String(i)); } - // Clean up all contexts - for (const context of contexts) { - await fetch(`${workerUrl}/api/code/context/${context.id}`, { - method: 'DELETE', - headers - }); + // Verify isolated state + for (let i = 0; i < manyContexts.length; i++) { + const exec = await executeCode(manyContexts[i], 'contextValue;'); + expect(exec.error, `Context ${i} read error`).toBeUndefined(); + expect(exec.results![0].text).toContain(String(i)); } - }, 120000); - test('should maintain state isolation with concurrent context execution', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create contexts sequentially - const contexts: CodeContext[] = []; - for (let i = 0; i < 4; i++) { - const response = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'javascript' }) - }); - expect(response.status).toBe(200); - const context = (await response.json()) as CodeContext; - contexts.push(context); + // Cleanup contexts sequentially + for (const ctx of manyContexts) { + await deleteContext(ctx.id); } - // Execute different code in each context concurrently - // Each context sets its own unique value using normal variable declarations - // With variable hoisting, const/let declarations persist across executions - const executionPromises = contexts.map((context, i) => - fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: `const value = ${i}; value;`, - options: { context } - }) - }).then((r) => r.json()) - ); - - const execResults = (await Promise.all( - executionPromises - )) as ExecutionResult[]; - - // Verify each execution succeeded and returned the correct value - for (let i = 0; i < contexts.length; i++) { - expect( - execResults[i].error, - `Context ${i} should not have error` - ).toBeUndefined(); - expect(execResults[i].results).toBeDefined(); - expect(execResults[i].results![0].text).toContain(String(i)); - } + // Test concurrent execution on same context (mutex test) + const mutexCtx = await createContext('javascript'); + await executeCode(mutexCtx, 'let counter = 0;'); - // Now verify each context maintained its isolated state by reading the value back - const verificationPromises = contexts.map((context) => - fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: 'value;', - options: { context } - }) - }).then((r) => r.json()) - ); - - const verifyResults = (await Promise.all( - verificationPromises - )) as ExecutionResult[]; - - // Each context should still have its own unique value - for (let i = 0; i < contexts.length; i++) { - expect( - verifyResults[i].error, - `Context ${i} verification should not have error` - ).toBeUndefined(); - expect(verifyResults[i].results).toBeDefined(); - expect(verifyResults[i].results![0].text).toContain(String(i)); - } - - // Clean up all contexts - await Promise.all( - contexts.map((context) => - fetch(`${workerUrl}/api/code/context/${context.id}`, { - method: 'DELETE', - headers - }) + // Launch 5 concurrent increments + const concurrentRequests = 5; + const results = await Promise.allSettled( + Array.from({ length: concurrentRequests }, () => + executeCode(mutexCtx, 'counter++; counter;') ) ); - }, 120000); - - test('should prevent concurrent execution on same context', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create single context - const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'javascript' }) - }); - - expect(ctxResponse.status).toBe(200); - const context = (await ctxResponse.json()) as CodeContext; - // Set up initial state with a counter variable using normal declaration - // With variable hoisting, let/var declarations persist across executions - const setupResponse = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: 'let counter = 0;', - options: { context } - }) - }); - expect(setupResponse.status).toBe(200); - - // Launch 20 concurrent executions that all try to increment the counter - // The mutex will queue these requests and execute them serially - const concurrentRequests = 20; - const requests = Array.from({ length: concurrentRequests }, () => - fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: 'counter++; counter;', - options: { context } - }) - }).then((r) => r.json()) - ); - - const results = await Promise.allSettled(requests); - - // Analyze results - collect all counter values + // Collect counter values const counterValues: number[] = []; - for (const result of results) { if (result.status === 'fulfilled') { - const execution = result.value as ExecutionResult; - expect(execution.error).toBeUndefined(); - - // Extract the counter value from successful execution results - if (execution.results && execution.results.length > 0) { - const resultText = execution.results[0].text; - if (resultText) { - const match = resultText.match(/\d+/); - if (match) { - counterValues.push(parseInt(match[0], 10)); - } - } - } + const exec = result.value; + expect(exec.error).toBeUndefined(); + const match = exec.results?.[0]?.text?.match(/\d+/); + if (match) counterValues.push(parseInt(match[0], 10)); } } - // Verify: all 20 requests succeeded + // All 5 should succeed with values 1-5 (serial execution via mutex) expect(counterValues.length).toBe(concurrentRequests); - - // Verify serial execution: counter values should be 1 through 20 - // Sort to handle out-of-order responses (execution was still serial) counterValues.sort((a, b) => a - b); - expect(counterValues).toEqual(Array.from({ length: 20 }, (_, i) => i + 1)); + expect(counterValues).toEqual(Array.from({ length: 5 }, (_, i) => i + 1)); - // Verify final state: counter should be 20 (proving all executed serially) - const finalCheck = await fetch(`${workerUrl}/api/code/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - code: 'counter;', - options: { context } - }) - }); - const finalResult = (await finalCheck.json()) as ExecutionResult; - const finalCounterText = finalResult.results?.[0]?.text; - const finalCounterValue = finalCounterText - ? parseInt(finalCounterText.match(/\d+/)?.[0] || '0', 10) - : 0; - expect(finalCounterValue).toBe(20); + // Verify final counter state + const finalExec = await executeCode(mutexCtx, 'counter;'); + const finalValue = parseInt( + finalExec.results?.[0]?.text?.match(/\d+/)?.[0] || '0', + 10 + ); + expect(finalValue).toBe(5); - // Clean up - await fetch(`${workerUrl}/api/code/context/${context.id}`, { - method: 'DELETE', - headers - }); - }, 120000); + await deleteContext(mutexCtx.id); + }, 30000); // ============================================================================ - // Error Handling + // Test 6: Error Handling // ============================================================================ - test('should return error for invalid language', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Try to create context with invalid language - const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'invalid-lang' }) - }); - - // Should return error - expect(ctxResponse.status).toBeGreaterThanOrEqual(400); - const errorData = (await ctxResponse.json()) as ErrorResponse; - expect(errorData.error).toBeTruthy(); - }, 120000); - - test('should return error for non-existent context', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); + test('error handling: invalid language, non-existent context, Python unavailable', async () => { + // Invalid language + const invalidLangResponse = await fetch( + `${workerUrl}/api/code/context/create`, + { + method: 'POST', + headers, + body: JSON.stringify({ language: 'invalid-lang' }) + } + ); + expect(invalidLangResponse.status).toBeGreaterThanOrEqual(400); + const invalidLangError = + (await invalidLangResponse.json()) as ErrorResponse; + expect(invalidLangError.error).toBeTruthy(); - // Try to execute with fake context - const execResponse = await fetch(`${workerUrl}/api/code/execute`, { + // Non-existent context execution + const fakeContextExec = await fetch(`${workerUrl}/api/code/execute`, { method: 'POST', headers, body: JSON.stringify({ @@ -1114,57 +453,41 @@ console.log('Sum:', sum); } }) }); + expect(fakeContextExec.status).toBeGreaterThanOrEqual(400); + const fakeContextError = (await fakeContextExec.json()) as ErrorResponse; + expect(fakeContextError.error).toBeTruthy(); - // Should return error - expect(execResponse.status).toBeGreaterThanOrEqual(400); - const errorData = (await execResponse.json()) as ErrorResponse; - expect(errorData.error).toBeTruthy(); - }, 120000); - - test('should return error when deleting non-existent context', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Initialize sandbox + // Delete non-existent context await fetch(`${workerUrl}/api/execute`, { method: 'POST', headers, body: JSON.stringify({ command: 'echo "init"' }) }); - - // Try to delete non-existent context - const deleteResponse = await fetch( + const deleteFakeResponse = await fetch( `${workerUrl}/api/code/context/fake-id-99999`, + { method: 'DELETE', headers } + ); + expect(deleteFakeResponse.status).toBeGreaterThanOrEqual(400); + const deleteFakeError = (await deleteFakeResponse.json()) as ErrorResponse; + expect(deleteFakeError.error).toBeTruthy(); + + // Python unavailable on base image + const sandbox = await getSharedSandbox(); + const baseImageHeaders = sandbox.createHeaders(createUniqueSession()); + const pythonUnavailableResponse = await fetch( + `${workerUrl}/api/code/context/create`, { - method: 'DELETE', - headers + method: 'POST', + headers: baseImageHeaders, + body: JSON.stringify({ language: 'python' }) } ); - - // Should return error - expect(deleteResponse.status).toBeGreaterThanOrEqual(400); - const errorData = (await deleteResponse.json()) as ErrorResponse; - expect(errorData.error).toBeTruthy(); - }, 120000); - - test('should return helpful error when Python unavailable on base image', async () => { - currentSandboxId = createSandboxId(); - // Use default headers (base image, no Python) to test Python-not-available error - const headers = createTestHeaders(currentSandboxId); - - // Try to create Python context on base image (no Python installed) - const response = await fetch(`${workerUrl}/api/code/context/create`, { - method: 'POST', - headers, - body: JSON.stringify({ language: 'python' }) - }); - - // Should return error (test worker returns 500 for all errors) - expect(response.status).toBe(500); - const errorData = (await response.json()) as ErrorResponse; - - // Error should guide users to the correct image variant - expect(errorData.error).toContain('Python interpreter not available'); - expect(errorData.error).toMatch(/-python/); - }, 120000); + expect(pythonUnavailableResponse.status).toBe(500); + const pythonUnavailableError = + (await pythonUnavailableResponse.json()) as ErrorResponse; + expect(pythonUnavailableError.error).toContain( + 'Python interpreter not available' + ); + expect(pythonUnavailableError.error).toMatch(/-python/); + }, 30000); }); diff --git a/tests/e2e/comprehensive-workflow.test.ts b/tests/e2e/comprehensive-workflow.test.ts new file mode 100644 index 00000000..34728c7a --- /dev/null +++ b/tests/e2e/comprehensive-workflow.test.ts @@ -0,0 +1,626 @@ +/** + * Comprehensive Workflow Integration Test + * + * This test validates a realistic end-to-end workflow using a SINGLE sandbox, + * combining features that were previously tested in isolation: + * - Git clone + * - Environment variables + * - File operations (read, write, mkdir, rename, move, delete) + * - Command execution + * - Background process management + * - Streaming output + * + * By testing features together in one sandbox, we: + * 1. Reduce test runtime (one container vs many) + * 2. Test realistic usage patterns + * 3. Catch integration issues between features + * + * Individual edge cases and error handling remain in dedicated test files. + */ + +import { describe, test, expect, beforeAll } from 'vitest'; +import { + getSharedSandbox, + createUniqueSession, + uniqueTestPath +} from './helpers/global-sandbox'; +import { parseSSEStream } from '../../packages/sandbox/src/sse-parser'; +import type { + ExecResult, + WriteFileResult, + ReadFileResult, + MkdirResult, + GitCheckoutResult, + EnvSetResult, + Process, + ProcessLogsResult, + ListFilesResult, + FileInfo, + ExecEvent +} from '@repo/shared'; + +describe('Comprehensive Workflow', () => { + let workerUrl: string; + let headers: Record; + + beforeAll(async () => { + // Use the shared sandbox with a unique session for this test file + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + headers = sandbox.createHeaders(createUniqueSession()); + }, 120000); + + /** + * Test 1: Complete Developer Workflow + * + * Simulates a realistic workflow: + * 1. Clone a repository + * 2. Set up environment variables + * 3. Explore and modify files + * 4. Run commands with environment + * 5. Start a background process + * 6. Monitor via streaming + * 7. Clean up + */ + test('should execute complete developer workflow: clone → env → files → process', async () => { + // ======================================== + // Phase 1: Clone a repository + // ======================================== + const testDir = uniqueTestPath('hello-world'); + const cloneResponse = await fetch(`${workerUrl}/api/git/clone`, { + method: 'POST', + headers, + body: JSON.stringify({ + repoUrl: 'https://github.com/octocat/Hello-World', + branch: 'master', + targetDir: testDir + }) + }); + + expect(cloneResponse.status).toBe(200); + const cloneData = (await cloneResponse.json()) as GitCheckoutResult; + expect(cloneData.success).toBe(true); + + // Verify repo structure using listFiles + const listResponse = await fetch(`${workerUrl}/api/list-files`, { + method: 'POST', + headers, + body: JSON.stringify({ path: testDir }) + }); + + expect(listResponse.status).toBe(200); + const listData = (await listResponse.json()) as ListFilesResult; + expect(listData.files.some((f: FileInfo) => f.name === 'README')).toBe( + true + ); + + // ======================================== + // Phase 2: Set up environment variables + // ======================================== + const setEnvResponse = await fetch(`${workerUrl}/api/env/set`, { + method: 'POST', + headers, + body: JSON.stringify({ + envVars: { + PROJECT_NAME: 'hello-world', + BUILD_ENV: 'test', + API_KEY: 'test-key-123' + } + }) + }); + + expect(setEnvResponse.status).toBe(200); + const envData = (await setEnvResponse.json()) as EnvSetResult; + expect(envData.success).toBe(true); + + // Verify env vars are available + const envCheckResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "$PROJECT_NAME|$BUILD_ENV|$API_KEY"' + }) + }); + + expect(envCheckResponse.status).toBe(200); + const envCheckData = (await envCheckResponse.json()) as ExecResult; + expect(envCheckData.stdout.trim()).toBe('hello-world|test|test-key-123'); + + // ======================================== + // Phase 3: File operations on cloned repo + // ======================================== + + // Read the README from cloned repo + const readReadmeResponse = await fetch(`${workerUrl}/api/file/read`, { + method: 'POST', + headers, + body: JSON.stringify({ path: `${testDir}/README` }) + }); + + expect(readReadmeResponse.status).toBe(200); + const readmeData = (await readReadmeResponse.json()) as ReadFileResult; + expect(readmeData.content).toContain('Hello'); + + // Create a new directory structure + const mkdirResponse = await fetch(`${workerUrl}/api/file/mkdir`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: `${testDir}/src/utils`, + recursive: true + }) + }); + + expect(mkdirResponse.status).toBe(200); + + // Write a config file using env vars in filename generation + const configContent = JSON.stringify( + { + name: 'hello-world', + env: 'test', + version: '1.0.0' + }, + null, + 2 + ); + + const writeConfigResponse = await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: `${testDir}/config.json`, + content: configContent + }) + }); + + expect(writeConfigResponse.status).toBe(200); + + // Write a source file + const sourceCode = ` +// Generated file using env: $BUILD_ENV +export function greet(name) { + return \`Hello, \${name}!\`; +} +`.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: `${testDir}/src/utils/greet.js`, + content: sourceCode + }) + }); + + // Rename the file + const renameResponse = await fetch(`${workerUrl}/api/file/rename`, { + method: 'POST', + headers, + body: JSON.stringify({ + oldPath: `${testDir}/src/utils/greet.js`, + newPath: `${testDir}/src/utils/greeter.js` + }) + }); + + expect(renameResponse.status).toBe(200); + + // Verify rename worked by reading new path + const readRenamedResponse = await fetch(`${workerUrl}/api/file/read`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: `${testDir}/src/utils/greeter.js` + }) + }); + + expect(readRenamedResponse.status).toBe(200); + const renamedData = (await readRenamedResponse.json()) as ReadFileResult; + expect(renamedData.content).toContain('greet'); + + // ======================================== + // Phase 4: Run commands with environment + // ======================================== + + // Use env vars in a command + const buildResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `echo "Building $PROJECT_NAME in $BUILD_ENV mode" && ls -la ${testDir}/src`, + cwd: testDir + }) + }); + + expect(buildResponse.status).toBe(200); + const buildData = (await buildResponse.json()) as ExecResult; + expect(buildData.stdout).toContain('Building hello-world in test mode'); + expect(buildData.stdout).toContain('utils'); + + // Run git status to verify we're in a git repo + const gitStatusResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'git status --porcelain', + cwd: testDir + }) + }); + + expect(gitStatusResponse.status).toBe(200); + const gitStatusData = (await gitStatusResponse.json()) as ExecResult; + // Should show our new files as untracked + expect(gitStatusData.stdout).toContain('config.json'); + expect(gitStatusData.stdout).toContain('src/'); + + // ======================================== + // Phase 5: Background process with streaming + // ======================================== + + // Write a simple server script that uses env vars + const serverScript = ` +const port = 8888; +console.log(\`[Server] Starting on port \${port}\`); +console.log(\`[Server] PROJECT_NAME = \${process.env.PROJECT_NAME}\`); +console.log(\`[Server] BUILD_ENV = \${process.env.BUILD_ENV}\`); + +let counter = 0; +const interval = setInterval(() => { + counter++; + console.log(\`[Server] Heartbeat \${counter}\`); + if (counter >= 3) { + clearInterval(interval); + console.log('[Server] Done'); + process.exit(0); + } +}, 500); +`.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: `${testDir}/server.js`, + content: serverScript + }) + }); + + // Start the background process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `bun run ${testDir}/server.js` + }) + }); + + expect(startResponse.status).toBe(200); + const processData = (await startResponse.json()) as Process; + expect(processData.id).toBeTruthy(); + const processId = processData.id; + + // Wait for process to complete + await new Promise((resolve) => setTimeout(resolve, 3000)); + + // Get process logs + const logsResponse = await fetch( + `${workerUrl}/api/process/${processId}/logs`, + { + method: 'GET', + headers + } + ); + + expect(logsResponse.status).toBe(200); + const logsData = (await logsResponse.json()) as ProcessLogsResult; + + // Verify env vars were available to the process + expect(logsData.stdout).toContain('PROJECT_NAME = hello-world'); + expect(logsData.stdout).toContain('BUILD_ENV = test'); + expect(logsData.stdout).toContain('Heartbeat 3'); + expect(logsData.stdout).toContain('Done'); + + // ======================================== + // Phase 6: Cleanup - move and delete files + // ======================================== + + // Move config to a backup location + await fetch(`${workerUrl}/api/file/mkdir`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: `${testDir}/backup`, + recursive: true + }) + }); + + const moveResponse = await fetch(`${workerUrl}/api/file/move`, { + method: 'POST', + headers, + body: JSON.stringify({ + sourcePath: `${testDir}/config.json`, + destinationPath: `${testDir}/backup/config.json` + }) + }); + + expect(moveResponse.status).toBe(200); + + // Delete the server script + const deleteResponse = await fetch(`${workerUrl}/api/file/delete`, { + method: 'DELETE', + headers, + body: JSON.stringify({ + path: `${testDir}/server.js` + }) + }); + + expect(deleteResponse.status).toBe(200); + + // Verify final state + const finalListResponse = await fetch(`${workerUrl}/api/list-files`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: testDir, + options: { recursive: true } + }) + }); + + expect(finalListResponse.status).toBe(200); + const finalListData = (await finalListResponse.json()) as ListFilesResult; + + // Should have backup/config.json but not server.js at root + const fileNames = finalListData.files.map((f: FileInfo) => f.relativePath); + expect(fileNames).toContain('backup/config.json'); + expect(fileNames).not.toContain('server.js'); + expect(fileNames).toContain('src/utils/greeter.js'); + }, 180000); + + /** + * Test 2: Streaming execution with real-time output + * + * Tests execStream to verify SSE streaming works correctly + * within the same sandbox context. + */ + test('should stream command output in real-time', async () => { + // Helper to collect SSE events + async function collectSSEEvents( + response: Response, + maxEvents: number = 50 + ): Promise { + if (!response.body) throw new Error('No body'); + + const events: ExecEvent[] = []; + const abortController = new AbortController(); + + try { + for await (const event of parseSSEStream( + response.body, + abortController.signal + )) { + events.push(event); + if (event.type === 'complete' || event.type === 'error') { + abortController.abort(); + break; + } + if (events.length >= maxEvents) { + abortController.abort(); + break; + } + } + } catch (error) { + if ( + error instanceof Error && + error.message !== 'Operation was aborted' + ) { + throw error; + } + } + + return events; + } + + // Stream a command that outputs multiple lines with timestamps + const streamResponse = await fetch(`${workerUrl}/api/execStream`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: + 'for i in 1 2 3; do echo "[$PROJECT_NAME] Step $i at $(date +%s)"; sleep 0.3; done' + }) + }); + + expect(streamResponse.status).toBe(200); + expect(streamResponse.headers.get('content-type')).toBe( + 'text/event-stream' + ); + + const events = await collectSSEEvents(streamResponse); + + // Verify event types + const eventTypes = new Set(events.map((e) => e.type)); + expect(eventTypes.has('start')).toBe(true); + expect(eventTypes.has('stdout')).toBe(true); + expect(eventTypes.has('complete')).toBe(true); + + // Verify output includes env var from earlier phase + const output = events + .filter((e) => e.type === 'stdout') + .map((e) => e.data) + .join(''); + expect(output).toContain('[hello-world]'); + expect(output).toContain('Step 1'); + expect(output).toContain('Step 3'); + + // Verify successful completion + const completeEvent = events.find((e) => e.type === 'complete'); + expect(completeEvent?.exitCode).toBe(0); + }, 60000); + + /** + * Test 3: Per-command env and cwd without mutating session + * + * Verifies that per-command options work correctly and + * don't affect the session state. + */ + test('should support per-command env and cwd without affecting session', async () => { + // Execute with per-command env + const cmdEnvResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "TEMP=$TEMP_VAR, PROJECT=$PROJECT_NAME"', + env: { TEMP_VAR: 'temporary-value' } + }) + }); + + expect(cmdEnvResponse.status).toBe(200); + const cmdEnvData = (await cmdEnvResponse.json()) as ExecResult; + // Should have both per-command env AND session env + expect(cmdEnvData.stdout.trim()).toBe( + 'TEMP=temporary-value, PROJECT=hello-world' + ); + + // Verify TEMP_VAR didn't persist to session + const verifyEnvResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "TEMP=$TEMP_VAR"' + }) + }); + + const verifyEnvData = (await verifyEnvResponse.json()) as ExecResult; + expect(verifyEnvData.stdout.trim()).toBe('TEMP='); + + // Execute with per-command cwd + const cmdCwdResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'pwd', + cwd: '/tmp' + }) + }); + + expect(cmdCwdResponse.status).toBe(200); + const cmdCwdData = (await cmdCwdResponse.json()) as ExecResult; + expect(cmdCwdData.stdout.trim()).toBe('/tmp'); + + // Verify session cwd wasn't changed + const verifyCwdResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'pwd' + }) + }); + + const verifyCwdData = (await verifyCwdResponse.json()) as ExecResult; + expect(verifyCwdData.stdout.trim()).toBe('/workspace'); + }, 60000); + + /** + * Test 4: Binary file handling + * + * Tests reading and writing binary files. + */ + test('should handle binary file operations', async () => { + // Create a binary file using base64 + const pngBase64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jYlkKQAAAABJRU5ErkJggg=='; + + await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `echo '${pngBase64}' | base64 -d > /workspace/test-image.png` + }) + }); + + // Read the binary file + const readBinaryResponse = await fetch(`${workerUrl}/api/file/read`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/test-image.png' + }) + }); + + expect(readBinaryResponse.status).toBe(200); + const binaryData = (await readBinaryResponse.json()) as ReadFileResult; + + expect(binaryData.isBinary).toBe(true); + expect(binaryData.encoding).toBe('base64'); + expect(binaryData.mimeType).toMatch(/image\/png/); + expect(binaryData.content).toBeTruthy(); + + // Clean up + await fetch(`${workerUrl}/api/file/delete`, { + method: 'DELETE', + headers, + body: JSON.stringify({ path: '/workspace/test-image.png' }) + }); + }, 60000); + + /** + * Test 5: Process list and management + * + * Tests starting multiple processes and listing them. + */ + test('should manage multiple background processes', async () => { + // Start two background processes + const process1Response = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ command: 'sleep 30' }) + }); + const process1 = (await process1Response.json()) as Process; + + const process2Response = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ command: 'sleep 30' }) + }); + const process2 = (await process2Response.json()) as Process; + + // Wait for processes to be registered + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // List all processes + const listResponse = await fetch(`${workerUrl}/api/process/list`, { + method: 'GET', + headers + }); + + expect(listResponse.status).toBe(200); + const processList = (await listResponse.json()) as Process[]; + + expect(processList.length).toBeGreaterThanOrEqual(2); + const ids = processList.map((p) => p.id); + expect(ids).toContain(process1.id); + expect(ids).toContain(process2.id); + + // Kill all processes + const killAllResponse = await fetch(`${workerUrl}/api/process/kill-all`, { + method: 'POST', + headers, + body: JSON.stringify({}) + }); + + expect(killAllResponse.status).toBe(200); + + // Poll until no running processes remain (up to 5 seconds) + let running: Process[] = []; + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => setTimeout(resolve, 500)); + const listAfterResponse = await fetch(`${workerUrl}/api/process/list`, { + method: 'GET', + headers + }); + const processesAfter = (await listAfterResponse.json()) as Process[]; + running = processesAfter.filter((p) => p.status === 'running'); + if (running.length === 0) break; + } + expect(running.length).toBe(0); + }, 60000); +}); diff --git a/tests/e2e/environment-workflow.test.ts b/tests/e2e/environment-workflow.test.ts index aaf6596e..1d1dee26 100644 --- a/tests/e2e/environment-workflow.test.ts +++ b/tests/e2e/environment-workflow.test.ts @@ -1,374 +1,240 @@ +import { describe, test, expect, beforeAll } from 'vitest'; import { - describe, - test, - expect, - beforeAll, - afterAll, - afterEach, - vi -} from 'vitest'; -import { getTestWorkerUrl, WranglerDevRunner } from './helpers/wrangler-runner'; -import { - createSandboxId, - createTestHeaders, - cleanupSandbox -} from './helpers/test-fixtures'; -import type { - EnvSetResult, - ExecResult, - WriteFileResult, - Process, - ProcessLogsResult -} from '@repo/shared'; - -describe('Environment Variables Workflow', () => { - describe('local', () => { - let runner: WranglerDevRunner | null; - let workerUrl: string; - let currentSandboxId: string | null = null; - - beforeAll(async () => { - // Get test worker URL (CI: uses deployed URL, Local: spawns wrangler dev) - const result = await getTestWorkerUrl(); - workerUrl = result.url; - runner = result.runner; - }); - - afterEach(async () => { - // Cleanup sandbox container after each test - if (currentSandboxId) { - await cleanupSandbox(workerUrl, currentSandboxId); - currentSandboxId = null; - } - }); - - afterAll(async () => { - if (runner) { - await runner.stop(); - } + getSharedSandbox, + createUniqueSession +} from './helpers/global-sandbox'; +import type { ExecResult, ExecEvent } from '@repo/shared'; +import { parseSSEStream } from '../../packages/sandbox/src/sse-parser'; + +/** + * Environment Variable Tests + * + * Tests all ways to set environment variables and their override behavior: + * - Dockerfile ENV (base level, e.g. SANDBOX_VERSION) + * - setEnvVars at session level + * - Per-command env in exec() + * - Per-command env in execStream() + * + * Override precedence (highest to lowest): + * 1. Per-command env + * 2. Session-level setEnvVars + * 3. Dockerfile ENV + */ +describe('Environment Variables', () => { + let workerUrl: string; + let headers: Record; + + beforeAll(async () => { + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + headers = sandbox.createHeaders(createUniqueSession()); + }, 120000); + + test('should have Dockerfile ENV vars available', async () => { + // SANDBOX_VERSION is set in the Dockerfile + const response = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ command: 'echo $SANDBOX_VERSION' }) }); - test('should set a single environment variable and verify it', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Step 1: Set environment variable - const setEnvResponse = await fetch(`${workerUrl}/api/env/set`, { - method: 'POST', - headers, - body: JSON.stringify({ - envVars: { TEST_VAR: 'hello_world' } - }) - }); - - expect(setEnvResponse.status).toBe(200); - const setEnvData = (await setEnvResponse.json()) as EnvSetResult; - expect(setEnvData.success).toBe(true); - - // Step 2: Verify environment variable with echo command (same sandbox) - const execResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo $TEST_VAR' - }) - }); - - expect(execResponse.status).toBe(200); - const execData = (await execResponse.json()) as ExecResult; - expect(execData.success).toBe(true); - expect(execData.stdout.trim()).toBe('hello_world'); - }, 90000); - - test('should set multiple environment variables at once', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Step 1: Set multiple environment variables - const setEnvResponse = await fetch(`${workerUrl}/api/env/set`, { - method: 'POST', - headers, - body: JSON.stringify({ - envVars: { - API_KEY: 'secret123', - DB_HOST: 'localhost', - PORT: '3000' - } - }) - }); - - expect(setEnvResponse.status).toBe(200); - const setEnvData = (await setEnvResponse.json()) as EnvSetResult; - expect(setEnvData.success).toBe(true); - - // Step 2: Verify all environment variables (same sandbox) - const execResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "$API_KEY|$DB_HOST|$PORT"' - }) - }); - - expect(execResponse.status).toBe(200); - const execData = (await execResponse.json()) as ExecResult; - expect(execData.success).toBe(true); - expect(execData.stdout.trim()).toBe('secret123|localhost|3000'); - }, 90000); - - test('should persist environment variables across multiple commands', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Step 1: Set environment variable - const setEnvResponse = await fetch(`${workerUrl}/api/env/set`, { - method: 'POST', - headers, - body: JSON.stringify({ - envVars: { PERSISTENT_VAR: 'still_here' } - }) - }); - - expect(setEnvResponse.status).toBe(200); - - // Step 2: Run first command to verify env var (same sandbox) - const exec1Response = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo $PERSISTENT_VAR' - }) - }); - - expect(exec1Response.status).toBe(200); - const exec1Data = (await exec1Response.json()) as ExecResult; - expect(exec1Data.success).toBe(true); - expect(exec1Data.stdout.trim()).toBe('still_here'); - - // Step 3: Run different command - env var should still be available (same sandbox) - const exec2Response = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'printenv PERSISTENT_VAR' - }) - }); - - expect(exec2Response.status).toBe(200); - const exec2Data = (await exec2Response.json()) as ExecResult; - expect(exec2Data.success).toBe(true); - expect(exec2Data.stdout.trim()).toBe('still_here'); - - // Step 4: Run third command with different shell builtin (same sandbox) - const exec3Response = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'sh -c "echo $PERSISTENT_VAR"' - }) - }); - - expect(exec3Response.status).toBe(200); - const exec3Data = (await exec3Response.json()) as ExecResult; - expect(exec3Data.success).toBe(true); - expect(exec3Data.stdout.trim()).toBe('still_here'); - }, 90000); - - test('should make environment variables available to background processes', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Step 1: Set environment variable - const setEnvResponse = await fetch(`${workerUrl}/api/env/set`, { - method: 'POST', - headers, - body: JSON.stringify({ - envVars: { PROCESS_VAR: 'from_env' } - }) - }); - - expect(setEnvResponse.status).toBe(200); - - // Step 2: Start a background process that uses the environment variable (same sandbox) - // Write a simple script that outputs the env var and exits - const writeResponse = await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/env-test.sh', - content: '#!/bin/sh\necho "ENV_VALUE=$PROCESS_VAR"\n' - }) - }); - - expect(writeResponse.status).toBe(200); - - // Make script executable (same sandbox) - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'chmod +x /workspace/env-test.sh' - }) - }); - - // Step 3: Start the process (same sandbox) - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: '/workspace/env-test.sh' - }) - }); - - expect(startResponse.status).toBe(200); - const startData = (await startResponse.json()) as Process; - expect(startData.id).toBeTruthy(); - const processId = startData.id; - - // Step 4: Wait for process to complete and get logs (same sandbox) - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const logsResponse = await fetch( - `${workerUrl}/api/process/${processId}/logs`, - { - method: 'GET', - headers + expect(response.status).toBe(200); + const data = (await response.json()) as ExecResult; + expect(data.success).toBe(true); + // Should have some version value (not empty) + expect(data.stdout.trim()).toBeTruthy(); + expect(data.stdout.trim()).not.toBe('$SANDBOX_VERSION'); + }, 30000); + + test('should set and persist session-level env vars via setEnvVars', async () => { + // Set env vars at session level + const setResponse = await fetch(`${workerUrl}/api/env/set`, { + method: 'POST', + headers, + body: JSON.stringify({ + envVars: { + MY_SESSION_VAR: 'session-value', + ANOTHER_VAR: 'another-value' } - ); - - expect(logsResponse.status).toBe(200); - const logsData = (await logsResponse.json()) as ProcessLogsResult; - expect(logsData.stdout).toContain('ENV_VALUE=from_env'); - - // Cleanup (same sandbox) - await fetch(`${workerUrl}/api/file/delete`, { - method: 'DELETE', - headers, - body: JSON.stringify({ - path: '/workspace/env-test.sh' - }) - }); - }, 90000); - - test('should handle commands that read stdin without hanging', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Test 1: cat with no arguments should exit immediately with EOF - const catResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'cat' - }) - }); - - expect(catResponse.status).toBe(200); - const catData = (await catResponse.json()) as ExecResult; - // cat with no input should exit with code 0 and produce no output - expect(catData.success).toBe(true); - expect(catData.stdout).toBe(''); - - // Test 2: bash read command should return immediately - const readResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'read -t 1 INPUT_VAR || echo "read returned"' - }) - }); - - expect(readResponse.status).toBe(200); - const readData = (await readResponse.json()) as ExecResult; - expect(readData.success).toBe(true); - expect(readData.stdout).toContain('read returned'); - - // Test 3: grep with no file should exit immediately - const grepResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'grep "test" || true' - }) - }); - - expect(grepResponse.status).toBe(200); - const grepData = (await grepResponse.json()) as ExecResult; - expect(grepData.success).toBe(true); - }, 90000); - - test('should support per-command env vars without mutating session env', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); + }) + }); - const perCommandResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "CMD=$CMD_ONLY"', - env: { CMD_ONLY: 'scoped-value' } - }) - }); + expect(setResponse.status).toBe(200); - expect(perCommandResponse.status).toBe(200); - const perCommandData = (await perCommandResponse.json()) as ExecResult; - expect(perCommandData.success).toBe(true); - expect(perCommandData.stdout.trim()).toBe('CMD=scoped-value'); + // Verify they persist across commands + const readResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "$MY_SESSION_VAR:$ANOTHER_VAR"' + }) + }); - const verifyResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "CMD=$CMD_ONLY"' - }) - }); + expect(readResponse.status).toBe(200); + const readData = (await readResponse.json()) as ExecResult; + expect(readData.stdout.trim()).toBe('session-value:another-value'); + }, 30000); + + test('should support per-command env in exec()', async () => { + const response = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "$CMD_VAR"', + env: { CMD_VAR: 'command-specific-value' } + }) + }); - expect(verifyResponse.status).toBe(200); - const verifyData = (await verifyResponse.json()) as ExecResult; - expect(verifyData.success).toBe(true); - expect(verifyData.stdout.trim()).toBe('CMD='); - }, 90000); + expect(response.status).toBe(200); + const data = (await response.json()) as ExecResult; + expect(data.stdout.trim()).toBe('command-specific-value'); + }, 30000); + + test('should support per-command env in execStream()', async () => { + const response = await fetch(`${workerUrl}/api/execStream`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "$STREAM_VAR"', + env: { STREAM_VAR: 'stream-env-value' } + }) + }); - test('should execute commands in custom cwd without affecting session state', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); + expect(response.status).toBe(200); + + // Collect streamed output + const events: ExecEvent[] = []; + const abortController = new AbortController(); + for await (const event of parseSSEStream( + response.body!, + abortController.signal + )) { + events.push(event); + if (event.type === 'complete' || event.type === 'error') break; + } + + const stdout = events + .filter((e) => e.type === 'stdout') + .map((e) => e.data) + .join(''); + expect(stdout.trim()).toBe('stream-env-value'); + }, 30000); + + test('should override session env with per-command env', async () => { + // First set a session-level var + await fetch(`${workerUrl}/api/env/set`, { + method: 'POST', + headers, + body: JSON.stringify({ + envVars: { OVERRIDE_TEST: 'session-level' } + }) + }); - const mkdirResponse = await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/custom-dir', - recursive: true - }) - }); - expect(mkdirResponse.status).toBe(200); + // Verify session value + const sessionResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ command: 'echo "$OVERRIDE_TEST"' }) + }); + const sessionData = (await sessionResponse.json()) as ExecResult; + expect(sessionData.stdout.trim()).toBe('session-level'); + + // Override with per-command env + const overrideResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "$OVERRIDE_TEST"', + env: { OVERRIDE_TEST: 'command-level' } + }) + }); + const overrideData = (await overrideResponse.json()) as ExecResult; + expect(overrideData.stdout.trim()).toBe('command-level'); + + // Session value should still be intact + const afterResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ command: 'echo "$OVERRIDE_TEST"' }) + }); + const afterData = (await afterResponse.json()) as ExecResult; + expect(afterData.stdout.trim()).toBe('session-level'); + }, 30000); + + test('should override Dockerfile ENV with session setEnvVars', async () => { + // Create a fresh session to test clean override + const sandbox = await getSharedSandbox(); + const freshHeaders = sandbox.createHeaders(createUniqueSession()); + + // First read Dockerfile value + const beforeResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: freshHeaders, + body: JSON.stringify({ command: 'echo "$SANDBOX_VERSION"' }) + }); + const beforeData = (await beforeResponse.json()) as ExecResult; + const dockerValue = beforeData.stdout.trim(); + expect(dockerValue).toBeTruthy(); + + // Override with session setEnvVars + await fetch(`${workerUrl}/api/env/set`, { + method: 'POST', + headers: freshHeaders, + body: JSON.stringify({ + envVars: { SANDBOX_VERSION: 'overridden-version' } + }) + }); - const cwdResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'pwd', - cwd: '/workspace/custom-dir' - }) - }); + // Verify override + const afterResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: freshHeaders, + body: JSON.stringify({ command: 'echo "$SANDBOX_VERSION"' }) + }); + const afterData = (await afterResponse.json()) as ExecResult; + expect(afterData.stdout.trim()).toBe('overridden-version'); + }, 30000); + + test('should handle commands that read stdin without hanging', async () => { + // Test 1: cat with no arguments should exit immediately with EOF + const catResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'cat' + }) + }); - expect(cwdResponse.status).toBe(200); - const cwdData = (await cwdResponse.json()) as ExecResult; - expect(cwdData.success).toBe(true); - expect(cwdData.stdout.trim()).toBe('/workspace/custom-dir'); + expect(catResponse.status).toBe(200); + const catData = (await catResponse.json()) as ExecResult; + expect(catData.success).toBe(true); + expect(catData.stdout).toBe(''); + + // Test 2: bash read command should return immediately + const readResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'read -t 1 INPUT_VAR || echo "read returned"' + }) + }); - const defaultResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'pwd' - }) - }); + expect(readResponse.status).toBe(200); + const readData = (await readResponse.json()) as ExecResult; + expect(readData.success).toBe(true); + expect(readData.stdout).toContain('read returned'); + + // Test 3: grep with no file should exit immediately + const grepResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'grep "test" || true' + }) + }); - expect(defaultResponse.status).toBe(200); - const defaultData = (await defaultResponse.json()) as ExecResult; - expect(defaultData.success).toBe(true); - expect(defaultData.stdout.trim()).toBe('/workspace'); - }, 90000); - }); + expect(grepResponse.status).toBe(200); + const grepData = (await grepResponse.json()) as ExecResult; + expect(grepData.success).toBe(true); + }, 90000); }); diff --git a/tests/e2e/file-operations-workflow.test.ts b/tests/e2e/file-operations-workflow.test.ts index 2cb8cdff..1577b518 100644 --- a/tests/e2e/file-operations-workflow.test.ts +++ b/tests/e2e/file-operations-workflow.test.ts @@ -1,1000 +1,104 @@ /** - * E2E Test: File Operations Workflow + * File Operations Error Handling Tests * - * Tests comprehensive file system operations including: - * - Directory creation (mkdir) - * - File deletion (deleteFile) - * - File renaming (renameFile) - * - File moving (moveFile) + * Tests error cases and edge cases for file operations. + * Happy path tests (mkdir, write, read, rename, move, delete, list) are in comprehensive-workflow.test.ts. * - * These tests validate realistic workflows like project scaffolding - * and file manipulation across directory structures. + * This file focuses on: + * - Deleting directories with deleteFile (should reject) + * - Deleting nonexistent files + * - listFiles errors (nonexistent dir, file instead of dir) + * - Hidden file handling */ -import { - afterAll, - afterEach, - beforeAll, - describe, - expect, - test, - vi -} from 'vitest'; -import type { - FileInfo, - WriteFileResult, - ReadFileResult, - DeleteFileResult, - MkdirResult, - ListFilesResult, - ExecResult, - FileExistsResult -} from '@repo/shared'; -import type { ErrorResponse } from './test-worker/types'; -import { getTestWorkerUrl, WranglerDevRunner } from './helpers/wrangler-runner'; -import { - createSandboxId, - createTestHeaders, - cleanupSandbox -} from './helpers/test-fixtures'; - -describe('File Operations Workflow (E2E)', () => { - let runner: WranglerDevRunner | null; - let workerUrl: string; - let currentSandboxId: string | null = null; - - beforeAll(async () => { - // Get test worker URL (CI: uses deployed URL, Local: spawns wrangler dev) - const result = await getTestWorkerUrl(); - workerUrl = result.url; - runner = result.runner; - }, 120000); // 2 minute timeout for wrangler startup - - afterEach(async () => { - // Cleanup sandbox container after each test - if (currentSandboxId) { - await cleanupSandbox(workerUrl, currentSandboxId); - currentSandboxId = null; - } - }); - - afterAll(async () => { - if (runner) { - await runner.stop(); - } - }); - - test('should create nested directories', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create nested directory structure - const mkdirResponse = await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/project/src/components', - - recursive: true - }) - }); - - const mkdirData = (await mkdirResponse.json()) as MkdirResult; - expect(mkdirData.success).toBe(true); - - // Verify directory exists by listing it - const lsResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'ls -la /workspace/project/src/components' - }) - }); - - const lsData = (await lsResponse.json()) as ReadFileResult; - expect(lsResponse.status).toBe(200); - expect(lsData.success).toBe(true); - // Directory should exist (ls succeeds) - }, 90000); - - test('should write files in subdirectories and read them back', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create directory structure - await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/app/config', - - recursive: true - }) - }); - - // Write file in subdirectory - const writeResponse = await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/app/config/settings.json', - content: JSON.stringify({ debug: true, port: 3000 }) - }) - }); - - expect(writeResponse.status).toBe(200); - - // Read file back - const readResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/app/config/settings.json' - }) - }); - - const readData = (await readResponse.json()) as ReadFileResult; - expect(readResponse.status).toBe(200); - expect(readData.content).toContain('debug'); - expect(readData.content).toContain('3000'); - }, 90000); - - test('should rename files', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create directory and write file - await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/docs', - - recursive: true - }) - }); - - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/docs/README.txt', - content: '# Project Documentation' - }) - }); - - // Rename file from .txt to .md - const renameResponse = await fetch(`${workerUrl}/api/file/rename`, { - method: 'POST', - headers, - body: JSON.stringify({ - oldPath: '/workspace/docs/README.txt', - newPath: '/workspace/docs/README.md' - }) - }); - - expect(renameResponse.status).toBe(200); - - // Verify new file exists - const readNewResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/docs/README.md' - }) - }); - - const readNewData = (await readNewResponse.json()) as ReadFileResult; - expect(readNewResponse.status).toBe(200); - expect(readNewData.content).toContain('Project Documentation'); - - // Verify old file doesn't exist - const readOldResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/docs/README.txt' - }) - }); - - expect(readOldResponse.status).toBe(500); // Should fail - file doesn't exist - }, 90000); - - test('should move files between directories', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create two directories - await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/source', - - recursive: true - }) - }); - - await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/destination', - - recursive: true - }) - }); - - // Write file in source directory - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/source/data.json', - content: JSON.stringify({ id: 1, name: 'test' }) - }) - }); - - // Move file to destination - const moveResponse = await fetch(`${workerUrl}/api/file/move`, { - method: 'POST', - headers, - body: JSON.stringify({ - sourcePath: '/workspace/source/data.json', - destinationPath: '/workspace/destination/data.json' - }) - }); - - expect(moveResponse.status).toBe(200); - - // Verify file exists in destination - const readDestResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/destination/data.json' - }) - }); - - const readDestData = (await readDestResponse.json()) as ReadFileResult; - expect(readDestResponse.status).toBe(200); - expect(readDestData.content).toContain('test'); - - // Verify file doesn't exist in source - const readSourceResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/source/data.json' - }) - }); - - expect(readSourceResponse.status).toBe(500); // Should fail - file moved - }, 90000); - - test('should delete files', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create directory and file - await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/temp', - - recursive: true - }) - }); - - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/temp/delete-me.txt', - content: 'This file will be deleted' - }) - }); - - // Verify file exists - const readBeforeResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/temp/delete-me.txt' - }) - }); - - expect(readBeforeResponse.status).toBe(200); - - // Delete file - const deleteResponse = await fetch(`${workerUrl}/api/file/delete`, { - method: 'DELETE', - headers, - body: JSON.stringify({ - path: '/workspace/temp/delete-me.txt' - }) - }); - - expect(deleteResponse.status).toBe(200); - - // Verify file doesn't exist - const readAfterResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/temp/delete-me.txt' - }) - }); - - expect(readAfterResponse.status).toBe(500); // Should fail - file deleted - }, 90000); - - test('should reject deleting directories with deleteFile', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create a directory - await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/test-dir', - - recursive: true - }) - }); - - // Try to delete directory with deleteFile - should fail - const deleteResponse = await fetch(`${workerUrl}/api/file/delete`, { - method: 'DELETE', - headers, - body: JSON.stringify({ - path: '/workspace/test-dir' - }) - }); - - // Should return error - expect(deleteResponse.status).toBe(500); - - const deleteData = (await deleteResponse.json()) as ErrorResponse; - expect(deleteData.error).toContain('Cannot delete directory'); - expect(deleteData.error).toContain('deleteFile()'); - - // Verify directory still exists - const lsResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'ls -d /workspace/test-dir' - }) - }); - - const lsData = (await lsResponse.json()) as ReadFileResult; - expect(lsResponse.status).toBe(200); - expect(lsData.success).toBe(true); // Directory should still exist - - // Cleanup - delete directory properly using exec - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'rm -rf /workspace/test-dir' - }) - }); - }, 90000); - - test('should delete directories recursively using exec', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create nested directory structure with files - await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/cleanup/nested/deep', - - recursive: true - }) - }); - - // Write files in different levels - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/cleanup/file1.txt', - content: 'Level 1' - }) - }); - - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/cleanup/nested/file2.txt', - content: 'Level 2' - }) - }); - - // Delete entire directory tree (use exec since deleteFile only works on files) - const deleteResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'rm -rf /workspace/cleanup' - }) - }); - - const deleteData = (await deleteResponse.json()) as DeleteFileResult; - expect(deleteResponse.status).toBe(200); - expect(deleteData.success).toBe(true); - - // Verify directory doesn't exist - const lsResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'ls /workspace/cleanup' - }) - }); - - const lsData = (await lsResponse.json()) as ReadFileResult; - // ls should fail or show empty result - expect(lsData.success).toBe(false); - }, 90000); - - test('should handle complete project scaffolding workflow', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Step 1: Create project directory structure - // Create main project directories - await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/myapp/src', - - recursive: true - }) - }); - - await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/myapp/tests', - - recursive: true - }) - }); - - await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/myapp/config', - - recursive: true - }) - }); - - // Step 2: Write initial files - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/myapp/package.json', - content: JSON.stringify({ name: 'myapp', version: '1.0.0' }) - }) - }); - - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/myapp/src/index.js', - content: 'console.log("Hello World");' - }) - }); - - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/myapp/config/dev.json', - content: JSON.stringify({ env: 'development' }) - }) - }); - - // Step 3: Rename config file - const renameResponse = await fetch(`${workerUrl}/api/file/rename`, { - method: 'POST', - headers, - body: JSON.stringify({ - oldPath: '/workspace/myapp/config/dev.json', - newPath: '/workspace/myapp/config/development.json' - }) - }); - - expect(renameResponse.status).toBe(200); - - // Step 4: Move index.js to a lib subdirectory - await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/myapp/src/lib', - - recursive: true - }) - }); - - const moveResponse = await fetch(`${workerUrl}/api/file/move`, { - method: 'POST', - headers, - body: JSON.stringify({ - sourcePath: '/workspace/myapp/src/index.js', - destinationPath: '/workspace/myapp/src/lib/index.js' - }) - }); - - expect(moveResponse.status).toBe(200); - - // Step 5: Verify final structure - const readPackageResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/myapp/package.json' - }) - }); - - const packageData = (await readPackageResponse.json()) as ReadFileResult; - expect(readPackageResponse.status).toBe(200); - expect(packageData.content).toContain('myapp'); - - const readConfigResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/myapp/config/development.json' - }) - }); - - const configData = (await readConfigResponse.json()) as ReadFileResult; - expect(readConfigResponse.status).toBe(200); - expect(configData.content).toContain('development'); - - const readIndexResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/myapp/src/lib/index.js' - }) - }); - - const indexData = (await readIndexResponse.json()) as ReadFileResult; - expect(readIndexResponse.status).toBe(200); - expect(indexData.content).toContain('Hello World'); - - // Step 6: Cleanup - delete entire project (use exec since deleteFile only works on files) - const deleteResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'rm -rf /workspace/myapp' - }) - }); - - const deleteData = (await deleteResponse.json()) as DeleteFileResult; - expect(deleteResponse.status).toBe(200); - expect(deleteData.success).toBe(true); - - // Verify cleanup - const lsResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'ls /workspace/myapp' - }) - }); - - const lsData = (await lsResponse.json()) as ReadFileResult; - expect(lsData.success).toBe(false); // Directory should not exist - }, 90000); - - test('should allow writing to any path (no path blocking - trust container isolation)', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Phase 0: Security simplification - we no longer block system paths - // Users control their sandbox - container isolation provides real security - // This test verifies we don't artificially restrict paths - - // Try to write to /tmp (should work - users control their sandbox) - const writeResponse = await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/tmp/test-no-restrictions.txt', - content: 'Users control their sandbox!' - }) - }); - - expect(writeResponse.status).toBe(200); - const writeData = (await writeResponse.json()) as WriteFileResult; - expect(writeData.success).toBe(true); - - // Verify we can read it back - const readResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/tmp/test-no-restrictions.txt' - }) - }); - - expect(readResponse.status).toBe(200); - const readData = (await readResponse.json()) as ReadFileResult; - expect(readData.content).toBe('Users control their sandbox!'); - }, 90000); - - test('should read text files with correct encoding and metadata', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create a text file - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/test.txt', - content: 'Hello, World! This is a test.' - }) - }); - - // Read the file and check metadata - const readResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/test.txt' - }) - }); - - expect(readResponse.status).toBe(200); - const readData = (await readResponse.json()) as ReadFileResult; - - expect(readData.success).toBe(true); - expect(readData.content).toBe('Hello, World! This is a test.'); - expect(readData.encoding).toBe('utf-8'); - expect(readData.isBinary).toBe(false); - expect(readData.mimeType).toMatch(/text\/plain/); - expect(readData.size).toBeGreaterThan(0); - }, 90000); - - test('should write and read binary files with base64 encoding', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create a simple binary file (1x1 PNG - smallest valid PNG) - const pngBase64 = - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jYlkKQAAAABJRU5ErkJggg=='; - - // First create the file using exec with base64 decode - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: `echo '${pngBase64}' | base64 -d > /workspace/test.png` - }) - }); - - // Read the binary file - const readResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/test.png' - }) - }); - - expect(readResponse.status).toBe(200); - const readData = (await readResponse.json()) as ReadFileResult; - - expect(readData.success).toBe(true); - expect(readData.encoding).toBe('base64'); - expect(readData.isBinary).toBe(true); - expect(readData.mimeType).toMatch(/image\/png/); - expect(readData.content).toBeTruthy(); - expect(readData.size).toBeGreaterThan(0); +import { beforeAll, beforeEach, describe, expect, test } from 'vitest'; +import type { FileInfo, ListFilesResult, ReadFileResult } from '@repo/shared'; +import type { ErrorResponse } from './test-worker/types'; +import { + getSharedSandbox, + createUniqueSession, + uniqueTestPath +} from './helpers/global-sandbox'; - // Verify the content is valid base64 - expect(readData.content).toMatch(/^[A-Za-z0-9+/=]+$/); - }, 90000); +describe('File Operations Error Handling', () => { + let workerUrl: string; + let headers: Record; + let testDir: string; - test('should detect JSON files as text', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); + beforeAll(async () => { + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + headers = sandbox.createHeaders(createUniqueSession()); + }, 120000); + + // Use unique directory for each test to avoid conflicts + beforeEach(() => { + testDir = uniqueTestPath('file-ops'); + }); - const jsonContent = JSON.stringify({ key: 'value', number: 42 }); + test('should reject deleting directories with deleteFile', async () => { + const dirPath = `${testDir}/test-dir`; - // Write JSON file - await fetch(`${workerUrl}/api/file/write`, { + // Create a directory + await fetch(`${workerUrl}/api/file/mkdir`, { method: 'POST', headers, body: JSON.stringify({ - path: '/workspace/config.json', - content: jsonContent + path: dirPath, + recursive: true }) }); - // Read and verify metadata - const readResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', + // Try to delete directory with deleteFile - should fail + const deleteResponse = await fetch(`${workerUrl}/api/file/delete`, { + method: 'DELETE', headers, body: JSON.stringify({ - path: '/workspace/config.json' + path: dirPath }) }); - expect(readResponse.status).toBe(200); - const readData = (await readResponse.json()) as ReadFileResult; - - expect(readData.success).toBe(true); - expect(readData.content).toBe(jsonContent); - expect(readData.encoding).toBe('utf-8'); - expect(readData.isBinary).toBe(false); - expect(readData.mimeType).toMatch(/json/); - }, 90000); - - test('should stream large text files', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create a larger text file - const largeContent = 'Line content\n'.repeat(1000); // 13KB file - - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/large.txt', - content: largeContent - }) - }); + expect(deleteResponse.status).toBe(500); + const deleteData = (await deleteResponse.json()) as ErrorResponse; + expect(deleteData.error).toContain('Cannot delete directory'); + expect(deleteData.error).toContain('deleteFile()'); - // Stream the file - const streamResponse = await fetch(`${workerUrl}/api/read/stream`, { + // Verify directory still exists + const lsResponse = await fetch(`${workerUrl}/api/execute`, { method: 'POST', headers, body: JSON.stringify({ - path: '/workspace/large.txt' + command: `ls -d ${dirPath}` }) }); - expect(streamResponse.status).toBe(200); - expect(streamResponse.headers.get('content-type')).toBe( - 'text/event-stream' - ); - - // Collect events from stream - const reader = streamResponse.body?.getReader(); - expect(reader).toBeDefined(); - - if (!reader) return; - - const decoder = new TextDecoder(); - let receivedMetadata = false; - let receivedChunks = 0; - let receivedComplete = false; - let buffer = ''; - - // Read entire stream - don't break early - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - } - - // Process all buffered events - const lines = buffer.split('\n'); - for (const line of lines) { - if (line.startsWith('data: ')) { - const jsonStr = line.slice(6); - try { - const event = JSON.parse(jsonStr); - - if (event.type === 'metadata') { - receivedMetadata = true; - expect(event.encoding).toBe('utf-8'); - expect(event.isBinary).toBe(false); - expect(event.mimeType).toMatch(/text\/plain/); - } else if (event.type === 'chunk') { - receivedChunks++; - expect(event.data).toBeTruthy(); - } else if (event.type === 'complete') { - receivedComplete = true; - expect(event.bytesRead).toBe(13000); - } - } catch (e) { - // Ignore parse errors for empty lines - } - } - } - - expect(receivedMetadata).toBe(true); - expect(receivedChunks).toBeGreaterThan(0); - expect(receivedComplete).toBe(true); + const lsData = (await lsResponse.json()) as ReadFileResult; + expect(lsResponse.status).toBe(200); + expect(lsData.success).toBe(true); }, 90000); test('should return error when deleting nonexistent file', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Try to delete a file that doesn't exist const deleteResponse = await fetch(`${workerUrl}/api/file/delete`, { method: 'DELETE', headers, body: JSON.stringify({ - path: '/workspace/this-file-does-not-exist.txt' + path: `${testDir}/this-file-does-not-exist.txt` }) }); - // Should return error with FILE_NOT_FOUND expect(deleteResponse.status).toBe(500); const errorData = (await deleteResponse.json()) as ErrorResponse; expect(errorData.error).toBeTruthy(); expect(errorData.error).toMatch(/not found|does not exist|no such file/i); }, 90000); - test('should list files with metadata and permissions', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create directory with files including executable script - await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/project', - recursive: true - }) - }); - - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/project/data.txt', - content: 'Some data' - }) - }); - - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: - 'echo "#!/bin/bash" > /workspace/project/script.sh && chmod +x /workspace/project/script.sh' - }) - }); - - // List files and verify metadata - const listResponse = await fetch(`${workerUrl}/api/list-files`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/project' - }) - }); - - expect(listResponse.status).toBe(200); - const listData = (await listResponse.json()) as ListFilesResult; - - expect(listData.success).toBe(true); - expect(listData.path).toBe('/workspace/project'); - expect(listData.files).toBeInstanceOf(Array); - expect(listData.count).toBeGreaterThan(0); - - // Verify file has correct metadata and permissions - const dataFile = listData.files.find( - (f: FileInfo) => f.name === 'data.txt' - ); - expect(dataFile).toBeDefined(); - if (!dataFile) throw new Error('dataFile not found'); - - expect(dataFile.type).toBe('file'); - expect(dataFile.absolutePath).toBe('/workspace/project/data.txt'); - expect(dataFile.relativePath).toBe('data.txt'); - expect(dataFile.size).toBeGreaterThan(0); - expect(dataFile.mode).toMatch(/^[r-][w-][x-][r-][w-][x-][r-][w-][x-]$/); - expect(dataFile.permissions.readable).toBe(true); - expect(dataFile.permissions.writable).toBe(true); - expect(dataFile.permissions.executable).toBe(false); - - // Verify executable script has correct permissions - const scriptFile = listData.files.find( - (f: FileInfo) => f.name === 'script.sh' - ); - expect(scriptFile).toBeDefined(); - if (!scriptFile) throw new Error('scriptFile not found'); - - expect(scriptFile.permissions.executable).toBe(true); - }, 90000); - - test('should list files recursively with correct relative paths', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create nested directory structure - await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/tree/level1/level2', - recursive: true - }) - }); - - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/tree/root.txt', - content: 'Root' - }) - }); - - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/tree/level1/level2/deep.txt', - content: 'Deep' - }) - }); - - const listResponse = await fetch(`${workerUrl}/api/list-files`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/tree', - options: { recursive: true } - }) - }); - - expect(listResponse.status).toBe(200); - const listData = (await listResponse.json()) as ListFilesResult; - - expect(listData.success).toBe(true); - - // Verify relative paths are correct - const rootFile = listData.files.find( - (f: FileInfo) => f.name === 'root.txt' - ); - expect(rootFile?.relativePath).toBe('root.txt'); - - const deepFile = listData.files.find( - (f: FileInfo) => f.name === 'deep.txt' - ); - expect(deepFile?.relativePath).toBe('level1/level2/deep.txt'); - }, 90000); - test('should handle listFiles errors appropriately', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - // Test non-existent directory const notFoundResponse = await fetch(`${workerUrl}/api/list-files`, { method: 'POST', headers, body: JSON.stringify({ - path: '/workspace/does-not-exist' + path: `${testDir}/does-not-exist` }) }); @@ -1004,11 +108,17 @@ describe('File Operations Workflow (E2E)', () => { expect(notFoundData.error).toMatch(/not found|does not exist/i); // Test listing a file instead of directory + const filePath = `${testDir}/file.txt`; + await fetch(`${workerUrl}/api/file/mkdir`, { + method: 'POST', + headers, + body: JSON.stringify({ path: testDir, recursive: true }) + }); await fetch(`${workerUrl}/api/file/write`, { method: 'POST', headers, body: JSON.stringify({ - path: '/workspace/file.txt', + path: filePath, content: 'Not a directory' }) }); @@ -1017,7 +127,7 @@ describe('File Operations Workflow (E2E)', () => { method: 'POST', headers, body: JSON.stringify({ - path: '/workspace/file.txt' + path: filePath }) }); @@ -1026,85 +136,15 @@ describe('File Operations Workflow (E2E)', () => { expect(wrongTypeData.error).toMatch(/not a directory/i); }, 90000); - test('should check file and directory existence', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Create a file and directory for testing - await fetch(`${workerUrl}/api/file/mkdir`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/testdir', - recursive: true - }) - }); - - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/testfile.txt', - content: 'Test content' - }) - }); - - // Check that file exists - const fileExistsResponse = await fetch(`${workerUrl}/api/file/exists`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/testfile.txt' - }) - }); - - expect(fileExistsResponse.status).toBe(200); - const fileExistsData = - (await fileExistsResponse.json()) as FileExistsResult; - expect(fileExistsData.success).toBe(true); - expect(fileExistsData.exists).toBe(true); - - // Check that directory exists - const dirExistsResponse = await fetch(`${workerUrl}/api/file/exists`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/testdir' - }) - }); - - expect(dirExistsResponse.status).toBe(200); - const dirExistsData = (await dirExistsResponse.json()) as FileExistsResult; - expect(dirExistsData.success).toBe(true); - expect(dirExistsData.exists).toBe(true); - - // Check that non-existent path returns false - const notExistsResponse = await fetch(`${workerUrl}/api/file/exists`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/nonexistent' - }) - }); - - expect(notExistsResponse.status).toBe(200); - const notExistsData = (await notExistsResponse.json()) as FileExistsResult; - expect(notExistsData.success).toBe(true); - expect(notExistsData.exists).toBe(false); - }, 90000); - - test('should list files in hidden directories without includeHidden flag', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); + // Regression test for #196: hidden files in hidden directories + test('should list files in hidden directories with includeHidden flag', async () => { + const hiddenDir = `${testDir}/.hidden/foo`; - // Create hidden directory structure with non-hidden files + // Create hidden directory structure await fetch(`${workerUrl}/api/file/mkdir`, { method: 'POST', headers, - body: JSON.stringify({ - path: '/workspace/.hidden/foo/bar', - recursive: true - }) + body: JSON.stringify({ path: `${hiddenDir}/bar`, recursive: true }) }); // Write visible files in hidden directory @@ -1112,17 +152,16 @@ describe('File Operations Workflow (E2E)', () => { method: 'POST', headers, body: JSON.stringify({ - path: '/workspace/.hidden/foo/visible1.txt', - content: 'Visible file 1' + path: `${hiddenDir}/visible1.txt`, + content: 'Visible 1' }) }); - await fetch(`${workerUrl}/api/file/write`, { method: 'POST', headers, body: JSON.stringify({ - path: '/workspace/.hidden/foo/visible2.txt', - content: 'Visible file 2' + path: `${hiddenDir}/visible2.txt`, + content: 'Visible 2' }) }); @@ -1131,54 +170,38 @@ describe('File Operations Workflow (E2E)', () => { method: 'POST', headers, body: JSON.stringify({ - path: '/workspace/.hidden/foo/.hiddenfile.txt', - content: 'Hidden file' + path: `${hiddenDir}/.hiddenfile.txt`, + content: 'Hidden' }) }); - // List files WITHOUT includeHidden flag - should show visible files only + // List WITHOUT includeHidden - should NOT show .hiddenfile.txt const listResponse = await fetch(`${workerUrl}/api/list-files`, { method: 'POST', headers, - body: JSON.stringify({ - path: '/workspace/.hidden/foo' - }) + body: JSON.stringify({ path: hiddenDir }) }); expect(listResponse.status).toBe(200); const listData = (await listResponse.json()) as ListFilesResult; - expect(listData.success).toBe(true); - expect(listData.files).toBeInstanceOf(Array); - // Should contain visible files const visibleFiles = listData.files.filter( (f: FileInfo) => !f.name.startsWith('.') ); expect(visibleFiles.length).toBe(3); // visible1.txt, visible2.txt, bar/ - const visible1 = listData.files.find( - (f: FileInfo) => f.name === 'visible1.txt' - ); - expect(visible1).toBeDefined(); - - const visible2 = listData.files.find( - (f: FileInfo) => f.name === 'visible2.txt' - ); - expect(visible2).toBeDefined(); - - // Should NOT contain hidden file const hiddenFile = listData.files.find( (f: FileInfo) => f.name === '.hiddenfile.txt' ); expect(hiddenFile).toBeUndefined(); - // List files WITH includeHidden flag - should show all files + // List WITH includeHidden - should show all files const listWithHiddenResponse = await fetch(`${workerUrl}/api/list-files`, { method: 'POST', headers, body: JSON.stringify({ - path: '/workspace/.hidden/foo', + path: hiddenDir, options: { includeHidden: true } }) }); @@ -1188,11 +211,52 @@ describe('File Operations Workflow (E2E)', () => { (await listWithHiddenResponse.json()) as ListFilesResult; expect(listWithHiddenData.success).toBe(true); - expect(listWithHiddenData.files.length).toBe(4); // visible1.txt, visible2.txt, bar/, .hiddenfile.txt + expect(listWithHiddenData.files.length).toBe(4); // +.hiddenfile.txt const hiddenFileWithFlag = listWithHiddenData.files.find( (f: FileInfo) => f.name === '.hiddenfile.txt' ); expect(hiddenFileWithFlag).toBeDefined(); }, 90000); + + test('should read binary files with base64 encoding', async () => { + // 1x1 PNG - smallest valid PNG + const pngBase64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAI9jYlkKQAAAABJRU5ErkJggg=='; + + // Create binary file via base64 decode + await fetch(`${workerUrl}/api/file/mkdir`, { + method: 'POST', + headers, + body: JSON.stringify({ path: testDir, recursive: true }) + }); + + await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `echo '${pngBase64}' | base64 -d > ${testDir}/test.png` + }) + }); + + // Read the binary file + const readResponse = await fetch(`${workerUrl}/api/file/read`, { + method: 'POST', + headers, + body: JSON.stringify({ path: `${testDir}/test.png` }) + }); + + expect(readResponse.status).toBe(200); + const readData = (await readResponse.json()) as ReadFileResult; + + expect(readData.success).toBe(true); + expect(readData.encoding).toBe('base64'); + expect(readData.isBinary).toBe(true); + expect(readData.mimeType).toMatch(/image\/png/); + expect(readData.content).toBeTruthy(); + expect(readData.size).toBeGreaterThan(0); + + // Verify the content is valid base64 + expect(readData.content).toMatch(/^[A-Za-z0-9+/=]+$/); + }, 90000); }); diff --git a/tests/e2e/git-clone-workflow.test.ts b/tests/e2e/git-clone-workflow.test.ts index 9fcafb0c..54e958f1 100644 --- a/tests/e2e/git-clone-workflow.test.ts +++ b/tests/e2e/git-clone-workflow.test.ts @@ -1,318 +1,64 @@ +import { describe, test, expect, beforeAll } from 'vitest'; import { - describe, - test, - expect, - beforeAll, - afterAll, - afterEach, - vi -} from 'vitest'; -import { getTestWorkerUrl, WranglerDevRunner } from './helpers/wrangler-runner'; -import { - createSandboxId, - createTestHeaders, - cleanupSandbox -} from './helpers/test-fixtures'; -import type { - GitCheckoutResult, - ReadFileResult, - ExecResult -} from '@repo/shared'; + getSharedSandbox, + createUniqueSession, + uniqueTestPath +} from './helpers/global-sandbox'; import type { ErrorResponse } from './test-worker/types'; /** - * Git Clone Workflow Integration Tests - * - * Tests the README "Build and Test Code" example (lines 343-362): - * - Clone a repository - * - Run npm install - * - Execute npm commands + * Git Clone Error Handling Tests * - * This validates the complete Git workflow: - * Clone → Install → Build/Test + * Tests error cases for git clone operations. + * Happy path tests are in comprehensive-workflow.test.ts. * - * Uses a real public repository to test: - * - Git clone operations - * - Working with actual repositories - * - Multi-step workflows matching production usage + * This file focuses on: + * - Nonexistent repository handling + * - Private repository without auth */ -describe('Git Clone Workflow', () => { - describe('local', () => { - let runner: WranglerDevRunner | null; - let workerUrl: string; - let currentSandboxId: string | null = null; - - beforeAll(async () => { - // Get test worker URL (CI: uses deployed URL, Local: spawns wrangler dev) - const result = await getTestWorkerUrl(); - workerUrl = result.url; - runner = result.runner; - }); - - afterEach(async () => { - // Cleanup sandbox container after each test - if (currentSandboxId) { - await cleanupSandbox(workerUrl, currentSandboxId); - currentSandboxId = null; - } - }); - - afterAll(async () => { - // Cleanup wrangler process (only in local mode) - if (runner) { - await runner.stop(); - } - }); - - test('should clone a public repository successfully', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Clone a very small public repository for testing - // Using octocat/Hello-World - a minimal test repository - const cloneResponse = await fetch(`${workerUrl}/api/git/clone`, { - method: 'POST', - headers, - body: JSON.stringify({ - repoUrl: 'https://github.com/octocat/Hello-World', - branch: 'master', - targetDir: '/workspace/test-repo' - }) - }); - - expect(cloneResponse.status).toBe(200); - const cloneData = (await cloneResponse.json()) as GitCheckoutResult; - expect(cloneData.success).toBe(true); - - // Verify the repository was cloned by checking for README - const fileCheckResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/test-repo/README' - }) - }); - - expect(fileCheckResponse.status).toBe(200); - const fileData = (await fileCheckResponse.json()) as ReadFileResult; - expect(fileData.content).toBeTruthy(); - expect(fileData.content).toContain('Hello'); // Verify README content - }); - - test('should clone repository with specific branch', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Clone a repository with a specific branch (using master for Hello-World) - const cloneResponse = await fetch(`${workerUrl}/api/git/clone`, { - method: 'POST', - headers, - body: JSON.stringify({ - repoUrl: 'https://github.com/octocat/Hello-World', - branch: 'master', // Explicitly specify master branch - targetDir: '/workspace/branch-test' - }) - }); - - expect(cloneResponse.status).toBe(200); - const cloneData = (await cloneResponse.json()) as GitCheckoutResult; - expect(cloneData.success).toBe(true); - - // Verify we're on the correct branch by checking git branch - const branchCheckResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'cd /workspace/branch-test && git branch --show-current' - }) - }); - - expect(branchCheckResponse.status).toBe(200); - const branchData = (await branchCheckResponse.json()) as ExecResult; - expect(branchData.stdout.trim()).toBe('master'); - }); - - test('should execute complete workflow: clone → list files → verify structure', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Step 1: Clone the Hello-World repository - const cloneResponse = await fetch(`${workerUrl}/api/git/clone`, { - method: 'POST', - headers, - body: JSON.stringify({ - repoUrl: 'https://github.com/octocat/Hello-World', - targetDir: '/workspace/project' - }) - }); - - expect(cloneResponse.status).toBe(200); - - // Step 2: List directory contents to verify clone - const listResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'ls -la /workspace/project' - }) - }); - - expect(listResponse.status).toBe(200); - const listData = (await listResponse.json()) as ExecResult; - expect(listData.exitCode).toBe(0); - expect(listData.stdout).toContain('README'); // Verify README exists - expect(listData.stdout).toContain('.git'); // Verify .git directory exists - - // Step 3: Verify README content - const readmeResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/project/README' - }) - }); - - expect(readmeResponse.status).toBe(200); - const readmeData = (await readmeResponse.json()) as ReadFileResult; - expect(readmeData.content).toBeTruthy(); - - // Step 4: Run a git command to verify repo is functional - const gitLogResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'cd /workspace/project && git log --oneline -1' - }) - }); - - expect(gitLogResponse.status).toBe(200); - const gitLogData = (await gitLogResponse.json()) as ExecResult; - expect(gitLogData.exitCode).toBe(0); - expect(gitLogData.stdout).toBeTruthy(); // Should have at least one commit - }); - - test('should handle cloning to default directory when targetDir not specified', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Clone without specifying targetDir (should use repo name "Hello-World") - const cloneResponse = await fetch(`${workerUrl}/api/git/clone`, { - method: 'POST', - headers, - body: JSON.stringify({ - repoUrl: 'https://github.com/octocat/Hello-World' - }) - }); - - expect(cloneResponse.status).toBe(200); - const cloneData = (await cloneResponse.json()) as GitCheckoutResult; - expect(cloneData.success).toBe(true); - - // The SDK should extract 'Hello-World' from the URL and clone there - // Verify by checking if Hello-World directory exists - const dirCheckResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'ls -la /workspace' - }) - }); - - expect(dirCheckResponse.status).toBe(200); - const dirData = (await dirCheckResponse.json()) as ExecResult; - expect(dirData.stdout).toContain('Hello-World'); - }); - - test('should handle git clone errors for nonexistent repository', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Try to clone a non-existent repository - const cloneResponse = await fetch(`${workerUrl}/api/git/clone`, { - method: 'POST', - headers, - body: JSON.stringify({ - repoUrl: - 'https://github.com/nonexistent/repository-that-does-not-exist-12345' - }) - }); // Don't throw on error - we expect this to fail - - // Git clone should fail with appropriate error - expect(cloneResponse.status).toBe(500); - const errorData = (await cloneResponse.json()) as ErrorResponse; - expect(errorData.error).toBeTruthy(); - // Should mention repository not found or doesn't exist - expect(errorData.error).toMatch( - /not found|does not exist|repository|fatal/i - ); +describe('Git Clone Error Handling', () => { + let workerUrl: string; + let headers: Record; + + beforeAll(async () => { + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + headers = sandbox.createHeaders(createUniqueSession()); + }, 120000); + + test('should handle git clone errors for nonexistent repository', async () => { + const cloneResponse = await fetch(`${workerUrl}/api/git/clone`, { + method: 'POST', + headers, + body: JSON.stringify({ + repoUrl: + 'https://github.com/nonexistent/repository-that-does-not-exist-12345' + }) }); - test('should handle git clone errors for private repository without auth', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Try to clone a private repository without providing credentials - // Using a known private repo pattern (will fail with auth error) - const cloneResponse = await fetch(`${workerUrl}/api/git/clone`, { - method: 'POST', - headers, - body: JSON.stringify({ - repoUrl: - 'https://github.com/cloudflare/private-test-repo-that-requires-auth' - }) - }); // Don't throw on error - we expect this to fail - - // Should fail with authentication error - expect(cloneResponse.status).toBe(500); - const errorData = (await cloneResponse.json()) as ErrorResponse; - expect(errorData.error).toBeTruthy(); - // Should mention authentication, permission, or access denied - expect(errorData.error).toMatch( - /authentication|permission|access|denied|fatal|not found/i - ); + expect(cloneResponse.status).toBe(500); + const errorData = (await cloneResponse.json()) as ErrorResponse; + expect(errorData.error).toBeTruthy(); + expect(errorData.error).toMatch( + /not found|does not exist|repository|fatal/i + ); + }, 90000); + + test('should handle git clone errors for private repository without auth', async () => { + const cloneResponse = await fetch(`${workerUrl}/api/git/clone`, { + method: 'POST', + headers, + body: JSON.stringify({ + repoUrl: + 'https://github.com/cloudflare/private-test-repo-that-requires-auth' + }) }); - test('should maintain session state across git clone and subsequent commands', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Step 1: Clone a repository - const cloneResponse = await fetch(`${workerUrl}/api/git/clone`, { - method: 'POST', - headers, - body: JSON.stringify({ - repoUrl: 'https://github.com/octocat/Hello-World', - targetDir: '/workspace/state-test' - }) - }); - - expect(cloneResponse.status).toBe(200); - - // Step 2: Create a new file in the cloned directory - const writeResponse = await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/state-test/test-marker.txt', - content: 'Session state test' - }) - }); - - expect(writeResponse.status).toBe(200); - - // Step 3: List files to verify both cloned content and our new file exist - const listResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'ls /workspace/state-test' - }) - }); - - expect(listResponse.status).toBe(200); - const listData = (await listResponse.json()) as ExecResult; - expect(listData.stdout).toContain('README'); // From cloned repo - expect(listData.stdout).toContain('test-marker.txt'); // Our new file - }); - }); + expect(cloneResponse.status).toBe(500); + const errorData = (await cloneResponse.json()) as ErrorResponse; + expect(errorData.error).toBeTruthy(); + expect(errorData.error).toMatch( + /authentication|permission|access|denied|fatal|not found/i + ); + }, 90000); }); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 00000000..37c61913 --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -0,0 +1,95 @@ +/** + * Global Setup for E2E Tests + * + * Runs ONCE before any test threads spawn. + * Creates the shared sandbox and passes info via a temp file (env vars don't work across processes). + */ + +import { writeFileSync, unlinkSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { getTestWorkerUrl, WranglerDevRunner } from './helpers/wrangler-runner'; +import { + createSandboxId, + createTestHeaders, + createPythonImageHeaders, + cleanupSandbox +} from './helpers/test-fixtures'; + +// Shared state file path +export const SHARED_STATE_FILE = join(tmpdir(), 'e2e-shared-sandbox.json'); + +let runner: WranglerDevRunner | null = null; +let sandboxId: string | null = null; +let workerUrl: string | null = null; + +export async function setup() { + console.log( + '\n[GlobalSetup] Starting wrangler and creating shared sandbox...' + ); + + // Clean up stale state from crashed runs + if (existsSync(SHARED_STATE_FILE)) { + unlinkSync(SHARED_STATE_FILE); + } + + const result = await getTestWorkerUrl(); + runner = result.runner; + workerUrl = result.url; + sandboxId = createSandboxId(); + + // Initialize the sandboxes + const headers = createTestHeaders(sandboxId); + const initResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ command: 'echo "Global sandbox initialized"' }) + }); + + if (!initResponse.ok) { + throw new Error(`Failed to initialize sandbox: ${initResponse.status}`); + } + + const pythonHeaders = createPythonImageHeaders(sandboxId); + const pythonInitResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: pythonHeaders, + body: JSON.stringify({ command: 'echo "Python sandbox initialized"' }) + }); + + if (!pythonInitResponse.ok) { + console.warn( + `Warning: Failed to initialize Python sandbox: ${pythonInitResponse.status}` + ); + } + + // Write state to temp file for worker threads to read + writeFileSync(SHARED_STATE_FILE, JSON.stringify({ workerUrl, sandboxId })); + + console.log( + `[GlobalSetup] Ready! URL: ${workerUrl}, Sandbox: ${sandboxId}\n` + ); +} + +export async function teardown() { + console.log('\n[GlobalTeardown] Cleaning up...'); + + if (sandboxId && workerUrl) { + try { + await cleanupSandbox(workerUrl, sandboxId); + } catch (e) { + console.warn('[GlobalTeardown] Cleanup error:', e); + } + } + + if (runner) { + await runner.stop(); + } + + // Clean up state file + if (existsSync(SHARED_STATE_FILE)) { + unlinkSync(SHARED_STATE_FILE); + } + + console.log('[GlobalTeardown] Done\n'); +} diff --git a/tests/e2e/helpers/global-sandbox.ts b/tests/e2e/helpers/global-sandbox.ts new file mode 100644 index 00000000..f2df0466 --- /dev/null +++ b/tests/e2e/helpers/global-sandbox.ts @@ -0,0 +1,236 @@ +/** + * Global Sandbox Manager + * + * Provides a single shared sandbox for all e2e tests to dramatically reduce + * container startup/shutdown overhead. + * + * Architecture: + * - ONE sandbox (container) is created on first access + * - Each test file gets a unique SESSION within that sandbox + * - Sessions provide isolated shell environments (env, cwd, shell state) + * - File system and process space are SHARED (tests must use unique paths) + * + * Usage in test files: + * ```typescript + * import { getSharedSandbox, createUniqueSession } from './helpers/global-sandbox'; + * + * describe('My Tests', () => { + * let workerUrl: string; + * let headers: Record; + * + * beforeAll(async () => { + * const sandbox = await getSharedSandbox(); + * workerUrl = sandbox.workerUrl; + * headers = sandbox.createHeaders(createUniqueSession()); + * }); + * + * test('my test', async () => { + * const res = await fetch(`${workerUrl}/api/execute`, { headers, ... }); + * }); + * }); + * ``` + */ + +import { randomUUID } from 'node:crypto'; +import { getTestWorkerUrl, WranglerDevRunner } from './wrangler-runner'; +import { + createSandboxId, + createTestHeaders, + cleanupSandbox +} from './test-fixtures'; + +export interface SharedSandbox { + /** The worker URL to make requests to */ + workerUrl: string; + /** The shared sandbox ID */ + sandboxId: string; + /** Create headers for a specific session (base image) */ + createHeaders: (sessionId?: string) => Record; + /** Create headers for Python image sandbox (with Python) */ + createPythonHeaders: (sessionId?: string) => Record; + /** Generate a unique file path prefix for test isolation */ + uniquePath: (prefix: string) => string; +} + +// Singleton state - persists across test files in the same process +let sharedSandbox: SharedSandbox | null = null; +let runner: WranglerDevRunner | null = null; +let initPromise: Promise | null = null; + +/** + * Get or create the shared sandbox. + * First call initializes, subsequent calls return the same instance. + * Thread-safe via promise caching. + */ +export async function getSharedSandbox(): Promise { + // Return existing sandbox + if (sharedSandbox) { + return sharedSandbox; + } + + // If initialization in progress, wait for it + if (initPromise) { + return initPromise; + } + + // Start initialization (only happens once) + initPromise = initializeSharedSandbox(); + return initPromise; +} + +/** + * Create a unique session ID for test isolation. + * Each test file should use a unique session. + */ +export function createUniqueSession(): string { + return `session-${randomUUID()}`; +} + +/** + * Generate a unique file path for test isolation. + * Use this to avoid file conflicts between tests. + */ +export function uniqueTestPath(prefix: string): string { + return `/workspace/test-${randomUUID().slice(0, 8)}/${prefix}`; +} + +async function initializeSharedSandbox(): Promise { + // Check if global setup already created the sandbox (read from temp file) + const { readFileSync, existsSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + + const stateFile = join(tmpdir(), 'e2e-shared-sandbox.json'); + + if (existsSync(stateFile)) { + try { + const state = JSON.parse(readFileSync(stateFile, 'utf-8')); + if (state.workerUrl && state.sandboxId) { + console.log( + `[SharedSandbox] Using global setup sandbox: ${state.sandboxId.slice(0, 12)}...` + ); + const baseHeaders = createTestHeaders(state.sandboxId); + + sharedSandbox = { + workerUrl: state.workerUrl, + sandboxId: state.sandboxId, + createHeaders: (sessionId?: string) => { + const headers = { ...baseHeaders }; + if (sessionId) { + headers['X-Session-Id'] = sessionId; + } + return headers; + }, + createPythonHeaders: (sessionId?: string) => { + const headers: Record = { + ...baseHeaders, + 'X-Sandbox-Type': 'python' + }; + if (sessionId) { + headers['X-Session-Id'] = sessionId; + } + return headers; + }, + uniquePath: (prefix: string) => + `/workspace/test-${randomUUID().slice(0, 8)}/${prefix}` + }; + return sharedSandbox; + } + } catch { + console.warn( + '[SharedSandbox] Failed to read state file, creating new sandbox' + ); + } + } + + // Fallback: create sandbox ourselves (single-threaded mode or no global setup) + console.log('\n[SharedSandbox] Initializing (this only happens once)...'); + + const result = await getTestWorkerUrl(); + runner = result.runner; + + const sandboxId = createSandboxId(); + const baseHeaders = createTestHeaders(sandboxId); + + // Initialize the sandbox with a simple command + const initResponse = await fetch(`${result.url}/api/execute`, { + method: 'POST', + headers: baseHeaders, + body: JSON.stringify({ command: 'echo "Shared sandbox initialized"' }) + }); + + if (!initResponse.ok) { + throw new Error( + `Failed to initialize shared sandbox: ${initResponse.status}` + ); + } + + console.log(`[SharedSandbox] Ready! ID: ${sandboxId}\n`); + + sharedSandbox = { + workerUrl: result.url, + sandboxId, + createHeaders: (sessionId?: string) => { + const headers = { ...baseHeaders }; + if (sessionId) { + headers['X-Session-Id'] = sessionId; + } + return headers; + }, + createPythonHeaders: (sessionId?: string) => { + const headers: Record = { + ...baseHeaders, + 'X-Sandbox-Type': 'python' + }; + if (sessionId) { + headers['X-Session-Id'] = sessionId; + } + return headers; + }, + uniquePath: (prefix: string) => + `/workspace/test-${randomUUID().slice(0, 8)}/${prefix}` + }; + + // Register cleanup on process exit (only when we created it) + process.on('beforeExit', async () => { + await cleanupSharedSandbox(); + }); + + // Handle SIGTERM/SIGINT for graceful shutdown (e.g., Ctrl+C, CI termination) + const handleSignal = async (signal: string) => { + console.log(`\n[SharedSandbox] Received ${signal}, cleaning up...`); + await cleanupSharedSandbox(); + }; + process.on('SIGTERM', () => handleSignal('SIGTERM')); + process.on('SIGINT', () => handleSignal('SIGINT')); + + return sharedSandbox; +} + +async function cleanupSharedSandbox(): Promise { + if (!sharedSandbox) return; + + console.log('\n[SharedSandbox] Cleaning up...'); + + try { + await cleanupSandbox(sharedSandbox.workerUrl, sharedSandbox.sandboxId); + } catch (error) { + console.warn('[SharedSandbox] Cleanup error:', error); + } + + if (runner) { + await runner.stop(); + runner = null; + } + + sharedSandbox = null; + initPromise = null; + console.log('[SharedSandbox] Cleanup complete\n'); +} + +/** + * Force cleanup - can be called manually if needed + */ +export async function forceCleanupSharedSandbox(): Promise { + await cleanupSharedSandbox(); +} diff --git a/tests/e2e/keepalive-workflow.test.ts b/tests/e2e/keepalive-workflow.test.ts index 5a3ae80e..04b830a1 100644 --- a/tests/e2e/keepalive-workflow.test.ts +++ b/tests/e2e/keepalive-workflow.test.ts @@ -1,277 +1,115 @@ +import { describe, test, expect, beforeAll } from 'vitest'; import { - describe, - test, - expect, - beforeAll, - afterAll, - afterEach, - vi -} from 'vitest'; -import { getTestWorkerUrl, WranglerDevRunner } from './helpers/wrangler-runner'; -import { - createSandboxId, - createTestHeaders, - cleanupSandbox -} from './helpers/test-fixtures'; + getSharedSandbox, + createUniqueSession +} from './helpers/global-sandbox'; import type { Process, ExecResult, ReadFileResult } from '@repo/shared'; /** - * KeepAlive Workflow Integration Tests + * KeepAlive Feature Tests * - * Tests the keepAlive feature that keeps containers alive indefinitely: - * - Container stays alive with keepAlive: true - * - Container respects normal timeout without keepAlive - * - Long-running processes work with keepAlive - * - Explicit destroy stops keepAlive container + * Tests the keepAlive header functionality. Uses SHARED sandbox since we're + * testing the keepAlive protocol behavior, not container lifecycle isolation. * - * This validates that: - * - The keepAlive interval properly renews activity timeout - * - Containers don't auto-timeout when keepAlive is enabled - * - Manual cleanup via destroy() works correctly + * What we verify: + * 1. keepAlive header is accepted and enables the mode + * 2. Multiple commands work with keepAlive enabled + * 3. File and process operations work with keepAlive + * 4. Explicit destroy works (cleanup endpoint) */ -describe('KeepAlive 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; +describe('KeepAlive Feature', () => { + let workerUrl: string; + let headers: Record; + + beforeAll(async () => { + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + headers = sandbox.createHeaders(createUniqueSession()); + }, 120000); + + test('should accept keepAlive header and execute commands', async () => { + const keepAliveHeaders = { ...headers, 'X-Sandbox-KeepAlive': 'true' }; + + // First command with keepAlive + const response1 = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ command: 'echo "keepAlive command 1"' }) }); - - afterEach(async () => { - // Cleanup sandbox container after each test - if (currentSandboxId) { - await cleanupSandbox(workerUrl, currentSandboxId); - currentSandboxId = null; - } + expect(response1.status).toBe(200); + const data1 = (await response1.json()) as ExecResult; + expect(data1.stdout).toContain('keepAlive command 1'); + + // Second command immediately after + const response2 = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ command: 'echo "keepAlive command 2"' }) }); - - afterAll(async () => { - // Only stop runner if we spawned one locally (CI uses deployed worker) - if (runner) { - await runner.stop(); - } + expect(response2.status).toBe(200); + const data2 = (await response2.json()) as ExecResult; + expect(data2.stdout).toContain('keepAlive command 2'); + }, 30000); + + test('should support background processes with keepAlive', async () => { + const keepAliveHeaders = { ...headers, 'X-Sandbox-KeepAlive': 'true' }; + + // Start a background process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ command: 'sleep 10' }) }); + expect(startResponse.status).toBe(200); + const processData = (await startResponse.json()) as Process; + expect(processData.id).toBeTruthy(); + + // Verify process is running + const statusResponse = await fetch( + `${workerUrl}/api/process/${processData.id}`, + { method: 'GET', headers: keepAliveHeaders } + ); + expect(statusResponse.status).toBe(200); + const statusData = (await statusResponse.json()) as Process; + expect(statusData.status).toBe('running'); + + // Cleanup + await fetch(`${workerUrl}/api/process/${processData.id}`, { + method: 'DELETE', + headers: keepAliveHeaders + }); + }, 30000); + + test('should work with file operations and keepAlive', async () => { + const keepAliveHeaders = { ...headers, 'X-Sandbox-KeepAlive': 'true' }; + const testPath = `/workspace/keepalive-test-${Date.now()}.txt`; + + // Write file with keepAlive + const writeResponse = await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ + path: testPath, + content: 'keepAlive file content' + }) + }); + expect(writeResponse.status).toBe(200); - test('should keep container alive with keepAlive enabled', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Add keepAlive header to enable keepAlive mode - const keepAliveHeaders = { - ...headers, - 'X-Sandbox-KeepAlive': 'true' - }; - - // Step 1: Initialize sandbox with keepAlive - const initResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: keepAliveHeaders, - body: JSON.stringify({ - command: 'echo "Container initialized with keepAlive"' - }) - }); - - expect(initResponse.status).toBe(200); - const initData = (await initResponse.json()) as ExecResult; - expect(initData.stdout).toContain('Container initialized with keepAlive'); - - // Step 2: Wait longer than normal activity timeout would allow (15 seconds) - // With keepAlive, container should stay alive - await new Promise((resolve) => setTimeout(resolve, 15000)); - - // Step 3: Execute another command to verify container is still alive - const verifyResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: keepAliveHeaders, - body: JSON.stringify({ - command: 'echo "Still alive after timeout period"' - }) - }); - - expect(verifyResponse.status).toBe(200); - const verifyData = (await verifyResponse.json()) as ExecResult; - expect(verifyData.stdout).toContain('Still alive after timeout period'); - }, 120000); - - test('should support long-running processes with keepAlive', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - const keepAliveHeaders = { - ...headers, - 'X-Sandbox-KeepAlive': 'true' - }; - - // Start a long sleep process (30 seconds) - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers: keepAliveHeaders, - body: JSON.stringify({ - command: 'sleep 30' - }) - }); - - expect(startResponse.status).toBe(200); - const startData = (await startResponse.json()) as Process; - expect(startData.id).toBeTruthy(); - const processId = startData.id; - - // Wait 20 seconds (longer than normal activity timeout) - await new Promise((resolve) => setTimeout(resolve, 20000)); - - // Verify process is still running - const statusResponse = await fetch( - `${workerUrl}/api/process/${processId}`, - { - method: 'GET', - headers: keepAliveHeaders - } - ); - - expect(statusResponse.status).toBe(200); - const statusData = (await statusResponse.json()) as Process; - expect(statusData.status).toBe('running'); - - // Cleanup - kill the process - await fetch(`${workerUrl}/api/process/${processId}`, { - method: 'DELETE', - headers: keepAliveHeaders - }); - }, 120000); - - test('should destroy container when explicitly requested', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - const keepAliveHeaders = { - ...headers, - 'X-Sandbox-KeepAlive': 'true' - }; - - // Step 1: Initialize sandbox with keepAlive - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: keepAliveHeaders, - body: JSON.stringify({ - command: 'echo "Testing destroy"' - }) - }); - - // Step 2: Explicitly destroy the container - const destroyResponse = await fetch(`${workerUrl}/cleanup`, { - method: 'POST', - headers: keepAliveHeaders - }); - - expect(destroyResponse.status).toBe(200); - - // Step 3: Verify container was destroyed by trying to execute a command - // This should fail or require re-initialization - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const verifyResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: keepAliveHeaders, - body: JSON.stringify({ - command: 'echo "After destroy"' - }) - }); - - // Container should be restarted (new container), not the same one - // We can verify by checking that the response is successful but it's a fresh container - expect(verifyResponse.status).toBe(200); - - // Mark as null so afterEach doesn't try to clean it up again - currentSandboxId = null; - }, 120000); - - test('should handle multiple commands with keepAlive over time', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - const keepAliveHeaders = { - ...headers, - 'X-Sandbox-KeepAlive': 'true' - }; - - // Initialize - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: keepAliveHeaders, - body: JSON.stringify({ - command: 'echo "Command 1"' - }) - }); - - // Execute multiple commands with delays between them - for (let i = 2; i <= 4; i++) { - // Wait 8 seconds between commands (would timeout without keepAlive) - await new Promise((resolve) => setTimeout(resolve, 8000)); - - const response = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers: keepAliveHeaders, - body: JSON.stringify({ - command: `echo "Command ${i}"` - }) - }); - - expect(response.status).toBe(200); - const data = (await response.json()) as ExecResult; - expect(data.stdout).toContain(`Command ${i}`); - } - }, 120000); - - test('should work with file operations while keepAlive is enabled', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - const keepAliveHeaders = { - ...headers, - 'X-Sandbox-KeepAlive': 'true' - }; - - // Initialize - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers: keepAliveHeaders, - body: JSON.stringify({ - path: '/workspace/test.txt', - content: 'Initial content' - }) - }); - - // Wait longer than normal timeout - await new Promise((resolve) => setTimeout(resolve, 15000)); - - // Perform file operations - should still work - const writeResponse = await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers: keepAliveHeaders, - body: JSON.stringify({ - path: '/workspace/test.txt', - content: 'Updated content after keepAlive' - }) - }); - - expect(writeResponse.status).toBe(200); - - // Read file to verify - const readResponse = await fetch(`${workerUrl}/api/file/read`, { - method: 'POST', - headers: keepAliveHeaders, - body: JSON.stringify({ - path: '/workspace/test.txt' - }) - }); - - expect(readResponse.status).toBe(200); - const readData = (await readResponse.json()) as ReadFileResult; - expect(readData.content).toContain('Updated content after keepAlive'); - }, 120000); - }); + // Read file with keepAlive + const readResponse = await fetch(`${workerUrl}/api/file/read`, { + method: 'POST', + headers: keepAliveHeaders, + body: JSON.stringify({ path: testPath }) + }); + expect(readResponse.status).toBe(200); + const readData = (await readResponse.json()) as ReadFileResult; + expect(readData.content).toBe('keepAlive file content'); + + // Cleanup + await fetch(`${workerUrl}/api/file/delete`, { + method: 'DELETE', + headers: keepAliveHeaders, + body: JSON.stringify({ path: testPath }) + }); + }, 30000); }); diff --git a/tests/e2e/process-lifecycle-workflow.test.ts b/tests/e2e/process-lifecycle-workflow.test.ts index a8a02a01..d4f70767 100644 --- a/tests/e2e/process-lifecycle-workflow.test.ts +++ b/tests/e2e/process-lifecycle-workflow.test.ts @@ -1,826 +1,246 @@ -import { describe, test, expect, beforeAll, afterAll, afterEach } from 'vitest'; -import { getTestWorkerUrl, WranglerDevRunner } from './helpers/wrangler-runner'; +import { describe, test, expect, beforeAll } from 'vitest'; import { - createSandboxId, - createTestHeaders, - cleanupSandbox -} from './helpers/test-fixtures'; -import type { - Process, - ProcessLogsResult, - ProcessStatus, - PortExposeResult -} from '@repo/shared'; + getSharedSandbox, + createUniqueSession +} from './helpers/global-sandbox'; +import type { Process, ProcessLogsResult } from '@repo/shared'; -// Port exposure tests require custom domain with wildcard DNS routing -// Skip these tests when running against workers.dev deployment (no wildcard support) +// Dedicated port for this test file's port exposure error tests +const PORT_LIFECYCLE_TEST_PORT = 9998; const skipPortExposureTests = process.env.TEST_WORKER_URL?.endsWith('.workers.dev') ?? false; /** - * Process Lifecycle Workflow Integration Tests + * Process Lifecycle Error Handling Tests * - * Tests the README "Run a Node.js App" example (lines 443-471): - * - Start a long-running server process - * - Monitor process logs in real-time - * - Get process status and details - * - Expose port and verify HTTP access - * - Kill process gracefully + * Tests error cases for process management. + * Happy path tests (start, list, logs, kill, kill-all) are in comprehensive-workflow.test.ts. * - * This validates the complete process management workflow: - * Start → Monitor → Status Check → Port Exposure → HTTP Request → Cleanup - * - * Uses a real Bun HTTP server to test: - * - Background process execution - * - Process state management - * - Log streaming (SSE) - * - Port exposure and HTTP proxying - * - Graceful process termination + * This file focuses on: + * - Killing nonexistent process + * - Exposing reserved ports + * - Unexposing non-exposed ports + * - Foreground operations not blocking on background processes */ -describe('Process Lifecycle 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 () => { - // Cleanup sandbox container after each test - if (currentSandboxId) { - await cleanupSandbox(workerUrl, currentSandboxId); - currentSandboxId = null; +describe('Process Lifecycle Error Handling', () => { + let workerUrl: string; + let headers: Record; + let portHeaders: Record; + + beforeAll(async () => { + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + headers = sandbox.createHeaders(createUniqueSession()); + // Port exposure requires sandbox headers (not session headers) + portHeaders = { + 'X-Sandbox-Id': sandbox.sandboxId, + 'Content-Type': 'application/json' + }; + }, 120000); + + test('should return error when killing nonexistent process', async () => { + const killResponse = await fetch( + `${workerUrl}/api/process/fake-process-id-12345`, + { + method: 'DELETE', + headers } - }); + ); - afterAll(async () => { - // Only stop runner if we spawned one locally (CI uses deployed worker) - if (runner) { - await runner.stop(); - } + expect(killResponse.status).toBe(500); + const errorData = (await killResponse.json()) as { error: string }; + expect(errorData.error).toBeTruthy(); + expect(errorData.error).toMatch( + /not found|does not exist|invalid|unknown/i + ); + }, 90000); + + test('should capture PID and logs immediately for fast commands', async () => { + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "Hello from process"' + }) }); - test('should start a server process and verify it runs', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Step 1: Start a simple sleep process (easier to test than a server) - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'sleep 30' - }) - }); - - expect(startResponse.status).toBe(200); - const startData = (await startResponse.json()) as Process; - expect(startData.id).toBeTruthy(); - const processId = startData.id; - - // Wait a bit for the process to start - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // Step 2: Get process status - const statusResponse = await fetch( - `${workerUrl}/api/process/${processId}`, - { - method: 'GET', - headers - } - ); - - expect(statusResponse.status).toBe(200); - const statusData = (await statusResponse.json()) as Process; - expect(statusData.id).toBe(processId); - expect(statusData.status).toBe('running'); - - // Step 3: Cleanup - kill the process - const killResponse = await fetch( - `${workerUrl}/api/process/${processId}`, - { - method: 'DELETE', - headers - } - ); - - expect(killResponse.status).toBe(200); - }, 90000); - - test('should list all running processes', async () => { - const sandboxId = createSandboxId(); - const headers = createTestHeaders(sandboxId); - - // Start 2 long-running processes - const process1Response = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'sleep 60' - }) - }); - - const process1Data = (await process1Response.json()) as Process; - const process1Id = process1Data.id; - - const process2Response = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'sleep 60' - }) - }); - - const process2Data = (await process2Response.json()) as Process; - const process2Id = process2Data.id; + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; - // Wait a bit for processes to be registered - await new Promise((resolve) => setTimeout(resolve, 500)); + // PID should be available immediately + expect(startData.pid).toBeDefined(); + expect(typeof startData.pid).toBe('number'); - // List all processes - const listResponse = await fetch(`${workerUrl}/api/process/list`, { + // Logs should be available immediately for fast commands + const logsResponse = await fetch( + `${workerUrl}/api/process/${processId}/logs`, + { method: 'GET', headers - }); - - expect(listResponse.status).toBe(200); - const listData = (await listResponse.json()) as Process[]; - - // Debug logging - console.log('[DEBUG] List response:', JSON.stringify(listData, null, 2)); - console.log('[DEBUG] Process IDs started:', process1Id, process2Id); - console.log('[DEBUG] SandboxId:', sandboxId); - - expect(Array.isArray(listData)).toBe(true); - expect(listData.length).toBeGreaterThanOrEqual(2); - - // Verify our processes are in the list - const processIds = listData.map((p) => p.id); - expect(processIds).toContain(process1Id); - expect(processIds).toContain(process2Id); - - // Cleanup - kill both processes - await fetch(`${workerUrl}/api/process/${process1Id}`, { - method: 'DELETE', - headers - }); - await fetch(`${workerUrl}/api/process/${process2Id}`, { - method: 'DELETE', - headers - }); - }, 90000); - - test('should not block foreground operations when background processes are running', async () => { - const sandboxId = createSandboxId(); - const headers = createTestHeaders(sandboxId); - - // Start a long-running background process - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'sleep 60' - }) - }); - - const startData = (await startResponse.json()) as Process; - const processId = startData.id; - - // Immediately run a foreground command - should complete quickly - const execStart = Date.now(); - const execResponse = await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "test"' - }) - }); - const execDuration = Date.now() - execStart; - - expect(execResponse.status).toBe(200); - expect(execDuration).toBeLessThan(2000); // Should complete in <2s, not wait for sleep - - // Test listFiles as well - it uses the same foreground execution path - const listStart = Date.now(); - const listResponse = await fetch(`${workerUrl}/api/list-files`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace' - }) - }); - const listDuration = Date.now() - listStart; - - expect(listResponse.status).toBe(200); - expect(listDuration).toBeLessThan(2000); // Should complete quickly - - // Cleanup - await fetch(`${workerUrl}/api/process/${processId}`, { - method: 'DELETE', - headers - }); - }, 90000); - - test('should capture PID and logs immediately for fast commands', async () => { - const sandboxId = createSandboxId(); - const headers = createTestHeaders(sandboxId); - - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "Hello from process"' - }) - }); - - expect(startResponse.status).toBe(200); - const startData = (await startResponse.json()) as Process; - const processId = startData.id; - - // PID should be available immediately - expect(startData.pid).toBeDefined(); - expect(typeof startData.pid).toBe('number'); - - // Logs should be available immediately for fast commands - const logsResponse = await fetch( - `${workerUrl}/api/process/${processId}/logs`, - { - method: 'GET', - headers - } - ); - - expect(logsResponse.status).toBe(200); - const logsData = (await logsResponse.json()) as ProcessLogsResult; - expect(logsData.stdout).toContain('Hello from process'); - }, 90000); + } + ); - test('should stream process logs in real-time', async () => { - const sandboxId = createSandboxId(); - const headers = createTestHeaders(sandboxId); + expect(logsResponse.status).toBe(200); + const logsData = (await logsResponse.json()) as ProcessLogsResult; + expect(logsData.stdout).toContain('Hello from process'); + }, 90000); - // Write a script that outputs multiple lines - const scriptCode = ` + test('should stream process logs in real-time', async () => { + // Write a script that outputs multiple lines + const scriptCode = ` console.log("Line 1"); await Bun.sleep(100); console.log("Line 2"); await Bun.sleep(100); console.log("Line 3"); - `.trim(); - - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/script.js', - content: scriptCode - }) - }); - - // Start the script - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'bun run /workspace/script.js' - }) - }); - - const startData = (await startResponse.json()) as Process; - const processId = startData.id; - - // Stream logs (SSE) - const streamResponse = await fetch( - `${workerUrl}/api/process/${processId}/stream`, - { - method: 'GET', - headers - } - ); - - expect(streamResponse.status).toBe(200); - expect(streamResponse.headers.get('content-type')).toBe( - 'text/event-stream' - ); - - // Collect events from the stream - const reader = streamResponse.body?.getReader(); - const decoder = new TextDecoder(); - const events: any[] = []; - - if (reader) { - let done = false; - let timeout = Date.now() + 10000; // 10s timeout - - while (!done && Date.now() < timeout) { - const { value, done: streamDone } = await reader.read(); - done = streamDone; - - if (value) { - const chunk = decoder.decode(value); - const lines = chunk - .split('\n\n') - .filter((line) => line.startsWith('data: ')); - - for (const line of lines) { - const eventData = line.replace('data: ', ''); - try { - events.push(JSON.parse(eventData)); - } catch (e) { - // Skip malformed events - } - } - } - - // Stop after collecting some events - if (events.length >= 3) { - reader.cancel(); - break; - } - } - } - - // Verify we received stream events - expect(events.length).toBeGreaterThan(0); - - // Cleanup - await fetch(`${workerUrl}/api/process/${processId}`, { - method: 'DELETE', - headers - }); - }, 90000); - - test.skipIf(skipPortExposureTests)( - 'should expose port and verify HTTP access', - async () => { - const sandboxId = createSandboxId(); - const headers = createTestHeaders(sandboxId); - - // Write and start a server - const serverCode = ` -const server = Bun.serve({ - port: 8080, - fetch(req) { - return new Response(JSON.stringify({ message: "Hello from Bun!" }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/script.js', + content: scriptCode + }) }); - }, -}); - -console.log("Server started on port 8080"); - `.trim(); - - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/app.js', - content: serverCode - }) - }); - - // Start the server - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'bun run /workspace/app.js' - }) - }); - - const startData = (await startResponse.json()) as Process; - const processId = startData.id; - - // Wait for server to start - await new Promise((resolve) => setTimeout(resolve, 3000)); - - // Expose port - const exposeResponse = await fetch(`${workerUrl}/api/port/expose`, { - method: 'POST', - headers, - body: JSON.stringify({ - port: 8080, - name: 'test-server' - }) - }); - - expect(exposeResponse.status).toBe(200); - const exposeData = (await exposeResponse.json()) as PortExposeResult; - expect(exposeData.url).toBeTruthy(); - const previewUrl = exposeData.url!; - - // Make HTTP request to preview URL - const healthResponse = await fetch(previewUrl); - expect(healthResponse.status).toBe(200); - const healthData = (await healthResponse.json()) as { message: string }; - expect(healthData.message).toBe('Hello from Bun!'); - - // Cleanup - unexpose port and kill process - await fetch(`${workerUrl}/api/exposed-ports/8080`, { - method: 'DELETE' - }); - - await fetch(`${workerUrl}/api/process/${processId}`, { - method: 'DELETE', - headers - }); - }, - 90000 - ); - - test('should kill all processes at once', async () => { - const sandboxId = createSandboxId(); - const headers = createTestHeaders(sandboxId); - // Start 3 long-running processes - const processes: string[] = []; - for (let i = 0; i < 3; i++) { - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'sleep 120' - }) - }); - - const data = (await startResponse.json()) as Process; - processes.push(data.id); - } - - // Wait for all processes to be registered - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // Verify all processes are running - const listResponse = await fetch(`${workerUrl}/api/process/list`, { - method: 'GET', - headers - }); - const listData = (await listResponse.json()) as Process[]; - expect(listData.length).toBeGreaterThanOrEqual(3); - - // Kill all processes - const killAllResponse = await fetch(`${workerUrl}/api/process/kill-all`, { - method: 'POST', - headers, - body: JSON.stringify({}) - }); - - expect(killAllResponse.status).toBe(200); + // Start the script + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'bun run /workspace/script.js' + }) + }); - // Verify processes are killed (list should be empty or only have non-running processes) - await new Promise((resolve) => setTimeout(resolve, 1000)); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; - const listAfterResponse = await fetch(`${workerUrl}/api/process/list`, { + // Stream logs (SSE) + const streamResponse = await fetch( + `${workerUrl}/api/process/${processId}/stream`, + { method: 'GET', headers - }); - const listAfterData = (await listAfterResponse.json()) as Process[]; - - // Should have fewer running processes now - const runningProcesses = listAfterData.filter( - (p) => p.status === 'running' - ); - expect(runningProcesses.length).toBe(0); - }, 90000); - - test.skipIf(skipPortExposureTests)( - 'should handle complete workflow: write → start → monitor → expose → request → cleanup', - async () => { - const sandboxId = createSandboxId(); - const headers = createTestHeaders(sandboxId); - - // Complete realistic workflow - const serverCode = ` -const server = Bun.serve({ - port: 8080, - fetch(req) { - const url = new URL(req.url); - if (url.pathname === '/health') { - return new Response(JSON.stringify({ status: 'ok' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } - return new Response('Not found', { status: 404 }); - }, -}); - -console.log("Server listening on port 8080"); - `.trim(); - - // Step 1: Write server code - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/health-server.js', - content: serverCode - }) - }); - - // Step 2: Start the process - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'bun run /workspace/health-server.js' - }) - }); - - expect(startResponse.status).toBe(200); - const startData = (await startResponse.json()) as Process; - const processId = startData.id; - - // Step 3: Wait and verify process is running - await new Promise((resolve) => setTimeout(resolve, 3000)); - - const statusResponse = await fetch( - `${workerUrl}/api/process/${processId}`, - { - method: 'GET', - headers - } - ); - - expect(statusResponse.status).toBe(200); - const statusData = (await statusResponse.json()) as Process; - expect(statusData.status).toBe('running'); - - // Step 4: Expose port - const exposeResponse = await fetch(`${workerUrl}/api/port/expose`, { - method: 'POST', - headers, - body: JSON.stringify({ - port: 8080, - name: 'health-api' - }) - }); - - expect(exposeResponse.status).toBe(200); - const exposeData = (await exposeResponse.json()) as PortExposeResult; - const previewUrl = exposeData.url; - - // Step 5: Make HTTP request to health endpoint - const healthResponse = await fetch( - new URL('/health', previewUrl).toString() - ); - expect(healthResponse.status).toBe(200); - const healthData = (await healthResponse.json()) as { status: string }; - expect(healthData.status).toBe('ok'); - - // Step 6: Get process logs - const logsResponse = await fetch( - `${workerUrl}/api/process/${processId}/logs`, - { - method: 'GET', - headers - } - ); - - expect(logsResponse.status).toBe(200); - const logsData = (await logsResponse.json()) as ProcessLogsResult; - expect(logsData.stdout).toContain('Server listening on port 8080'); - - // Step 7: Cleanup - unexpose port and kill the process - await fetch(`${workerUrl}/api/exposed-ports/8080`, { - method: 'DELETE' - }); - - const killResponse = await fetch( - `${workerUrl}/api/process/${processId}`, - { - method: 'DELETE', - headers - } - ); - - expect(killResponse.status).toBe(200); - }, - 120000 - ); - - test('should return error when killing nonexistent process', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Try to kill a process that doesn't exist - const killResponse = await fetch( - `${workerUrl}/api/process/fake-process-id-12345`, - { - method: 'DELETE', - headers - } - ); - - // Should return error - expect(killResponse.status).toBe(500); - const errorData = (await killResponse.json()) as { error: string }; - expect(errorData.error).toBeTruthy(); - expect(errorData.error).toMatch( - /not found|does not exist|invalid|unknown/i - ); - }, 90000); - - test.skipIf(skipPortExposureTests)( - 'should reject exposing reserved ports', - async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Try to expose a reserved port (e.g., port 22 - SSH) - const exposeResponse = await fetch(`${workerUrl}/api/port/expose`, { - method: 'POST', - headers, - body: JSON.stringify({ - port: 22, - name: 'ssh-server' - }) - }); - - // Should return error for reserved port - expect(exposeResponse.status).toBe(500); - const errorData = (await exposeResponse.json()) as { error: string }; - expect(errorData.error).toBeTruthy(); - expect(errorData.error).toMatch( - /reserved|not allowed|forbidden|invalid port/i - ); - }, - 90000 + } ); - test.skipIf(skipPortExposureTests)( - 'should return error when unexposing non-exposed port', - async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Initialize sandbox first - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "init"' - }) - }); - - // Try to unexpose a port that was never exposed - const unexposeResponse = await fetch( - `${workerUrl}/api/exposed-ports/9999`, - { - method: 'DELETE' - } - ); - - // Should return error - expect(unexposeResponse.status).toBe(500); - const errorData = (await unexposeResponse.json()) as { error: string }; - expect(errorData.error).toBeTruthy(); - expect(errorData.error).toMatch( - /not found|not exposed|does not exist/i - ); - }, - 90000 + expect(streamResponse.status).toBe(200); + expect(streamResponse.headers.get('content-type')).toBe( + 'text/event-stream' ); - test('should retrieve completed process metadata', async () => { - const sandboxId = createSandboxId(); - const headers = createTestHeaders(sandboxId); - - // Start a short-lived process that completes quickly - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "test output"' - }) - }); - - expect(startResponse.status).toBe(200); - const startData = (await startResponse.json()) as Process; - const processId = startData.id; - - // Poll until process completes (pattern developers would use) - let processData; - const maxAttempts = 60; // 30 seconds with 500ms intervals - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const getResponse = await fetch( - `${workerUrl}/api/process/${processId}`, - { - method: 'GET', - headers + // Collect events from the stream + const reader = streamResponse.body?.getReader(); + const decoder = new TextDecoder(); + const events: any[] = []; + + if (reader) { + let done = false; + let timeout = Date.now() + 10000; // 10s timeout + + while (!done && Date.now() < timeout) { + const { value, done: streamDone } = await reader.read(); + done = streamDone; + + if (value) { + const chunk = decoder.decode(value); + const lines = chunk + .split('\n\n') + .filter((line) => line.startsWith('data: ')); + + for (const line of lines) { + const eventData = line.replace('data: ', ''); + try { + events.push(JSON.parse(eventData)); + } catch (e) { + // Skip malformed events + } } - ); - - expect(getResponse.status).toBe(200); - processData = (await getResponse.json()) as Process; - - if (processData.status === 'completed') { - break; } - if (attempt < maxAttempts - 1) { - await new Promise((resolve) => setTimeout(resolve, 500)); - } - } - - // Verify completed status - if (!processData) { - throw new Error('Process never completed within timeout'); - } - expect(processData.id).toBe(processId); - expect(processData.status).toBe('completed'); - expect(processData.exitCode).toBe(0); - expect(processData.endTime).toBeTruthy(); - }, 90000); - - test('should include completed processes in list', async () => { - const sandboxId = createSandboxId(); - const headers = createTestHeaders(sandboxId); - - // Start a process that completes quickly - const completedResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "completed process"' - }) - }); - - const completedData = (await completedResponse.json()) as Process; - const completedId = completedData.id; - - // Poll until first process completes (pattern developers would use) - const maxAttempts = 60; // 30 seconds with 500ms intervals - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const getResponse = await fetch( - `${workerUrl}/api/process/${completedId}`, - { - method: 'GET', - headers - } - ); - - expect(getResponse.status).toBe(200); - const data = (await getResponse.json()) as Process; - - if (data.status === 'completed') { + // Stop after collecting some events + if (events.length >= 3) { + reader.cancel(); break; } - - if (attempt < maxAttempts - 1) { - await new Promise((resolve) => setTimeout(resolve, 500)); - } } + } + }, 90000); - // Start a long-running process - const runningResponse = await fetch(`${workerUrl}/api/process/start`, { + test.skipIf(skipPortExposureTests)( + 'should reject exposing reserved ports', + async () => { + const exposeResponse = await fetch(`${workerUrl}/api/port/expose`, { method: 'POST', - headers, + headers: portHeaders, body: JSON.stringify({ - command: 'sleep 60' + port: 22, + name: 'ssh-server' }) }); - const runningData = (await runningResponse.json()) as Process; - const runningId = runningData.id; - - // Wait for running process to start - await new Promise((resolve) => setTimeout(resolve, 500)); - - // List all processes - const listResponse = await fetch(`${workerUrl}/api/process/list`, { - method: 'GET', - headers - }); - - expect(listResponse.status).toBe(200); - const listData = (await listResponse.json()) as Process[]; - - // Should include both completed and running processes - const processIds = listData.map((p: any) => p.id); - expect(processIds).toContain(completedId); - expect(processIds).toContain(runningId); + expect(exposeResponse.status).toBeGreaterThanOrEqual(400); + const errorData = (await exposeResponse.json()) as { error: string }; + expect(errorData.error).toBeTruthy(); + expect(errorData.error).toMatch( + /reserved|not allowed|forbidden|invalid port/i + ); + }, + 90000 + ); + + test.skipIf(skipPortExposureTests)( + 'should return error when unexposing non-exposed port', + async () => { + const unexposeResponse = await fetch( + `${workerUrl}/api/exposed-ports/${PORT_LIFECYCLE_TEST_PORT}`, + { + method: 'DELETE', + headers: portHeaders + } + ); - // Verify statuses - const completedProcess = listData.find((p) => p.id === completedId); - const runningProcess = listData.find((p) => p.id === runningId); + expect(unexposeResponse.status).toBe(500); + const errorData = (await unexposeResponse.json()) as { error: string }; + expect(errorData.error).toBeTruthy(); + expect(errorData.error).toMatch(/not found|not exposed|does not exist/i); + }, + 90000 + ); + + test('should not block foreground operations when background processes are running', async () => { + // Start a long-running background process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'sleep 60' + }) + }); - if (!completedProcess) throw new Error('Completed process not found'); - if (!runningProcess) throw new Error('Running process not found'); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; + + // Immediately run a foreground command - should complete quickly + const execStart = Date.now(); + const execResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "test"' + }) + }); + const execDuration = Date.now() - execStart; - expect(completedProcess.status).toBe('completed'); - expect(runningProcess.status).toBe('running'); + expect(execResponse.status).toBe(200); + expect(execDuration).toBeLessThan(2000); // Should complete in <2s - // Cleanup - kill running process - await fetch(`${workerUrl}/api/process/${runningId}`, { - method: 'DELETE', - headers - }); - }, 90000); - }); + // Cleanup + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, 90000); }); diff --git a/tests/e2e/process-readiness-workflow.test.ts b/tests/e2e/process-readiness-workflow.test.ts index aa9cff02..bbb6e2a8 100644 --- a/tests/e2e/process-readiness-workflow.test.ts +++ b/tests/e2e/process-readiness-workflow.test.ts @@ -1,10 +1,8 @@ -import { describe, test, expect, beforeAll, afterAll, afterEach } from 'vitest'; -import { getTestWorkerUrl, WranglerDevRunner } from './helpers/wrangler-runner'; +import { describe, test, expect, beforeAll } from 'vitest'; import { - createSandboxId, - createTestHeaders, - cleanupSandbox -} from './helpers/test-fixtures'; + getSharedSandbox, + createUniqueSession +} from './helpers/global-sandbox'; import type { Process, WaitForLogResult, PortExposeResult } from '@repo/shared'; // Port exposure tests require custom domain with wildcard DNS routing @@ -19,94 +17,79 @@ const skipPortExposureTests = * - 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 = ` + let workerUrl: string; + let headers: Record; + let portHeaders: Record; + + beforeAll(async () => { + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + headers = sandbox.createHeaders(createUniqueSession()); + // Port exposure requires sandbox headers (not session headers) + portHeaders = { + 'X-Sandbox-Id': sandbox.sandboxId, + 'Content-Type': 'application/json' + }; + }, 120000); + + test('should wait for string pattern in process output', async () => { + // Write a script that outputs a specific message after a delay + const scriptCode = ` console.log("Starting up..."); await Bun.sleep(500); console.log("Server ready on port 8080"); await Bun.sleep(60000); // Keep running - `.trim(); + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/server.js', + content: scriptCode + }) + }); - 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' + }) + }); - // Start the process - const startResponse = await fetch(`${workerUrl}/api/process/start`, { + 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({ - command: 'bun run /workspace/server.js' + pattern: 'Server ready on port 8080', + timeout: 10000 }) - }); - - 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'); + 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); + // Cleanup + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, 60000); - // Write a Bun server that listens on a port - const serverCode = ` + test('should wait for port to become available', async () => { + // Write a Bun server that listens on a port + const serverCode = ` const server = Bun.serve({ hostname: "0.0.0.0", port: 9090, @@ -117,70 +100,67 @@ const server = Bun.serve({ 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; + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/portserver.js', + content: serverCode + }) + }); - // Wait for port 9090 to be available - const waitResponse = await fetch( - `${workerUrl}/api/process/${processId}/waitForPort`, - { - method: 'POST', - headers, - body: JSON.stringify({ - port: 9090, - timeout: 15000 - }) - } - ); + // Start the process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'bun run /workspace/portserver.js' + }) + }); - expect(waitResponse.status).toBe(200); + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; - // Verify the port is actually listening by trying to curl it - const verifyResponse = await fetch(`${workerUrl}/api/execute`, { + // Wait for port 9090 to be available + const waitResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForPort`, + { method: 'POST', headers, body: JSON.stringify({ - command: 'curl -s http://localhost:9090' + port: 9090, + timeout: 15000 }) - }); + } + ); - const verifyData = (await verifyResponse.json()) as { stdout: string }; - expect(verifyData.stdout).toBe('OK'); + expect(waitResponse.status).toBe(200); - // Cleanup - await fetch(`${workerUrl}/api/process/${processId}`, { - method: 'DELETE', - headers - }); - }, 60000); + // Verify the port is actually listening by trying to curl it + const verifyResponse = await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'curl -s http://localhost:9090' + }) + }); + + const verifyData = (await verifyResponse.json()) as { stdout: string }; + expect(verifyData.stdout).toBe('OK'); - test('should chain waitForLog and waitForPort for multiple conditions', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); + // Cleanup + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, 60000); - // Write a script with delayed ready message and a server - const scriptCode = ` + test('should chain waitForLog and waitForPort for multiple conditions', async () => { + // Write a script with delayed ready message and a server + const scriptCode = ` console.log("Initializing..."); await Bun.sleep(500); console.log("Database connected"); @@ -192,179 +172,232 @@ const server = Bun.serve({ }); console.log("Ready to serve requests"); await Bun.sleep(60000); - `.trim(); + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/app.js', + content: scriptCode + }) + }); - await fetch(`${workerUrl}/api/file/write`, { + // 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({ - path: '/workspace/app.js', - content: scriptCode + pattern: 'Database connected', + timeout: 10000 }) - }); + } + ); + expect(waitLogResponse.status).toBe(200); - // Start the process - const startResponse = await fetch(`${workerUrl}/api/process/start`, { + // Then wait for port + const waitPortResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForPort`, + { method: 'POST', headers, body: JSON.stringify({ - command: 'bun run /workspace/app.js' + port: 9091, + timeout: 10000 }) - }); - - 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); + } + ); + expect(waitPortResponse.status).toBe(200); - test('should fail with timeout error if pattern never appears', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); + // Cleanup + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, 60000); - // Write a script that never outputs the expected pattern - const scriptCode = ` + test('should fail with timeout error if pattern never appears', async () => { + // Write a script that never outputs the expected pattern + const scriptCode = ` console.log("Starting..."); console.log("Still starting..."); await Bun.sleep(60000); - `.trim(); + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/slow.js', + content: scriptCode + }) + }); - await fetch(`${workerUrl}/api/file/write`, { + // 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({ - path: '/workspace/slow.js', - content: scriptCode + pattern: 'Server ready', + timeout: 2000 }) - }); + } + ); - // Start the process - const startResponse = await fetch(`${workerUrl}/api/process/start`, { + // Should fail with timeout + expect(waitResponse.status).toBe(500); + const errorData = (await waitResponse.json()) as { error: string }; + expect(errorData.error).toMatch(/timeout|did not become ready/i); + + // Cleanup + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, 60000); + + test('should fail with error if process exits before pattern appears', async () => { + // Start a process that exits immediately + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'echo "quick exit"' + }) + }); + + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; + + // Wait for pattern - should fail because process exits + const waitResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForLog`, + { method: 'POST', headers, body: JSON.stringify({ - command: 'bun run /workspace/slow.js' + pattern: 'Server ready', + timeout: 10000 }) - }); - - 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 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); - // 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); + test('should detect pattern in stderr as well as stdout', async () => { + // Write a script that outputs to stderr + const scriptCode = ` +console.error("Starting up in stderr..."); +await Bun.sleep(300); +console.error("Ready (stderr)"); +await Bun.sleep(60000); + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/stderr.js', + content: scriptCode + }) + }); - // Cleanup - await fetch(`${workerUrl}/api/process/${processId}`, { - method: 'DELETE', - headers - }); - }, 60000); + // Start the process + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'bun run /workspace/stderr.js' + }) + }); - test('should fail with error if process exits before pattern appears', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); + expect(startResponse.status).toBe(200); + const startData = (await startResponse.json()) as Process; + const processId = startData.id; - // Start a process that exits immediately - const startResponse = await fetch(`${workerUrl}/api/process/start`, { + // Wait for the pattern (which appears in stderr) + const waitResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForLog`, + { method: 'POST', headers, body: JSON.stringify({ - command: 'echo "quick exit"' + pattern: 'Ready (stderr)', + timeout: 10000 }) - }); - - 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); + expect(waitResponse.status).toBe(200); + const waitData = (await waitResponse.json()) as WaitForLogResult; + expect(waitData.line).toContain('Ready (stderr)'); - test('should detect pattern in stderr as well as stdout', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); + // Cleanup + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, 60000); - // 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); + test.skipIf(skipPortExposureTests)( + 'should start server, wait for port, and expose it', + async () => { + // Write a simple HTTP server + const serverCode = ` +const server = Bun.serve({ + port: 9092, + fetch(req) { + return new Response(JSON.stringify({ message: "Hello!" }), { + headers: { "Content-Type": "application/json" } + }); + }, +}); +console.log("Server listening on port 9092"); `.trim(); await fetch(`${workerUrl}/api/file/write`, { method: 'POST', headers, body: JSON.stringify({ - path: '/workspace/stderr.js', - content: scriptCode + path: '/workspace/http-server.js', + content: serverCode }) }); @@ -373,7 +406,7 @@ await Bun.sleep(60000); method: 'POST', headers, body: JSON.stringify({ - command: 'bun run /workspace/stderr.js' + command: 'bun run /workspace/http-server.js' }) }); @@ -381,114 +414,49 @@ await Bun.sleep(60000); 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`, + // Wait for port + const waitPortResponse = await fetch( + `${workerUrl}/api/process/${processId}/waitForPort`, { method: 'POST', headers, body: JSON.stringify({ - pattern: 'Ready (stderr)', - timeout: 10000 + port: 9092, + timeout: 30000 }) } ); + expect(waitPortResponse.status).toBe(200); - expect(waitResponse.status).toBe(200); - const waitData = (await waitResponse.json()) as WaitForLogResult; - expect(waitData.line).toContain('Ready (stderr)'); + // Expose the port + const exposeResponse = await fetch(`${workerUrl}/api/port/expose`, { + method: 'POST', + headers: portHeaders, + body: JSON.stringify({ + port: 9092 + }) + }); + + expect(exposeResponse.status).toBe(200); + const exposeData = (await exposeResponse.json()) as PortExposeResult; + expect(exposeData.url).toBeTruthy(); + + // Make a request to the exposed URL + const apiResponse = await fetch(exposeData.url); + expect(apiResponse.status).toBe(200); + const apiData = (await apiResponse.json()) as { message: string }; + expect(apiData.message).toBe('Hello!'); // Cleanup + await fetch(`${workerUrl}/api/exposed-ports/9092`, { + method: 'DELETE', + headers: portHeaders + }); await fetch(`${workerUrl}/api/process/${processId}`, { method: 'DELETE', headers }); - }, 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 - ); - }); + }, + 90000 + ); }); diff --git a/tests/e2e/session-state-isolation-workflow.test.ts b/tests/e2e/session-state-isolation-workflow.test.ts index f42d80a9..42272868 100644 --- a/tests/e2e/session-state-isolation-workflow.test.ts +++ b/tests/e2e/session-state-isolation-workflow.test.ts @@ -1,13 +1,8 @@ +import { describe, test, expect, beforeAll, afterAll } from 'vitest'; import { - describe, - test, - expect, - beforeAll, - afterAll, - afterEach, - vi -} from 'vitest'; -import { getTestWorkerUrl, WranglerDevRunner } from './helpers/wrangler-runner'; + createUniqueSession, + getSharedSandbox +} from './helpers/global-sandbox'; import { createSandboxId, createTestHeaders, @@ -18,62 +13,47 @@ import type { SessionCreateResult, SessionDeleteResult, ReadFileResult, - WriteFileResult, - ExecResult, - ProcessInfoResult + ExecResult } from '@repo/shared'; /** * Session State Isolation Workflow Integration Tests * - * Tests session isolation features as described in README "Session Management" (lines 711-754). + * Tests session isolation features WITHIN a single container. + * Sessions provide isolated shell state (env, cwd, functions) but share + * file system and process space - that's by design! * - * IMPORTANT: As of commit 645672aa, PID namespace isolation was removed. - * Sessions now provide **state isolation** (env vars, cwd, shell state) for workflow organization, - * NOT security isolation. All sessions share the same process table. - * - * This validates: - * - Environment variable isolation between sessions - * - Working directory isolation - * - Shell state isolation (functions, aliases) - * - Process space is SHARED (by design) - * - File system is SHARED (by design) - * - Concurrent execution without output mixing + * All tests share ONE container since we're testing session isolation, + * not container isolation. */ describe('Session State Isolation Workflow', () => { describe('local', () => { - let runner: WranglerDevRunner | null; let workerUrl: string; - let currentSandboxId: string | null = null; + let sandboxId: string; + let baseHeaders: Record; beforeAll(async () => { - // Get test worker URL (CI: uses deployed URL, Local: spawns wrangler dev) - const result = await getTestWorkerUrl(); - workerUrl = result.url; - runner = result.runner; - }); - - afterEach(async () => { - // Cleanup sandbox container after each test - if (currentSandboxId) { - await cleanupSandbox(workerUrl, currentSandboxId); - currentSandboxId = null; - } - }); - - afterAll(async () => { - if (runner) { - await runner.stop(); - } - }); + // Create ONE sandbox for all session isolation tests + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + sandboxId = sandbox.sandboxId; + baseHeaders = createTestHeaders(sandboxId, createUniqueSession()); - test('should isolate environment variables between sessions', async () => { - currentSandboxId = createSandboxId(); + // Initialize the sandbox + await fetch(`${workerUrl}/api/execute`, { + method: 'POST', + headers: baseHeaders, + body: JSON.stringify({ + command: 'echo "Session isolation sandbox ready"' + }) + }); + }, 120000); + test('should isolate environment variables between sessions', async () => { // Create session1 with production environment const session1Response = await fetch(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId!), + headers: createTestHeaders(sandboxId!), body: JSON.stringify({ env: { NODE_ENV: 'production', @@ -93,7 +73,7 @@ describe('Session State Isolation Workflow', () => { // Create session2 with test environment const session2Response = await fetch(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId!), + headers: createTestHeaders(sandboxId!), body: JSON.stringify({ env: { NODE_ENV: 'test', @@ -112,7 +92,7 @@ describe('Session State Isolation Workflow', () => { // Verify session1 has production environment const exec1Response = await fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session1Id), + headers: createTestHeaders(sandboxId, session1Id), body: JSON.stringify({ command: 'echo "$NODE_ENV|$API_KEY|$DB_HOST"' }) @@ -128,7 +108,7 @@ describe('Session State Isolation Workflow', () => { // Verify session2 has test environment const exec2Response = await fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session2Id), + headers: createTestHeaders(sandboxId, session2Id), body: JSON.stringify({ command: 'echo "$NODE_ENV|$API_KEY|$DB_HOST"' }) @@ -144,7 +124,7 @@ describe('Session State Isolation Workflow', () => { // Set NEW_VAR in session1 dynamically const setEnv1Response = await fetch(`${workerUrl}/api/env/set`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session1Id), + headers: createTestHeaders(sandboxId, session1Id), body: JSON.stringify({ envVars: { NEW_VAR: 'session1-only' } }) @@ -155,7 +135,7 @@ describe('Session State Isolation Workflow', () => { // Verify NEW_VAR exists in session1 const check1Response = await fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session1Id), + headers: createTestHeaders(sandboxId, session1Id), body: JSON.stringify({ command: 'echo $NEW_VAR' }) @@ -167,7 +147,7 @@ describe('Session State Isolation Workflow', () => { // Verify NEW_VAR does NOT leak to session2 const check2Response = await fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session2Id), + headers: createTestHeaders(sandboxId, session2Id), body: JSON.stringify({ command: 'echo "VALUE:$NEW_VAR:END"' }) @@ -178,12 +158,10 @@ describe('Session State Isolation Workflow', () => { }, 90000); test('should isolate working directories between sessions', async () => { - currentSandboxId = createSandboxId(); - // Create directory structure first (using default session) await fetch(`${workerUrl}/api/file/mkdir`, { method: 'POST', - headers: createTestHeaders(currentSandboxId!), + headers: createTestHeaders(sandboxId!), body: JSON.stringify({ path: '/workspace/app', recursive: true @@ -192,7 +170,7 @@ describe('Session State Isolation Workflow', () => { await fetch(`${workerUrl}/api/file/mkdir`, { method: 'POST', - headers: createTestHeaders(currentSandboxId!), + headers: createTestHeaders(sandboxId!), body: JSON.stringify({ path: '/workspace/test', recursive: true @@ -201,7 +179,7 @@ describe('Session State Isolation Workflow', () => { await fetch(`${workerUrl}/api/file/mkdir`, { method: 'POST', - headers: createTestHeaders(currentSandboxId!), + headers: createTestHeaders(sandboxId!), body: JSON.stringify({ path: '/workspace/app/src', recursive: true @@ -210,7 +188,7 @@ describe('Session State Isolation Workflow', () => { await fetch(`${workerUrl}/api/file/mkdir`, { method: 'POST', - headers: createTestHeaders(currentSandboxId!), + headers: createTestHeaders(sandboxId!), body: JSON.stringify({ path: '/workspace/test/unit', recursive: true @@ -220,7 +198,7 @@ describe('Session State Isolation Workflow', () => { // Create session1 with cwd: /workspace/app const session1Response = await fetch(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId!), + headers: createTestHeaders(sandboxId!), body: JSON.stringify({ cwd: '/workspace/app' }) @@ -233,7 +211,7 @@ describe('Session State Isolation Workflow', () => { // Create session2 with cwd: /workspace/test const session2Response = await fetch(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId!), + headers: createTestHeaders(sandboxId!), body: JSON.stringify({ cwd: '/workspace/test' }) @@ -246,7 +224,7 @@ describe('Session State Isolation Workflow', () => { // Verify session1 starts in /workspace/app const pwd1Response = await fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session1Id), + headers: createTestHeaders(sandboxId, session1Id), body: JSON.stringify({ command: 'pwd' }) @@ -258,7 +236,7 @@ describe('Session State Isolation Workflow', () => { // Verify session2 starts in /workspace/test const pwd2Response = await fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session2Id), + headers: createTestHeaders(sandboxId, session2Id), body: JSON.stringify({ command: 'pwd' }) @@ -270,7 +248,7 @@ describe('Session State Isolation Workflow', () => { // Change directory in session1 await fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session1Id), + headers: createTestHeaders(sandboxId, session1Id), body: JSON.stringify({ command: 'cd src' }) @@ -279,7 +257,7 @@ describe('Session State Isolation Workflow', () => { // Change directory in session2 await fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session2Id), + headers: createTestHeaders(sandboxId, session2Id), body: JSON.stringify({ command: 'cd unit' }) @@ -288,7 +266,7 @@ describe('Session State Isolation Workflow', () => { // Verify session1 is in /workspace/app/src const newPwd1Response = await fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session1Id), + headers: createTestHeaders(sandboxId, session1Id), body: JSON.stringify({ command: 'pwd' }) @@ -300,7 +278,7 @@ describe('Session State Isolation Workflow', () => { // Verify session2 is in /workspace/test/unit const newPwd2Response = await fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session2Id), + headers: createTestHeaders(sandboxId, session2Id), body: JSON.stringify({ command: 'pwd' }) @@ -311,12 +289,10 @@ describe('Session State Isolation Workflow', () => { }, 90000); test('should isolate shell state (functions and aliases) between sessions', async () => { - currentSandboxId = createSandboxId(); - // Create two sessions const session1Response = await fetch(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId!), + headers: createTestHeaders(sandboxId!), body: JSON.stringify({}) }); @@ -326,7 +302,7 @@ describe('Session State Isolation Workflow', () => { const session2Response = await fetch(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId!), + headers: createTestHeaders(sandboxId!), body: JSON.stringify({}) }); @@ -337,7 +313,7 @@ describe('Session State Isolation Workflow', () => { // Define greet() function in session1 const defineFunc1Response = await fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session1Id), + headers: createTestHeaders(sandboxId, session1Id), body: JSON.stringify({ command: 'greet() { echo "Hello from Production"; }' }) @@ -348,7 +324,7 @@ describe('Session State Isolation Workflow', () => { // Call greet() in session1 - should work const call1Response = await fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session1Id), + headers: createTestHeaders(sandboxId, session1Id), body: JSON.stringify({ command: 'greet' }) @@ -361,7 +337,7 @@ describe('Session State Isolation Workflow', () => { // Try to call greet() in session2 - should fail const call2Response = await fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session2Id), + headers: createTestHeaders(sandboxId, session2Id), body: JSON.stringify({ command: 'greet' }) @@ -374,7 +350,7 @@ describe('Session State Isolation Workflow', () => { // Define different greet() function in session2 await fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session2Id), + headers: createTestHeaders(sandboxId, session2Id), body: JSON.stringify({ command: 'greet() { echo "Hello from Test"; }' }) @@ -383,7 +359,7 @@ describe('Session State Isolation Workflow', () => { // Call greet() in session2 - should use session2's definition const call3Response = await fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session2Id), + headers: createTestHeaders(sandboxId, session2Id), body: JSON.stringify({ command: 'greet' }) @@ -396,7 +372,7 @@ describe('Session State Isolation Workflow', () => { // Verify session1's greet() is still unchanged const call4Response = await fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session1Id), + headers: createTestHeaders(sandboxId, session1Id), body: JSON.stringify({ command: 'greet' }) @@ -407,12 +383,10 @@ describe('Session State Isolation Workflow', () => { }, 90000); test('should share process space between sessions (by design)', async () => { - currentSandboxId = createSandboxId(); - // Create two sessions const session1Response = await fetch(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId!), + headers: createTestHeaders(sandboxId!), body: JSON.stringify({}) }); @@ -422,7 +396,7 @@ describe('Session State Isolation Workflow', () => { const session2Response = await fetch(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId!), + headers: createTestHeaders(sandboxId!), body: JSON.stringify({}) }); @@ -433,7 +407,7 @@ describe('Session State Isolation Workflow', () => { // Start a long-running process in session1 const startResponse = await fetch(`${workerUrl}/api/process/start`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session1Id), + headers: createTestHeaders(sandboxId, session1Id), body: JSON.stringify({ command: 'sleep 120' }) @@ -449,7 +423,7 @@ describe('Session State Isolation Workflow', () => { // List processes from session2 - should see session1's process (shared process table) const listResponse = await fetch(`${workerUrl}/api/process/list`, { method: 'GET', - headers: createTestHeaders(currentSandboxId, session2Id) + headers: createTestHeaders(sandboxId, session2Id) }); expect(listResponse.status).toBe(200); @@ -469,7 +443,7 @@ describe('Session State Isolation Workflow', () => { `${workerUrl}/api/process/${processId}`, { method: 'DELETE', - headers: createTestHeaders(currentSandboxId, session2Id) + headers: createTestHeaders(sandboxId, session2Id) } ); @@ -482,7 +456,7 @@ describe('Session State Isolation Workflow', () => { `${workerUrl}/api/process/${processId}`, { method: 'GET', - headers: createTestHeaders(currentSandboxId, session1Id) + headers: createTestHeaders(sandboxId, session1Id) } ); @@ -491,12 +465,10 @@ describe('Session State Isolation Workflow', () => { }, 90000); test('should share file system between sessions (by design)', async () => { - currentSandboxId = createSandboxId(); - // Create two sessions const session1Response = await fetch(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId!), + headers: createTestHeaders(sandboxId!), body: JSON.stringify({}) }); @@ -506,7 +478,7 @@ describe('Session State Isolation Workflow', () => { const session2Response = await fetch(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId!), + headers: createTestHeaders(sandboxId!), body: JSON.stringify({}) }); @@ -517,7 +489,7 @@ describe('Session State Isolation Workflow', () => { // Write a file from session1 const writeResponse = await fetch(`${workerUrl}/api/file/write`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session1Id), + headers: createTestHeaders(sandboxId, session1Id), body: JSON.stringify({ path: '/workspace/shared.txt', content: 'Written by session1' @@ -529,7 +501,7 @@ describe('Session State Isolation Workflow', () => { // Read the file from session2 - should see session1's content const readResponse = await fetch(`${workerUrl}/api/file/read`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session2Id), + headers: createTestHeaders(sandboxId, session2Id), body: JSON.stringify({ path: '/workspace/shared.txt' }) @@ -542,7 +514,7 @@ describe('Session State Isolation Workflow', () => { // Modify the file from session2 const modifyResponse = await fetch(`${workerUrl}/api/file/write`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session2Id), + headers: createTestHeaders(sandboxId, session2Id), body: JSON.stringify({ path: '/workspace/shared.txt', content: 'Modified by session2' @@ -554,7 +526,7 @@ describe('Session State Isolation Workflow', () => { // Read from session1 - should see session2's modification const verifyResponse = await fetch(`${workerUrl}/api/file/read`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session1Id), + headers: createTestHeaders(sandboxId, session1Id), body: JSON.stringify({ path: '/workspace/shared.txt' }) @@ -566,7 +538,7 @@ describe('Session State Isolation Workflow', () => { // Cleanup await fetch(`${workerUrl}/api/file/delete`, { method: 'DELETE', - headers: createTestHeaders(currentSandboxId, session1Id), + headers: createTestHeaders(sandboxId, session1Id), body: JSON.stringify({ path: '/workspace/shared.txt' }) @@ -574,12 +546,10 @@ describe('Session State Isolation Workflow', () => { }, 90000); test('should support concurrent execution without output mixing', async () => { - currentSandboxId = createSandboxId(); - // Create two sessions const session1Response = await fetch(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId!), + headers: createTestHeaders(sandboxId!), body: JSON.stringify({ env: { SESSION_NAME: 'session1' } }) @@ -591,7 +561,7 @@ describe('Session State Isolation Workflow', () => { const session2Response = await fetch(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId!), + headers: createTestHeaders(sandboxId!), body: JSON.stringify({ env: { SESSION_NAME: 'session2' } }) @@ -604,7 +574,7 @@ describe('Session State Isolation Workflow', () => { // Execute commands simultaneously const exec1Promise = fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session1Id), + headers: createTestHeaders(sandboxId, session1Id), body: JSON.stringify({ command: 'sleep 2 && echo "Completed in $SESSION_NAME"' }) @@ -612,7 +582,7 @@ describe('Session State Isolation Workflow', () => { const exec2Promise = fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, session2Id), + headers: createTestHeaders(sandboxId, session2Id), body: JSON.stringify({ command: 'sleep 2 && echo "Completed in $SESSION_NAME"' }) @@ -640,12 +610,10 @@ describe('Session State Isolation Workflow', () => { }, 90000); test('should properly cleanup session resources with deleteSession', async () => { - currentSandboxId = createSandboxId(); - // Create a session with custom environment variable const sessionResponse = await fetch(`${workerUrl}/api/session/create`, { method: 'POST', - headers: createTestHeaders(currentSandboxId!), + headers: createTestHeaders(sandboxId!), body: JSON.stringify({ env: { SESSION_VAR: 'test-value' } }) @@ -658,7 +626,7 @@ describe('Session State Isolation Workflow', () => { // Verify session works before deletion const execBeforeResponse = await fetch(`${workerUrl}/api/execute`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, sessionId), + headers: createTestHeaders(sandboxId, sessionId), body: JSON.stringify({ command: 'echo $SESSION_VAR' }) @@ -671,7 +639,7 @@ describe('Session State Isolation Workflow', () => { // Delete the session const deleteResponse = await fetch(`${workerUrl}/api/session/delete`, { method: 'POST', - headers: createTestHeaders(currentSandboxId!), + headers: createTestHeaders(sandboxId!), body: JSON.stringify({ sessionId: sessionId }) @@ -690,7 +658,7 @@ describe('Session State Isolation Workflow', () => { `${workerUrl}/api/execute`, { method: 'POST', - headers: createTestHeaders(currentSandboxId, sessionId), // Use same session ID + headers: createTestHeaders(sandboxId, sessionId), // Use same session ID body: JSON.stringify({ command: 'echo $SESSION_VAR' }) @@ -709,7 +677,7 @@ describe('Session State Isolation Workflow', () => { `${workerUrl}/api/execute`, { method: 'POST', - headers: createTestHeaders(currentSandboxId!), // Use default session + headers: createTestHeaders(sandboxId!), // Use default session body: JSON.stringify({ command: 'echo "sandbox-alive"' }) diff --git a/tests/e2e/streaming-operations-workflow.test.ts b/tests/e2e/streaming-operations-workflow.test.ts index 1face4c4..edbab45c 100644 --- a/tests/e2e/streaming-operations-workflow.test.ts +++ b/tests/e2e/streaming-operations-workflow.test.ts @@ -1,680 +1,198 @@ +import { describe, test, expect, beforeAll } from 'vitest'; import { - describe, - test, - expect, - beforeAll, - afterAll, - afterEach, - vi -} from 'vitest'; -import { getTestWorkerUrl, WranglerDevRunner } from './helpers/wrangler-runner'; -import { - createSandboxId, - createTestHeaders, - cleanupSandbox -} from './helpers/test-fixtures'; + getSharedSandbox, + createUniqueSession +} from './helpers/global-sandbox'; import { parseSSEStream } from '../../packages/sandbox/src/sse-parser'; -import type { ExecEvent, SessionCreateResult } from '@repo/shared'; +import type { ExecEvent } from '@repo/shared'; /** - * Streaming Operations Workflow Integration Tests + * Streaming Operations Edge Case Tests * - * Tests the README "AsyncIterable Streaming Support" (lines 636-709): - * - Real-time output streaming via execStream() - * - Event types: start, stdout, stderr, complete, error - * - State persistence after streaming commands - * - Error handling during streaming - * - Concurrent streaming operations + * Tests error handling and edge cases for streaming. + * Basic streaming tests are in comprehensive-workflow.test.ts. * - * This validates the execStream() method which provides SSE-based - * streaming for real-time command output. + * This file focuses on: + * - Command failures with non-zero exit codes + * - Nonexistent commands (exit code 127) + * - Chunked output delivery over time + * - File content streaming */ -describe('Streaming Operations Workflow', () => { - describe('local', () => { - let runner: WranglerDevRunner | null; - let workerUrl: string; - let currentSandboxId: string | null = null; - - beforeAll(async () => { - // Get test worker URL (CI: uses deployed URL, Local: spawns wrangler dev) - const result = await getTestWorkerUrl(); - workerUrl = result.url; - runner = result.runner; - }); - - afterEach(async () => { - // Cleanup sandbox container after each test - if (currentSandboxId) { - await cleanupSandbox(workerUrl, currentSandboxId); - currentSandboxId = null; - } - }); - - afterAll(async () => { - if (runner) { - await runner.stop(); - } - }); - - /** - * Helper to collect events from streaming response using SDK's parseSSEStream utility - */ - async function collectSSEEvents( - response: Response, - maxEvents: number = 50 - ): Promise { - if (!response.body) { - throw new Error('No readable stream in response'); - } - - console.log('[Test] Starting to consume stream...'); - const events: ExecEvent[] = []; - const abortController = new AbortController(); - - try { - for await (const event of parseSSEStream( - response.body, - abortController.signal - )) { - console.log('[Test] Received event:', event.type); - events.push(event); - - // Stop after complete or error event - if (event.type === 'complete' || event.type === 'error') { - abortController.abort(); - break; - } +describe('Streaming Operations Edge Cases', () => { + let workerUrl: string; + let headers: Record; + + beforeAll(async () => { + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + headers = sandbox.createHeaders(createUniqueSession()); + }, 120000); + + async function collectSSEEvents( + response: Response, + maxEvents: number = 50 + ): Promise { + if (!response.body) { + throw new Error('No readable stream in response'); + } - // Stop if we've collected enough events - if (events.length >= maxEvents) { - abortController.abort(); - break; - } + const events: ExecEvent[] = []; + const abortController = new AbortController(); + + try { + for await (const event of parseSSEStream( + response.body, + abortController.signal + )) { + events.push(event); + if (event.type === 'complete' || event.type === 'error') { + abortController.abort(); + break; } - } catch (error) { - // Ignore abort errors (expected when we stop early) - if ( - error instanceof Error && - error.message !== 'Operation was aborted' - ) { - throw error; + if (events.length >= maxEvents) { + abortController.abort(); + break; } } - - console.log('[Test] Collected', events.length, 'events total'); - return events; - } - - test('should stream stdout events in real-time', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Stream a command that outputs multiple lines - const streamResponse = await fetch(`${workerUrl}/api/execStream`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "Line 1"; echo "Line 2"; echo "Line 3"' - }) - }); - - expect(streamResponse.status).toBe(200); - expect(streamResponse.headers.get('content-type')).toBe( - 'text/event-stream' - ); - - // Collect events from stream - const events = await collectSSEEvents(streamResponse); - - // Verify we got events - expect(events.length).toBeGreaterThan(0); - - // Should have start event - const startEvent = events.find((e) => e.type === 'start'); - expect(startEvent).toBeDefined(); - expect(startEvent?.command).toContain('echo'); - - // Should have stdout events - const stdoutEvents = events.filter((e) => e.type === 'stdout'); - expect(stdoutEvents.length).toBeGreaterThan(0); - - // Combine stdout data - const output = stdoutEvents.map((e) => e.data).join(''); - expect(output).toContain('Line 1'); - expect(output).toContain('Line 2'); - expect(output).toContain('Line 3'); - - // Should have complete event - const completeEvent = events.find((e) => e.type === 'complete'); - expect(completeEvent).toBeDefined(); - expect(completeEvent?.exitCode).toBe(0); - }, 90000); - - test('should stream stderr events separately', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Stream a command that outputs to both stdout and stderr (wrap in bash -c for >&2) - const streamResponse = await fetch(`${workerUrl}/api/execStream`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: "bash -c 'echo stdout message; echo stderr message >&2'" - }) - }); - - expect(streamResponse.status).toBe(200); - - const events = await collectSSEEvents(streamResponse); - - // Should have both stdout and stderr events - const stdoutEvents = events.filter((e) => e.type === 'stdout'); - const stderrEvents = events.filter((e) => e.type === 'stderr'); - - expect(stdoutEvents.length).toBeGreaterThan(0); - expect(stderrEvents.length).toBeGreaterThan(0); - - // Verify data - const stdoutData = stdoutEvents.map((e) => e.data).join(''); - const stderrData = stderrEvents.map((e) => e.data).join(''); - - expect(stdoutData).toContain('stdout message'); - expect(stderrData).toContain('stderr message'); - - // Verify stdout doesn't contain stderr and vice versa - expect(stdoutData).not.toContain('stderr message'); - expect(stderrData).not.toContain('stdout message'); - }, 90000); - - test('should include all event types: start, stdout, complete', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - const streamResponse = await fetch(`${workerUrl}/api/execStream`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "Hello Streaming"' - }) - }); - - const events = await collectSSEEvents(streamResponse); - - // Verify event types - const eventTypes = new Set(events.map((e) => e.type)); - - expect(eventTypes.has('start')).toBe(true); - expect(eventTypes.has('stdout')).toBe(true); - expect(eventTypes.has('complete')).toBe(true); - - // Verify event order: start should be first, complete should be last - expect(events[0].type).toBe('start'); - expect(events[events.length - 1].type).toBe('complete'); - - // Verify all events have timestamps - for (const event of events) { - expect(event.timestamp).toBeDefined(); - expect(typeof event.timestamp).toBe('string'); - } - }, 90000); - - test('should handle command failures with non-zero exit code', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Stream a command that fails - const streamResponse = await fetch(`${workerUrl}/api/execStream`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'false' // Always fails with exit code 1 - }) - }); - - const events = await collectSSEEvents(streamResponse); - - // Should have complete event with non-zero exit code - const completeEvent = events.find((e) => e.type === 'complete'); - expect(completeEvent).toBeDefined(); - expect(completeEvent?.exitCode).not.toBe(0); - }, 90000); - - test('should handle nonexistent commands with proper exit code', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Initialize sandbox first - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "init"' - }) - }); - - // Try to stream a nonexistent command (should execute and fail with exit code 127) - const streamResponse = await fetch(`${workerUrl}/api/execStream`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'nonexistentcommand123' - }) - }); - - // Should return 200 (streaming started successfully) - expect(streamResponse.status).toBe(200); - expect(streamResponse.headers.get('content-type')).toBe( - 'text/event-stream' - ); - - // Collect events from stream - const events = await collectSSEEvents(streamResponse); - - // Should have complete event with exit code 127 (command not found) - const completeEvent = events.find((e) => e.type === 'complete'); - expect(completeEvent).toBeDefined(); - expect(completeEvent?.exitCode).toBe(127); // Standard Unix "command not found" exit code - - // Should have stderr events with error message - const stderrEvents = events.filter((e) => e.type === 'stderr'); - expect(stderrEvents.length).toBeGreaterThan(0); - - // Verify stderr contains command not found message - const stderrData = stderrEvents.map((e) => e.data).join(''); - expect(stderrData.toLowerCase()).toMatch(/command not found|not found/); - }, 90000); - - test('should handle environment variables in streaming commands', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Stream a command that sets and uses a variable within the same bash invocation - const streamResponse1 = await fetch(`${workerUrl}/api/execStream`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: "bash -c 'STREAM_VAR=streaming-value; echo $STREAM_VAR'" - }) - }); - - const events1 = await collectSSEEvents(streamResponse1); - const completeEvent1 = events1.find((e) => e.type === 'complete'); - expect(completeEvent1?.exitCode).toBe(0); - - // Verify the output shows the variable value - const stdoutEvents1 = events1.filter((e) => e.type === 'stdout'); - const output1 = stdoutEvents1.map((e) => e.data).join(''); - expect(output1).toContain('streaming-value'); - }, 90000); - - test('should handle long-running streaming commands', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Stream a command that outputs over time (wrap in bash -c for loop) - const streamResponse = await fetch(`${workerUrl}/api/execStream`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: - 'bash -c \'for i in 1 2 3 4 5; do echo "Count: $i"; sleep 0.2; done\'' - }) - }); - - const events = await collectSSEEvents(streamResponse, 20); - - // Should receive multiple stdout events over time - const stdoutEvents = events.filter((e) => e.type === 'stdout'); - expect(stdoutEvents.length).toBeGreaterThanOrEqual(5); - - // Verify we got all counts - const output = stdoutEvents.map((e) => e.data).join(''); - for (let i = 1; i <= 5; i++) { - expect(output).toContain(`Count: ${i}`); - } - - // Should complete successfully - const completeEvent = events.find((e) => e.type === 'complete'); - expect(completeEvent).toBeDefined(); - expect(completeEvent?.exitCode).toBe(0); - }, 90000); - - test('should support concurrent streaming operations', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Initialize with first request - await fetch(`${workerUrl}/api/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "init"' - }) - }); - - // Start two streaming commands concurrently - const stream1Promise = fetch(`${workerUrl}/api/execStream`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "Stream 1"; sleep 1; echo "Stream 1 done"' - }) - }); - - const stream2Promise = fetch(`${workerUrl}/api/execStream`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: 'echo "Stream 2"; sleep 1; echo "Stream 2 done"' - }) - }); - - // Wait for both streams to start - const [stream1Response, stream2Response] = await Promise.all([ - stream1Promise, - stream2Promise - ]); - - expect(stream1Response.status).toBe(200); - expect(stream2Response.status).toBe(200); - - // Collect events from both streams - const [events1, events2] = await Promise.all([ - collectSSEEvents(stream1Response), - collectSSEEvents(stream2Response) - ]); - - // Verify both completed successfully - const complete1 = events1.find((e) => e.type === 'complete'); - const complete2 = events2.find((e) => e.type === 'complete'); - - expect(complete1).toBeDefined(); - expect(complete1?.exitCode).toBe(0); - expect(complete2).toBeDefined(); - expect(complete2?.exitCode).toBe(0); - - // Verify outputs didn't mix - const output1 = events1 - .filter((e) => e.type === 'stdout') - .map((e) => e.data) - .join(''); - const output2 = events2 - .filter((e) => e.type === 'stdout') - .map((e) => e.data) - .join(''); - - expect(output1).toContain('Stream 1'); - expect(output1).not.toContain('Stream 2'); - expect(output2).toContain('Stream 2'); - expect(output2).not.toContain('Stream 1'); - }, 90000); - - test('should work with explicit sessions', async () => { - currentSandboxId = createSandboxId(); - - // Create a session with environment variables - const sessionResponse = await fetch(`${workerUrl}/api/session/create`, { - method: 'POST', - headers: createTestHeaders(currentSandboxId ?? ''), - body: JSON.stringify({ - env: { - SESSION_ID: 'test-session-streaming', - NODE_ENV: 'test' - } - }) - }); - - const sessionData = (await sessionResponse.json()) as SessionCreateResult; - const sessionId = sessionData.sessionId; - if (!sessionId) { - throw new Error('Session ID not returned from API'); + } catch (error) { + if (error instanceof Error && error.message !== 'Operation was aborted') { + throw error; } + } - // Stream a command in the session - const streamResponse = await fetch(`${workerUrl}/api/execStream`, { - method: 'POST', - headers: createTestHeaders(currentSandboxId, sessionId), - body: JSON.stringify({ - command: 'echo "Session: $SESSION_ID, Env: $NODE_ENV"' - }) - }); - - const events = await collectSSEEvents(streamResponse); - - // Verify output contains session environment variables - const stdoutEvents = events.filter((e) => e.type === 'stdout'); - const output = stdoutEvents.map((e) => e.data).join(''); - - expect(output).toContain('Session: test-session-streaming'); - expect(output).toContain('Env: test'); - - // Verify complete - const completeEvent = events.find((e) => e.type === 'complete'); - expect(completeEvent?.exitCode).toBe(0); - }, 90000); - - test('should handle 15+ second streaming command', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - console.log('[Test] Starting 15+ second streaming command...'); - - // Stream a command that runs for 15+ seconds with output every 2 seconds - const streamResponse = await fetch(`${workerUrl}/api/execStream`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: - 'bash -c \'for i in {1..8}; do echo "Tick $i at $(date +%s)"; sleep 2; done; echo "SUCCESS"\'' - }) - }); - - expect(streamResponse.status).toBe(200); - - const startTime = Date.now(); - const events = await collectSSEEvents(streamResponse, 50); - const duration = Date.now() - startTime; - - console.log(`[Test] Stream completed in ${duration}ms`); - - // Verify command ran for approximately 16 seconds (8 ticks * 2 seconds) - expect(duration).toBeGreaterThan(14000); // At least 14 seconds - expect(duration).toBeLessThan(40000); // But completed (not timed out) - - // Should have received all ticks - const stdoutEvents = events.filter((e) => e.type === 'stdout'); - const output = stdoutEvents.map((e) => e.data).join(''); - - for (let i = 1; i <= 8; i++) { - expect(output).toContain(`Tick ${i}`); - } - expect(output).toContain('SUCCESS'); - - // Most importantly: should complete with exit code 0 (not timeout) - const completeEvent = events.find((e) => e.type === 'complete'); - expect(completeEvent).toBeDefined(); - expect(completeEvent?.exitCode).toBe(0); - - console.log( - '[Test] ✅ Streaming command completed successfully after 16+ seconds!' - ); - }, 90000); - - test('should handle high-volume streaming over extended period', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - console.log('[Test] Starting high-volume streaming test...'); - - // Stream command that generates many lines over 10+ seconds - // Tests throttling: renewActivityTimeout shouldn't be called for every chunk - const streamResponse = await fetch(`${workerUrl}/api/execStream`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: - 'bash -c \'for i in {1..100}; do echo "Line $i: $(date +%s.%N)"; sleep 0.1; done\'' - }) - }); - - expect(streamResponse.status).toBe(200); - - const events = await collectSSEEvents(streamResponse, 150); - - // Should have many stdout events - const stdoutEvents = events.filter((e) => e.type === 'stdout'); - expect(stdoutEvents.length).toBeGreaterThanOrEqual(50); - - // Verify we got output from beginning and end - const output = stdoutEvents.map((e) => e.data).join(''); - expect(output).toContain('Line 1'); - expect(output).toContain('Line 100'); - - // Should complete successfully - const completeEvent = events.find((e) => e.type === 'complete'); - expect(completeEvent).toBeDefined(); - expect(completeEvent?.exitCode).toBe(0); - - console.log('[Test] ✅ High-volume streaming completed successfully'); - }, 90000); - - test('should handle streaming with intermittent output gaps', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - console.log('[Test] Starting intermittent output test...'); - - // Command with gaps between output bursts - // Tests that activity renewal works even when output is periodic - const streamResponse = await fetch(`${workerUrl}/api/execStream`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: - 'bash -c \'echo "Burst 1"; sleep 3; echo "Burst 2"; sleep 3; echo "Burst 3"; sleep 3; echo "Complete"\'' - }) - }); - - expect(streamResponse.status).toBe(200); - - const events = await collectSSEEvents(streamResponse, 30); - - const stdoutEvents = events.filter((e) => e.type === 'stdout'); - const output = stdoutEvents.map((e) => e.data).join(''); - - // All bursts should be received despite gaps - expect(output).toContain('Burst 1'); - expect(output).toContain('Burst 2'); - expect(output).toContain('Burst 3'); - expect(output).toContain('Complete'); - - // Should complete successfully - const completeEvent = events.find((e) => e.type === 'complete'); - expect(completeEvent).toBeDefined(); - expect(completeEvent?.exitCode).toBe(0); - - console.log('[Test] ✅ Intermittent output handled correctly'); - }, 90000); - - /** - * Test for streaming execution - * This validates that long-running commands work via streaming - */ - test('should handle very long-running commands (60+ seconds) via streaming', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Add keepAlive header to keep container alive during long execution - const keepAliveHeaders = { - ...headers, - 'X-Sandbox-KeepAlive': 'true' - }; - - console.log('[Test] Starting 60+ second command via streaming...'); - - // With streaming, it should complete successfully - const streamResponse = await fetch(`${workerUrl}/api/execStream`, { - method: 'POST', - headers: keepAliveHeaders, - body: JSON.stringify({ - // Command that runs for 60+ seconds with periodic output - command: - 'bash -c \'for i in {1..12}; do echo "Minute mark $i"; sleep 5; done; echo "COMPLETED"\'' - }) - }); - - expect(streamResponse.status).toBe(200); - - const startTime = Date.now(); - const events = await collectSSEEvents(streamResponse, 100); - const duration = Date.now() - startTime; - - console.log(`[Test] Very long stream completed in ${duration}ms`); + return events; + } - // Verify command ran for approximately 60 seconds (12 ticks * 5 seconds) - expect(duration).toBeGreaterThan(55000); // At least 55 seconds - expect(duration).toBeLessThan(75000); // But not timed out (under 75s) + test('should handle command failures with non-zero exit code', async () => { + const streamResponse = await fetch(`${workerUrl}/api/execStream`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'false' // Always fails with exit code 1 + }) + }); - // Should have received all minute marks - const stdoutEvents = events.filter((e) => e.type === 'stdout'); - const output = stdoutEvents.map((e) => e.data).join(''); + const events = await collectSSEEvents(streamResponse); - for (let i = 1; i <= 12; i++) { - expect(output).toContain(`Minute mark ${i}`); - } - expect(output).toContain('COMPLETED'); + const completeEvent = events.find((e) => e.type === 'complete'); + expect(completeEvent).toBeDefined(); + expect(completeEvent?.exitCode).not.toBe(0); + }, 90000); - // Most importantly: should complete with exit code 0 (not timeout/disconnect) - const completeEvent = events.find((e) => e.type === 'complete'); - expect(completeEvent).toBeDefined(); - expect(completeEvent?.exitCode).toBe(0); + test('should handle nonexistent commands with proper exit code', async () => { + const streamResponse = await fetch(`${workerUrl}/api/execStream`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: 'nonexistentcommand123' + }) + }); - console.log('[Test] ✅ Very long-running command completed!'); - }, 90000); + expect(streamResponse.status).toBe(200); + + const events = await collectSSEEvents(streamResponse); + + const completeEvent = events.find((e) => e.type === 'complete'); + expect(completeEvent).toBeDefined(); + expect(completeEvent?.exitCode).toBe(127); // Command not found + + const stderrEvents = events.filter((e) => e.type === 'stderr'); + expect(stderrEvents.length).toBeGreaterThan(0); + const stderrData = stderrEvents.map((e) => e.data).join(''); + expect(stderrData.toLowerCase()).toMatch(/command not found|not found/); + }, 90000); + + test('should handle streaming with multiple output chunks over time', async () => { + // Tests that streaming correctly delivers output over ~2 seconds + const streamResponse = await fetch(`${workerUrl}/api/execStream`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: + 'bash -c \'for i in 1 2 3; do echo "Chunk $i"; sleep 0.5; done; echo "DONE"\'' + }) + }); - test('should handle command that sleeps for extended period', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); + expect(streamResponse.status).toBe(200); - // Add keepAlive header to keep container alive during long sleep - const keepAliveHeaders = { - ...headers, - 'X-Sandbox-KeepAlive': 'true' - }; + const startTime = Date.now(); + const events = await collectSSEEvents(streamResponse, 20); + const duration = Date.now() - startTime; - console.log('[Test] Testing sleep 45 && echo "done" pattern...'); + // Should take ~1.5s (3 × 0.5s sleeps) + expect(duration).toBeGreaterThan(1000); + expect(duration).toBeLessThan(10000); - // This is the exact pattern that was failing before - const streamResponse = await fetch(`${workerUrl}/api/execStream`, { - method: 'POST', - headers: keepAliveHeaders, - body: JSON.stringify({ - command: 'sleep 45 && echo "done"' - }) - }); + const stdoutEvents = events.filter((e) => e.type === 'stdout'); + const output = stdoutEvents.map((e) => e.data).join(''); - expect(streamResponse.status).toBe(200); + expect(output).toContain('Chunk 1'); + expect(output).toContain('Chunk 2'); + expect(output).toContain('Chunk 3'); + expect(output).toContain('DONE'); - const startTime = Date.now(); - const events = await collectSSEEvents(streamResponse, 20); - const duration = Date.now() - startTime; + const completeEvent = events.find((e) => e.type === 'complete'); + expect(completeEvent).toBeDefined(); + expect(completeEvent?.exitCode).toBe(0); + }, 15000); - console.log(`[Test] Sleep command completed in ${duration}ms`); + test('should stream file contents', async () => { + // Create a test file first + const testPath = `/workspace/stream-test-${Date.now()}.txt`; + const testContent = + 'Line 1\nLine 2\nLine 3\nThis is streaming file content.'; - // Should have taken at least 45 seconds - expect(duration).toBeGreaterThan(44000); + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ path: testPath, content: testContent }) + }); - // Should have the output - const stdoutEvents = events.filter((e) => e.type === 'stdout'); - const output = stdoutEvents.map((e) => e.data).join(''); - expect(output).toContain('done'); + // Stream the file back + const streamResponse = await fetch(`${workerUrl}/api/read/stream`, { + method: 'POST', + headers, + body: JSON.stringify({ path: testPath }) + }); - // Should complete successfully - const completeEvent = events.find((e) => e.type === 'complete'); - expect(completeEvent).toBeDefined(); - expect(completeEvent?.exitCode).toBe(0); + expect(streamResponse.status).toBe(200); + expect(streamResponse.headers.get('Content-Type')).toBe( + 'text/event-stream' + ); + + // Collect streamed content + const reader = streamResponse.body?.getReader(); + expect(reader).toBeDefined(); + + const decoder = new TextDecoder(); + let rawContent = ''; + while (true) { + const { done, value } = await reader!.read(); + if (done) break; + rawContent += decoder.decode(value, { stream: true }); + } - console.log('[Test] ✅ Long sleep command completed without disconnect!'); - }, 90000); - }); + // Parse SSE JSON events + const lines = rawContent.split('\n').filter((l) => l.startsWith('data: ')); + const events = lines.map((l) => JSON.parse(l.slice(6))); + + // Should have metadata, chunk(s), and complete events + const metadata = events.find((e) => e.type === 'metadata'); + const chunk = events.find((e) => e.type === 'chunk'); + const complete = events.find((e) => e.type === 'complete'); + + expect(metadata).toBeDefined(); + expect(metadata.mimeType).toBe('text/plain'); + expect(chunk).toBeDefined(); + expect(chunk.data).toBe(testContent); + expect(complete).toBeDefined(); + expect(complete.bytesRead).toBe(testContent.length); + + // Cleanup + await fetch(`${workerUrl}/api/file/delete`, { + method: 'POST', + headers, + body: JSON.stringify({ path: testPath }) + }); + }, 30000); }); diff --git a/tests/e2e/test-worker/Dockerfile b/tests/e2e/test-worker/Dockerfile index 81bd3a73..d0d0db58 100644 --- a/tests/e2e/test-worker/Dockerfile +++ b/tests/e2e/test-worker/Dockerfile @@ -3,4 +3,10 @@ FROM docker.io/cloudflare/sandbox-test:0.6.2 # Expose ports used for testing -EXPOSE 8080 +# 8080: general testing +# 9090: process-readiness-workflow.test.ts (waitForPort test) +# 9091: process-readiness-workflow.test.ts (chained waitForLog/Port test) +# 9092: process-readiness-workflow.test.ts (port exposure test) +# 9998: reserved for process-lifecycle-workflow.test.ts +# 9999: reserved for websocket-workflow.test.ts +EXPOSE 8080 9090 9091 9092 9998 9999 diff --git a/tests/e2e/test-worker/Dockerfile.python b/tests/e2e/test-worker/Dockerfile.python index 1497af73..49554ebc 100644 --- a/tests/e2e/test-worker/Dockerfile.python +++ b/tests/e2e/test-worker/Dockerfile.python @@ -3,4 +3,10 @@ FROM docker.io/cloudflare/sandbox-test:0.6.2-python # Expose ports used for testing -EXPOSE 8080 +# 8080: general testing +# 9090: process-readiness-workflow.test.ts (waitForPort test) +# 9091: process-readiness-workflow.test.ts (chained waitForLog/Port test) +# 9092: process-readiness-workflow.test.ts (port exposure test) +# 9998: reserved for process-lifecycle-workflow.test.ts +# 9999: reserved for websocket-workflow.test.ts +EXPOSE 8080 9090 9091 9092 9998 9999 diff --git a/tests/e2e/websocket-connect.test.ts b/tests/e2e/websocket-connect.test.ts index 7d3df877..ff523148 100644 --- a/tests/e2e/websocket-connect.test.ts +++ b/tests/e2e/websocket-connect.test.ts @@ -1,158 +1,89 @@ -import { describe, test, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { describe, test, expect, beforeAll } from 'vitest'; import WebSocket from 'ws'; -import { getTestWorkerUrl, WranglerDevRunner } from './helpers/wrangler-runner'; import { - createSandboxId, - createTestHeaders, - cleanupSandbox -} from './helpers/test-fixtures'; + getSharedSandbox, + createUniqueSession +} from './helpers/global-sandbox'; /** - * WebSocket wsConnect() Integration Tests + * WebSocket Connection Tests * - * Tests the wsConnect() method for routing WebSocket requests to container services. - * Focuses on transport-level functionality, not application-level server implementations. + * Tests WebSocket routing via wsConnect(). Uses SHARED sandbox. */ describe('WebSocket Connections', () => { - let runner: WranglerDevRunner | null = null; let workerUrl: string; - let currentSandboxId: string | null = null; + let sandboxId: string; beforeAll(async () => { - const result = await getTestWorkerUrl(); - workerUrl = result.url; - runner = result.runner; - }); + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + sandboxId = sandbox.sandboxId; - afterEach(async () => { - if (currentSandboxId) { - await cleanupSandbox(workerUrl, currentSandboxId); - currentSandboxId = null; - } - }); - - afterAll(async () => { - if (runner) { - await runner.stop(); - } - }); - - test('should establish WebSocket connection to container service', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Start a simple echo server in the container + // Initialize sandbox (container echo server is built-in) await fetch(`${workerUrl}/api/init`, { method: 'POST', - headers + headers: { 'X-Sandbox-Id': sandboxId } }); + }, 120000); - // Wait for server to be ready (generous timeout for first startup) - await new Promise((resolve) => setTimeout(resolve, 2000)); - - // Connect via WebSocket using wsConnect() routing + test('should establish WebSocket connection and echo messages', async () => { const wsUrl = workerUrl.replace(/^http/, 'ws') + '/ws/echo'; const ws = new WebSocket(wsUrl, { - headers: { - 'X-Sandbox-Id': currentSandboxId - } + headers: { 'X-Sandbox-Id': sandboxId } }); // Wait for connection await new Promise((resolve, reject) => { ws.on('open', () => resolve()); - ws.on('error', (error) => reject(error)); + ws.on('error', reject); setTimeout(() => reject(new Error('Connection timeout')), 10000); }); - // Send message and verify echo back + // Send and receive const testMessage = 'Hello WebSocket'; const messagePromise = new Promise((resolve, reject) => { ws.on('message', (data) => resolve(data.toString())); setTimeout(() => reject(new Error('Echo timeout')), 5000); }); - ws.send(testMessage); - const echoedMessage = await messagePromise; - expect(echoedMessage).toBe(testMessage); - - // Clean close + expect(await messagePromise).toBe(testMessage); ws.close(); - await new Promise((resolve) => { - ws.on('close', () => resolve()); - setTimeout(() => resolve(), 1000); - }); - }, 30000); + }, 20000); test('should handle multiple concurrent connections', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Initialize echo server - await fetch(`${workerUrl}/api/init`, { - method: 'POST', - headers - }); - - // Wait for server to be ready - await new Promise((resolve) => setTimeout(resolve, 2000)); - - // Open 3 concurrent connections to echo server const wsUrl = workerUrl.replace(/^http/, 'ws') + '/ws/echo'; - const ws1 = new WebSocket(wsUrl, { - headers: { 'X-Sandbox-Id': currentSandboxId } - }); - const ws2 = new WebSocket(wsUrl, { - headers: { 'X-Sandbox-Id': currentSandboxId } - }); - const ws3 = new WebSocket(wsUrl, { - headers: { 'X-Sandbox-Id': currentSandboxId } - }); - - // Wait for all connections to open - await Promise.all([ - new Promise((resolve, reject) => { - ws1.on('open', () => resolve()); - ws1.on('error', reject); - setTimeout(() => reject(new Error('WS1 timeout')), 10000); - }), - new Promise((resolve, reject) => { - ws2.on('open', () => resolve()); - ws2.on('error', reject); - setTimeout(() => reject(new Error('WS2 timeout')), 10000); - }), - new Promise((resolve, reject) => { - ws3.on('open', () => resolve()); - ws3.on('error', reject); - setTimeout(() => reject(new Error('WS3 timeout')), 10000); - }) - ]); - - // Send different messages on each connection simultaneously - const results = await Promise.all([ - new Promise((resolve) => { - ws1.on('message', (data) => resolve(data.toString())); - ws1.send('Message 1'); - }), - new Promise((resolve) => { - ws2.on('message', (data) => resolve(data.toString())); - ws2.send('Message 2'); - }), - new Promise((resolve) => { - ws3.on('message', (data) => resolve(data.toString())); - ws3.send('Message 3'); - }) - ]); - - // Verify each connection received its own message (no interference) - expect(results[0]).toBe('Message 1'); - expect(results[1]).toBe('Message 2'); - expect(results[2]).toBe('Message 3'); - // Close all connections - ws1.close(); - ws2.close(); - ws3.close(); - }, 30000); + // Open 3 connections + const connections = [1, 2, 3].map( + () => new WebSocket(wsUrl, { headers: { 'X-Sandbox-Id': sandboxId } }) + ); + + // Wait for all to open + await Promise.all( + connections.map( + (ws) => + new Promise((resolve, reject) => { + ws.on('open', () => resolve()); + ws.on('error', reject); + setTimeout(() => reject(new Error('Timeout')), 10000); + }) + ) + ); + + // Send and receive on each + const results = await Promise.all( + connections.map( + (ws, i) => + new Promise((resolve) => { + ws.on('message', (data) => resolve(data.toString())); + ws.send(`Message ${i + 1}`); + }) + ) + ); + + expect(results).toEqual(['Message 1', 'Message 2', 'Message 3']); + + connections.forEach((ws) => ws.close()); + }, 20000); }); diff --git a/tests/e2e/websocket-workflow.test.ts b/tests/e2e/websocket-workflow.test.ts index 811c19ec..3a313c37 100644 --- a/tests/e2e/websocket-workflow.test.ts +++ b/tests/e2e/websocket-workflow.test.ts @@ -1,177 +1,109 @@ -import { - describe, - test, - expect, - beforeAll, - afterAll, - afterEach, - vi -} from 'vitest'; +import { describe, test, expect, beforeAll } from 'vitest'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import WebSocket from 'ws'; -import { getTestWorkerUrl, WranglerDevRunner } from './helpers/wrangler-runner'; -import { - createSandboxId, - createTestHeaders, - cleanupSandbox -} from './helpers/test-fixtures'; +import { getSharedSandbox } from './helpers/global-sandbox'; import type { Process, PortExposeResult } from '@repo/shared'; -// Port exposure tests require custom domain with wildcard DNS routing -// Skip these tests when running against workers.dev deployment (no wildcard support) -const skipWebSocketTests = +// Dedicated port for websocket tests - not used by any other test file +const skipPortExposureTests = process.env.TEST_WORKER_URL?.endsWith('.workers.dev') ?? false; +const WEBSOCKET_TEST_PORT = 9999; /** - * WebSocket Workflow Integration Tests + * WebSocket Port Exposure Tests * - * Tests WebSocket support for exposed sandbox ports. - * - * SCOPE: Phase 1 - WebSocket Routing Validation - * This test validates that WebSocket upgrade requests are correctly routed - * through the sandbox infrastructure (Worker → proxyToSandbox → Sandbox.fetch → Container). - * - * NOT TESTED HERE: - * - Long-running connection timeout management (Phase 2) - * - Keep-alive strategies (Phase 2) - * - Error recovery and reconnection (Phase 3) - * - Concurrent connections or load testing (Phase 4) - * - * KNOWN LIMITATION: - * Current runtime has 30-second CPU timeout without keep-alive mechanism. - * This test keeps connections brief (< 5s) to validate routing correctness only. - * Timeout management will be addressed in Phase 2. + * Tests WebSocket via exposed ports. Uses SHARED sandbox with unique session. */ -describe('WebSocket Workflow', () => { - describe.skipIf(skipWebSocketTests)('local', () => { - let runner: WranglerDevRunner | null = null; +describe('WebSocket Port Exposure', () => { + describe('local', () => { let workerUrl: string; - let currentSandboxId: string | null = null; + let headers: Record; + let sandboxId: string; beforeAll(async () => { - const result = await getTestWorkerUrl(); - workerUrl = result.url; - runner = result.runner; - }); - - afterEach(async () => { - // Cleanup sandbox container after each test - if (currentSandboxId) { - await cleanupSandbox(workerUrl, currentSandboxId); - currentSandboxId = null; - } - }); - - afterAll(async () => { - if (runner) { - await runner.stop(); - } - }); - - test('should connect to WebSocket server via exposed port and echo messages', async () => { - currentSandboxId = createSandboxId(); - const headers = createTestHeaders(currentSandboxId); - - // Read the WebSocket echo server fixture - const serverCode = readFileSync( - join(__dirname, 'fixtures', 'websocket-echo-server.ts'), - 'utf-8' - ); - - // Step 1: Write the WebSocket echo server to the container - await fetch(`${workerUrl}/api/file/write`, { - method: 'POST', - headers, - body: JSON.stringify({ - path: '/workspace/ws-server.ts', - content: serverCode - }) - }); - - // Step 2: Start the WebSocket server as a background process - const port = 8080; - const startResponse = await fetch(`${workerUrl}/api/process/start`, { - method: 'POST', - headers, - body: JSON.stringify({ - command: `bun run /workspace/ws-server.ts ${port}` - }) - }); - - expect(startResponse.status).toBe(200); - const processData = (await startResponse.json()) as Process; - const processId = processData.id; - expect(processData.id).toBeTruthy(); - - // Wait for server to be ready (generous timeout for first startup) - await new Promise((resolve) => setTimeout(resolve, 2000)); - - // Step 3: Expose the port to get preview URL - const exposeResponse = await fetch(`${workerUrl}/api/port/expose`, { - method: 'POST', - headers, - body: JSON.stringify({ - port, - name: 'websocket-test' - }) - }); - - expect(exposeResponse.status).toBe(200); - const exposeData = (await exposeResponse.json()) as PortExposeResult; - expect(exposeData.url).toBeTruthy(); - console.log('[DEBUG] Preview URL:', exposeData.url); - - // Step 4: Connect to WebSocket via preview URL - // Convert http:// to ws:// for WebSocket protocol - const wsUrl = exposeData.url.replace(/^http/, 'ws'); - - const ws = new WebSocket(wsUrl); - - // Wait for connection to open - await new Promise((resolve, reject) => { - ws.on('open', () => resolve()); - ws.on('error', (error) => reject(error)); - setTimeout( - () => reject(new Error('WebSocket connection timeout')), - 10000 + const sandbox = await getSharedSandbox(); + workerUrl = sandbox.workerUrl; + sandboxId = sandbox.sandboxId; + // Port exposure requires sandbox headers, not session headers + headers = { + 'X-Sandbox-Id': sandboxId, + 'Content-Type': 'application/json' + }; + }, 120000); + + test.skipIf(skipPortExposureTests)( + 'should connect to WebSocket server via exposed port', + async () => { + // Write the echo server + const serverCode = readFileSync( + join(__dirname, 'fixtures', 'websocket-echo-server.ts'), + 'utf-8' ); - }); - - console.log('[DEBUG] WebSocket connected'); + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/ws-server.ts', + content: serverCode + }) + }); - // Step 5: Send a message and verify echo - const testMessage = 'Hello WebSocket!'; - const messagePromise = new Promise((resolve, reject) => { - ws.on('message', (data) => { - resolve(data.toString()); + // Start server on dedicated port + const port = WEBSOCKET_TEST_PORT; + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ + command: `bun run /workspace/ws-server.ts ${port}` + }) }); - setTimeout(() => reject(new Error('Echo timeout')), 5000); - }); + expect(startResponse.status).toBe(200); + const processData = (await startResponse.json()) as Process; - ws.send(testMessage); - const echoedMessage = await messagePromise; + // Wait for startup + await new Promise((r) => setTimeout(r, 1000)); - expect(echoedMessage).toBe(testMessage); - console.log('[DEBUG] Message echoed successfully:', echoedMessage); + // Expose port + const exposeResponse = await fetch(`${workerUrl}/api/port/expose`, { + method: 'POST', + headers, + body: JSON.stringify({ port, name: 'ws-test' }) + }); + expect(exposeResponse.status).toBe(200); + const exposeData = (await exposeResponse.json()) as PortExposeResult; - // Step 6: Close WebSocket connection gracefully - ws.close(); - await new Promise((resolve) => { - ws.on('close', () => resolve()); - setTimeout(() => resolve(), 1000); // Fallback timeout - }); + // Connect WebSocket + const wsUrl = exposeData.url.replace(/^http/, 'ws'); + const ws = new WebSocket(wsUrl); - // Step 7: Cleanup - kill process and unexpose port - await fetch(`${workerUrl}/api/process/${processId}`, { - method: 'DELETE', - headers - }); + await new Promise((resolve, reject) => { + ws.on('open', () => resolve()); + ws.on('error', reject); + setTimeout(() => reject(new Error('Timeout')), 10000); + }); - await fetch(`${workerUrl}/api/exposed-ports/${port}`, { - method: 'DELETE', - headers - }); - }, 90000); + // Echo test + const testMessage = 'WebSocket via exposed port!'; + const messagePromise = new Promise((resolve, reject) => { + ws.on('message', (data) => resolve(data.toString())); + setTimeout(() => reject(new Error('Echo timeout')), 5000); + }); + ws.send(testMessage); + expect(await messagePromise).toBe(testMessage); + + // Cleanup + ws.close(); + await fetch(`${workerUrl}/api/process/${processData.id}`, { + method: 'DELETE', + headers + }); + await fetch(`${workerUrl}/api/exposed-ports/${port}`, { + method: 'DELETE', + headers + }); + }, + 30000 + ); }); }); diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 82867bdb..a7e47a76 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -1,23 +1,13 @@ import { config } from 'dotenv'; import { defineConfig } from 'vitest/config'; -// Load environment variables from .env file config(); /** - * E2E test configuration - * - * These tests run against deployed Cloudflare Workers (in CI) or local wrangler dev. - * They validate true end-to-end behavior with real Durable Objects and containers. - * - * Run with: npm run test:e2e - * - * Architecture: - * - CI: Uses TEST_WORKER_URL pointing to deployed worker - * - Local: Each test file spawns its own wrangler dev instance - * - Tests run SEQUENTIALLY to avoid container provisioning resource contention + * E2E tests using shared sandbox - runs in parallel + * Tests use unique sessions for isolation within one container. + * Bucket-mounting tests self-skip locally (require FUSE/CI). */ - export default defineConfig({ test: { name: 'e2e', @@ -25,19 +15,23 @@ export default defineConfig({ environment: 'node', include: ['tests/e2e/**/*.test.ts'], - // Longer timeouts for E2E tests (wrangler startup, container operations) - testTimeout: 120000, // 2 minutes per test - hookTimeout: 60000, // 1 minute for beforeAll/afterAll - teardownTimeout: 30000, // 30s for cleanup + testTimeout: 120000, + hookTimeout: 60000, + teardownTimeout: 30000, + + // Global setup creates sandbox BEFORE threads spawn, passes info through a tmp file + globalSetup: ['tests/e2e/global-setup.ts'], - // Run tests sequentially to avoid infrastructure resource contention - // Parallel execution causes container provisioning issues on both local and CI - pool: 'forks', + // Threads run in parallel - they all use the same sandbox through a tmp file + pool: 'threads', poolOptions: { - forks: { - singleFork: true // Force sequential execution + threads: { + singleThread: false, + isolate: false } }, - fileParallelism: false // No parallel file execution + fileParallelism: true, + + retry: 1 } });