From 0fbf8305e463ecf69c54203aa263f65fd6f24b32 Mon Sep 17 00:00:00 2001 From: katereznykova Date: Tue, 25 Nov 2025 19:49:47 +0000 Subject: [PATCH 1/6] fix JS and TS executors to await Promise in context execution --- .../src/runtime/executors/javascript/node_executor.ts | 9 +++++++++ .../src/runtime/executors/typescript/ts_executor.ts | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/sandbox-container/src/runtime/executors/javascript/node_executor.ts b/packages/sandbox-container/src/runtime/executors/javascript/node_executor.ts index d64a5c06..f78bc14b 100644 --- a/packages/sandbox-container/src/runtime/executors/javascript/node_executor.ts +++ b/packages/sandbox-container/src/runtime/executors/javascript/node_executor.ts @@ -84,6 +84,15 @@ rl.on('line', async (line: string) => { } result = vm.runInContext(code, context, options); + + // If result is a Promise (thenable), await it + if ( + result && + typeof result === 'object' && + typeof (result as any).then === 'function' + ) { + result = await (result as Promise); + } } catch (error: unknown) { const err = error as Error; stderr += err.stack || err.toString(); diff --git a/packages/sandbox-container/src/runtime/executors/typescript/ts_executor.ts b/packages/sandbox-container/src/runtime/executors/typescript/ts_executor.ts index 2453f4be..a2e2336c 100644 --- a/packages/sandbox-container/src/runtime/executors/typescript/ts_executor.ts +++ b/packages/sandbox-container/src/runtime/executors/typescript/ts_executor.ts @@ -95,6 +95,15 @@ rl.on('line', async (line: string) => { } result = vm.runInContext(jsCode, context, options); + + // If result is a Promise (thenable), await it + if ( + result && + typeof result === 'object' && + typeof (result as any).then === 'function' + ) { + result = await (result as Promise); + } } catch (error: unknown) { const err = error as Error; if (err.message?.includes('Transform failed')) { From 0fd5dc10243152b43438b2e6dfa1f6912c3ce1b9 Mon Sep 17 00:00:00 2001 From: katereznykova Date: Tue, 25 Nov 2025 19:52:06 +0000 Subject: [PATCH 2/6] adding tests to verify promise resolution --- tests/e2e/code-interpreter-workflow.test.ts | 184 ++++++++++++++++++++ 1 file changed, 184 insertions(+) diff --git a/tests/e2e/code-interpreter-workflow.test.ts b/tests/e2e/code-interpreter-workflow.test.ts index efdf3851..268cfeb0 100644 --- a/tests/e2e/code-interpreter-workflow.test.ts +++ b/tests/e2e/code-interpreter-workflow.test.ts @@ -367,6 +367,190 @@ describe('Code Interpreter Workflow (E2E)', () => { ); }, 120000); + // ============================================================================ + // Async/Await Promise Handling (Issue #206) + // ============================================================================ + + test('should resolve async IIFE and return the value', 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 async IIFE that returns a value + const execResponse = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: '(async () => { return 42; })()', + 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.length).toBeGreaterThan(0); + // The result should be 42, not an empty object {} + const resultData = execution.results[0]; + expect(resultData.json).toBe(42); + }, 120000); + + test('should resolve Promise.resolve() and return the value', 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 Promise.resolve + const execResponse = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: 'Promise.resolve({ status: "success", value: 123 })', + 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.length).toBeGreaterThan(0); + // The result should be the resolved object, not {} + const resultData = execution.results[0]; + expect(resultData.json).toEqual({ status: 'success', value: 123 }); + }, 120000); + + test('should handle nested async operations', 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 nested async code + const execResponse = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: ` +(async () => { + const a = await Promise.resolve(10); + const b = await Promise.resolve(20); + return 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.length).toBeGreaterThan(0); + // The result should be 30 + const resultData = execution.results[0]; + expect(resultData.json).toBe(30); + }, 120000); + + test('should still handle synchronous code correctly', 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 synchronous code + const execResponse = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: '({ sum: 1 + 2 + 3, product: 2 * 3 * 4 })', + 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.length).toBeGreaterThan(0); + const resultData = execution.results[0]; + expect(resultData.json).toEqual({ sum: 6, product: 24 }); + }, 120000); + + test('should resolve TypeScript async code', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Create TypeScript context + const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { + method: 'POST', + headers, + body: JSON.stringify({ language: 'typescript' }) + }); + + const context = await ctxResponse.json(); + + // Execute TypeScript async code + const execResponse = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: ` +(async (): Promise => { + const value: number = await Promise.resolve(100); + return value * 2; +})() +`.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.length).toBeGreaterThan(0); + // The result should be 200 + const resultData = execution.results[0]; + expect(resultData.json).toBe(200); + }, 120000); + // ============================================================================ // Streaming Execution // ============================================================================ From 43d9b86fe1dca787fb9eaf572dfb58be6e547313 Mon Sep 17 00:00:00 2001 From: katereznykova Date: Tue, 25 Nov 2025 19:59:30 +0000 Subject: [PATCH 3/6] fix small nits --- .../executors/javascript/node_executor.ts | 24 +++- .../executors/typescript/ts_executor.ts | 24 +++- tests/e2e/code-interpreter-workflow.test.ts | 119 ++++++++++++++++++ 3 files changed, 155 insertions(+), 12 deletions(-) diff --git a/packages/sandbox-container/src/runtime/executors/javascript/node_executor.ts b/packages/sandbox-container/src/runtime/executors/javascript/node_executor.ts index f78bc14b..25f91ff9 100644 --- a/packages/sandbox-container/src/runtime/executors/javascript/node_executor.ts +++ b/packages/sandbox-container/src/runtime/executors/javascript/node_executor.ts @@ -8,6 +8,22 @@ import * as util from 'node:util'; import * as vm from 'node:vm'; import type { RichOutput } from '../../process-pool'; +interface Thenable { + then: ( + onfulfilled?: (value: T) => unknown, + onrejected?: (reason: unknown) => unknown + ) => unknown; +} + +function isThenable(value: unknown): value is Thenable { + return ( + value !== null && + typeof value === 'object' && + 'then' in value && + typeof (value as Thenable).then === 'function' + ); +} + // Create CommonJS-like globals for the sandbox const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -86,12 +102,8 @@ rl.on('line', async (line: string) => { result = vm.runInContext(code, context, options); // If result is a Promise (thenable), await it - if ( - result && - typeof result === 'object' && - typeof (result as any).then === 'function' - ) { - result = await (result as Promise); + if (isThenable(result)) { + result = await result; } } catch (error: unknown) { const err = error as Error; diff --git a/packages/sandbox-container/src/runtime/executors/typescript/ts_executor.ts b/packages/sandbox-container/src/runtime/executors/typescript/ts_executor.ts index a2e2336c..b5de9852 100644 --- a/packages/sandbox-container/src/runtime/executors/typescript/ts_executor.ts +++ b/packages/sandbox-container/src/runtime/executors/typescript/ts_executor.ts @@ -9,6 +9,22 @@ import * as vm from 'node:vm'; import { transformSync } from 'esbuild'; import type { RichOutput } from '../../process-pool'; +interface Thenable { + then: ( + onfulfilled?: (value: T) => unknown, + onrejected?: (reason: unknown) => unknown + ) => unknown; +} + +function isThenable(value: unknown): value is Thenable { + return ( + value !== null && + typeof value === 'object' && + 'then' in value && + typeof (value as Thenable).then === 'function' + ); +} + // Create CommonJS-like globals for the sandbox const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -97,12 +113,8 @@ rl.on('line', async (line: string) => { result = vm.runInContext(jsCode, context, options); // If result is a Promise (thenable), await it - if ( - result && - typeof result === 'object' && - typeof (result as any).then === 'function' - ) { - result = await (result as Promise); + if (isThenable(result)) { + result = await result; } } catch (error: unknown) { const err = error as Error; diff --git a/tests/e2e/code-interpreter-workflow.test.ts b/tests/e2e/code-interpreter-workflow.test.ts index 268cfeb0..02f739df 100644 --- a/tests/e2e/code-interpreter-workflow.test.ts +++ b/tests/e2e/code-interpreter-workflow.test.ts @@ -551,6 +551,125 @@ describe('Code Interpreter Workflow (E2E)', () => { expect(resultData.json).toBe(200); }, 120000); + test('should handle Promise.reject() and report the error', 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 Promise.reject + const execResponse = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: 'Promise.reject(new Error("Intentional rejection"))', + options: { context } + }) + }); + + expect(execResponse.status).toBe(200); + const execution = (await execResponse.json()) as ExecutionResult; + + // Should have an error from the rejected Promise + expect(execution.error).toBeDefined(); + if (!execution.error) throw new Error('Expected error to be defined'); + + expect( + execution.error.message || + execution.error.name || + execution.logs.stderr.join('') + ).toMatch(/Intentional rejection|Error/i); + }, 120000); + + test('should handle async function that throws an error', 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 async function that throws + const execResponse = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: ` +(async () => { + throw new Error("Async error thrown"); +})() +`.trim(), + options: { context } + }) + }); + + expect(execResponse.status).toBe(200); + const execution = (await execResponse.json()) as ExecutionResult; + + // Should have an error from the thrown exception + expect(execution.error).toBeDefined(); + if (!execution.error) throw new Error('Expected error to be defined'); + + expect( + execution.error.message || + execution.error.name || + execution.logs.stderr.join('') + ).toMatch(/Async error thrown|Error/i); + }, 120000); + + test('should handle TypeScript async function that throws', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Create TypeScript context + const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { + method: 'POST', + headers, + body: JSON.stringify({ language: 'typescript' }) + }); + + const context = await ctxResponse.json(); + + // Execute TypeScript async function that throws + const execResponse = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: ` +(async (): Promise => { + throw new TypeError("TypeScript async error"); +})() +`.trim(), + options: { context } + }) + }); + + expect(execResponse.status).toBe(200); + const execution = (await execResponse.json()) as ExecutionResult; + + // Should have an error from the thrown exception + expect(execution.error).toBeDefined(); + if (!execution.error) throw new Error('Expected error to be defined'); + + expect( + execution.error.message || + execution.error.name || + execution.logs.stderr.join('') + ).toMatch(/TypeScript async error|TypeError|Error/i); + }, 120000); + // ============================================================================ // Streaming Execution // ============================================================================ From e1c3e8631a0b5d4fee5fb6451c0c8b80beb3d39f Mon Sep 17 00:00:00 2001 From: whoiskatrin Date: Tue, 25 Nov 2025 20:09:43 +0000 Subject: [PATCH 4/6] Update executor to await Promises in context execution Fixes the JavaScript and TypeScript executors to properly await Promises during context execution. --- .changeset/heavy-bananas-dream.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/heavy-bananas-dream.md diff --git a/.changeset/heavy-bananas-dream.md b/.changeset/heavy-bananas-dream.md new file mode 100644 index 00000000..d4957434 --- /dev/null +++ b/.changeset/heavy-bananas-dream.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/sandbox": patch +--- + +fix JS and TS executors to await Promise in context execution From 2b7481abf24f75423d2223951ba1c1f75e3f613c Mon Sep 17 00:00:00 2001 From: katereznykova Date: Tue, 25 Nov 2025 20:32:49 +0000 Subject: [PATCH 5/6] update tests --- tests/e2e/code-interpreter-workflow.test.ts | 26 ++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/e2e/code-interpreter-workflow.test.ts b/tests/e2e/code-interpreter-workflow.test.ts index 02f739df..a5c8addb 100644 --- a/tests/e2e/code-interpreter-workflow.test.ts +++ b/tests/e2e/code-interpreter-workflow.test.ts @@ -384,12 +384,12 @@ describe('Code Interpreter Workflow (E2E)', () => { const context = await ctxResponse.json(); - // Execute async IIFE that returns a value + // Execute async IIFE that returns a value (wrapped in object for json output) const execResponse = await fetch(`${workerUrl}/api/code/execute`, { method: 'POST', headers, body: JSON.stringify({ - code: '(async () => { return 42; })()', + code: '(async () => { return { value: 42 }; })()', options: { context } }) }); @@ -400,9 +400,9 @@ describe('Code Interpreter Workflow (E2E)', () => { expect(execution.error).toBeUndefined(); expect(execution.results).toBeDefined(); expect(execution.results.length).toBeGreaterThan(0); - // The result should be 42, not an empty object {} + // The result should be { value: 42 }, not an empty object {} const resultData = execution.results[0]; - expect(resultData.json).toBe(42); + expect(resultData.json).toEqual({ value: 42 }); }, 120000); test('should resolve Promise.resolve() and return the value', async () => { @@ -452,7 +452,7 @@ describe('Code Interpreter Workflow (E2E)', () => { const context = await ctxResponse.json(); - // Execute nested async code + // Execute nested async code (wrapped in object for json output) const execResponse = await fetch(`${workerUrl}/api/code/execute`, { method: 'POST', headers, @@ -461,7 +461,7 @@ describe('Code Interpreter Workflow (E2E)', () => { (async () => { const a = await Promise.resolve(10); const b = await Promise.resolve(20); - return a + b; + return { sum: a + b }; })() `.trim(), options: { context } @@ -474,9 +474,9 @@ describe('Code Interpreter Workflow (E2E)', () => { expect(execution.error).toBeUndefined(); expect(execution.results).toBeDefined(); expect(execution.results.length).toBeGreaterThan(0); - // The result should be 30 + // The result should be { sum: 30 } const resultData = execution.results[0]; - expect(resultData.json).toBe(30); + expect(resultData.json).toEqual({ sum: 30 }); }, 120000); test('should still handle synchronous code correctly', async () => { @@ -525,15 +525,15 @@ describe('Code Interpreter Workflow (E2E)', () => { const context = await ctxResponse.json(); - // Execute TypeScript async code + // Execute TypeScript async code (wrapped in object for json output) const execResponse = await fetch(`${workerUrl}/api/code/execute`, { method: 'POST', headers, body: JSON.stringify({ code: ` -(async (): Promise => { +(async (): Promise<{ result: number }> => { const value: number = await Promise.resolve(100); - return value * 2; + return { result: value * 2 }; })() `.trim(), options: { context } @@ -546,9 +546,9 @@ describe('Code Interpreter Workflow (E2E)', () => { expect(execution.error).toBeUndefined(); expect(execution.results).toBeDefined(); expect(execution.results.length).toBeGreaterThan(0); - // The result should be 200 + // The result should be { result: 200 } const resultData = execution.results[0]; - expect(resultData.json).toBe(200); + expect(resultData.json).toEqual({ result: 200 }); }, 120000); test('should handle Promise.reject() and report the error', async () => { From e4aa98a301bfa731d9301528c5abc3421f7074d9 Mon Sep 17 00:00:00 2001 From: katereznykova Date: Fri, 28 Nov 2025 10:53:38 +0000 Subject: [PATCH 6/6] top level await --- package-lock.json | 47 ++-- packages/sandbox-container/package.json | 1 + .../executors/javascript/node_executor.ts | 24 +- .../executors/shared/code-transformer.ts | 55 +++++ .../src/runtime/executors/shared/thenable.ts | 24 ++ .../executors/typescript/ts_executor.ts | 24 +- .../tests/runtime/code-transformer.test.ts | 189 ++++++++++++++++ tests/e2e/code-interpreter-workflow.test.ts | 208 ++++++++++++++++++ 8 files changed, 521 insertions(+), 51 deletions(-) create mode 100644 packages/sandbox-container/src/runtime/executors/shared/code-transformer.ts create mode 100644 packages/sandbox-container/src/runtime/executors/shared/thenable.ts create mode 100644 packages/sandbox-container/tests/runtime/code-transformer.test.ts diff --git a/package-lock.json b/package-lock.json index 07f212e6..f83b21e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -401,6 +401,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -422,6 +423,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -561,6 +563,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1506,7 +1509,8 @@ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20251121.0.tgz", "integrity": "sha512-jzFg7hEGKzpEalxTCanN6lM8IdkvO/brsERp/+OyMms4Zi0nhDPUAg9dUcKU8wDuDUnzbjkplY6YRwle7Cq6gA==", "devOptional": true, - "license": "MIT OR Apache-2.0" + "license": "MIT OR Apache-2.0", + "peer": true }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", @@ -2820,6 +2824,7 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -4289,6 +4294,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4304,6 +4310,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz", "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4313,6 +4320,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4439,6 +4447,7 @@ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", @@ -4454,6 +4463,7 @@ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", @@ -4482,6 +4492,7 @@ "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "fflate": "^0.8.2", @@ -4629,6 +4640,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5374,6 +5386,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5658,6 +5671,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -7595,6 +7609,7 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -7859,6 +7874,7 @@ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "devOptional": true, "license": "MPL-2.0", + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -7895,7 +7911,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7916,7 +7931,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7937,7 +7951,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7958,7 +7971,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7979,7 +7991,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8000,7 +8011,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8021,7 +8031,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8042,7 +8051,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8063,7 +8071,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8084,7 +8091,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8105,7 +8111,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8155,7 +8160,6 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -9724,6 +9728,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9940,6 +9945,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9949,6 +9955,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9960,8 +9967,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-katex": { "version": "3.1.0", @@ -10335,6 +10341,7 @@ "integrity": "sha512-ZRLgPlS91l4JztLYEZnmMcd3Umcla1hkXJgiEiR4HloRJBBoeaX8qogTu5Jfu36rRMVLndzqYv0h+M5gJAkUfg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "=0.98.0", "@rolldown/pluginutils": "1.0.0-beta.51" @@ -11150,6 +11157,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11349,6 +11357,7 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -11508,6 +11517,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11577,6 +11587,7 @@ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "license": "MIT", + "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -12018,6 +12029,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -12132,6 +12144,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12164,6 +12177,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -12589,6 +12603,7 @@ "integrity": "sha512-Om5ns0Lyx/LKtYI04IV0bjIrkBgoFNg0p6urzr2asekJlfP18RqFzyqMFZKf0i9Gnjtz/JfAS/Ol6tjCe5JJsQ==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -13352,6 +13367,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -13422,6 +13438,7 @@ "version": "0.0.2", "dependencies": { "@repo/shared": "*", + "acorn": "^8.14.0", "esbuild": "^0.27.0", "zod": "^3.22.3" }, diff --git a/packages/sandbox-container/package.json b/packages/sandbox-container/package.json index 25aaa5d1..6ad104b5 100644 --- a/packages/sandbox-container/package.json +++ b/packages/sandbox-container/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@repo/shared": "*", + "acorn": "^8.14.0", "esbuild": "^0.27.0", "zod": "^3.22.3" }, diff --git a/packages/sandbox-container/src/runtime/executors/javascript/node_executor.ts b/packages/sandbox-container/src/runtime/executors/javascript/node_executor.ts index 25f91ff9..00a6e465 100644 --- a/packages/sandbox-container/src/runtime/executors/javascript/node_executor.ts +++ b/packages/sandbox-container/src/runtime/executors/javascript/node_executor.ts @@ -7,22 +7,8 @@ import { fileURLToPath } from 'node:url'; import * as util from 'node:util'; import * as vm from 'node:vm'; import type { RichOutput } from '../../process-pool'; - -interface Thenable { - then: ( - onfulfilled?: (value: T) => unknown, - onrejected?: (reason: unknown) => unknown - ) => unknown; -} - -function isThenable(value: unknown): value is Thenable { - return ( - value !== null && - typeof value === 'object' && - 'then' in value && - typeof (value as Thenable).then === 'function' - ); -} +import { transformForAsyncExecution } from '../shared/code-transformer'; +import { isThenable } from '../shared/thenable'; // Create CommonJS-like globals for the sandbox const __filename = fileURLToPath(import.meta.url); @@ -99,9 +85,11 @@ rl.on('line', async (line: string) => { options.timeout = timeout; } - result = vm.runInContext(code, context, options); + // Transform code to support top-level await and capture last expression + const wrappedCode = transformForAsyncExecution(code); + result = vm.runInContext(wrappedCode, context, options); - // If result is a Promise (thenable), await it + // Result is always a Promise from async IIFE - await it if (isThenable(result)) { result = await result; } diff --git a/packages/sandbox-container/src/runtime/executors/shared/code-transformer.ts b/packages/sandbox-container/src/runtime/executors/shared/code-transformer.ts new file mode 100644 index 00000000..83e70d20 --- /dev/null +++ b/packages/sandbox-container/src/runtime/executors/shared/code-transformer.ts @@ -0,0 +1,55 @@ +import * as acorn from 'acorn'; + +/** + * Transforms JavaScript code to support top-level await and return the last expression's value. + * + * The transformation wraps code in an async IIFE: + * - If the last statement is an expression, it's returned + * - If the last statement is a declaration/control flow, undefined is returned + * + * Examples: + * "42" → "(async () => {\nreturn 42;\n})()" + * "const x = 5" → "(async () => {\nconst x = 5\n})()" + * "const x = 5; x * 2" → "(async () => {\nconst x = 5; return x * 2;\n})()" + * "await Promise.resolve()" → "(async () => {\nreturn await Promise.resolve();\n})()" + */ +export function transformForAsyncExecution(code: string): string { + const trimmed = code.trim(); + + if (!trimmed) { + return '(async () => {})()'; + } + + try { + const ast = acorn.parse(trimmed, { + ecmaVersion: 'latest', + sourceType: 'script', + allowAwaitOutsideFunction: true + }); + + const body = ast.body; + + if (body.length === 0) { + return '(async () => {})()'; + } + + const lastNode = body[body.length - 1]; + + // Check if last statement is an ExpressionStatement (evaluates to a value) + if (lastNode.type === 'ExpressionStatement') { + const beforeLast = trimmed.slice(0, lastNode.start); + const lastExpr = trimmed.slice(lastNode.start, lastNode.end); + // Remove trailing semicolon for clean return statement + const cleanedExpr = lastExpr.replace(/;$/, ''); + + return `(async () => {\n${beforeLast}return ${cleanedExpr};\n})()`; + } + + // Last statement is a declaration, control flow, etc. - no return value + return `(async () => {\n${trimmed}\n})()`; + } catch { + // Parse error - wrap as-is and let vm.runInContext surface the error + // with proper line numbers and context + return `(async () => {\n${trimmed}\n})()`; + } +} diff --git a/packages/sandbox-container/src/runtime/executors/shared/thenable.ts b/packages/sandbox-container/src/runtime/executors/shared/thenable.ts new file mode 100644 index 00000000..790a3818 --- /dev/null +++ b/packages/sandbox-container/src/runtime/executors/shared/thenable.ts @@ -0,0 +1,24 @@ +/** + * Represents a Promise-like object with a then method. + * Used for detecting async results from vm.runInContext(). + */ +export interface Thenable { + then: ( + onfulfilled?: (value: T) => unknown, + onrejected?: (reason: unknown) => unknown + ) => unknown; +} + +/** + * Type guard to check if a value is a thenable (Promise-like). + * This is used to detect when vm.runInContext() returns a Promise + * that needs to be awaited. + */ +export function isThenable(value: unknown): value is Thenable { + return ( + value !== null && + typeof value === 'object' && + 'then' in value && + typeof (value as Thenable).then === 'function' + ); +} diff --git a/packages/sandbox-container/src/runtime/executors/typescript/ts_executor.ts b/packages/sandbox-container/src/runtime/executors/typescript/ts_executor.ts index b5de9852..5a707984 100644 --- a/packages/sandbox-container/src/runtime/executors/typescript/ts_executor.ts +++ b/packages/sandbox-container/src/runtime/executors/typescript/ts_executor.ts @@ -8,22 +8,8 @@ import * as util from 'node:util'; import * as vm from 'node:vm'; import { transformSync } from 'esbuild'; import type { RichOutput } from '../../process-pool'; - -interface Thenable { - then: ( - onfulfilled?: (value: T) => unknown, - onrejected?: (reason: unknown) => unknown - ) => unknown; -} - -function isThenable(value: unknown): value is Thenable { - return ( - value !== null && - typeof value === 'object' && - 'then' in value && - typeof (value as Thenable).then === 'function' - ); -} +import { transformForAsyncExecution } from '../shared/code-transformer'; +import { isThenable } from '../shared/thenable'; // Create CommonJS-like globals for the sandbox const __filename = fileURLToPath(import.meta.url); @@ -110,9 +96,11 @@ rl.on('line', async (line: string) => { options.timeout = timeout; } - result = vm.runInContext(jsCode, context, options); + // Transform code to support top-level await and capture last expression + const wrappedCode = transformForAsyncExecution(jsCode); + result = vm.runInContext(wrappedCode, context, options); - // If result is a Promise (thenable), await it + // Result is always a Promise from async IIFE - await it if (isThenable(result)) { result = await result; } diff --git a/packages/sandbox-container/tests/runtime/code-transformer.test.ts b/packages/sandbox-container/tests/runtime/code-transformer.test.ts new file mode 100644 index 00000000..4fe94af2 --- /dev/null +++ b/packages/sandbox-container/tests/runtime/code-transformer.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test } from 'bun:test'; +import { transformForAsyncExecution } from '../../src/runtime/executors/shared/code-transformer'; + +describe('transformForAsyncExecution', () => { + describe('basic expressions (should return value)', () => { + test('single number', () => { + const result = transformForAsyncExecution('42'); + expect(result).toBe('(async () => {\nreturn 42;\n})()'); + }); + + test('single string', () => { + const result = transformForAsyncExecution('"hello"'); + expect(result).toBe('(async () => {\nreturn "hello";\n})()'); + }); + + test('identifier', () => { + const result = transformForAsyncExecution('x'); + expect(result).toBe('(async () => {\nreturn x;\n})()'); + }); + + test('property access', () => { + const result = transformForAsyncExecution('data.items'); + expect(result).toBe('(async () => {\nreturn data.items;\n})()'); + }); + + test('function call', () => { + const result = transformForAsyncExecution('foo()'); + expect(result).toBe('(async () => {\nreturn foo();\n})()'); + }); + + test('binary expression', () => { + const result = transformForAsyncExecution('1 + 2'); + expect(result).toBe('(async () => {\nreturn 1 + 2;\n})()'); + }); + + test('object literal', () => { + const result = transformForAsyncExecution('({ a: 1, b: 2 })'); + expect(result).toBe('(async () => {\nreturn ({ a: 1, b: 2 });\n})()'); + }); + + test('array literal', () => { + const result = transformForAsyncExecution('[1, 2, 3]'); + expect(result).toBe('(async () => {\nreturn [1, 2, 3];\n})()'); + }); + + test('expression with trailing semicolon', () => { + const result = transformForAsyncExecution('42;'); + expect(result).toBe('(async () => {\nreturn 42;\n})()'); + }); + }); + + describe('declarations (should return undefined)', () => { + test('const declaration', () => { + const result = transformForAsyncExecution('const x = 5'); + expect(result).toBe('(async () => {\nconst x = 5\n})()'); + }); + + test('let declaration', () => { + const result = transformForAsyncExecution('let x = 5'); + expect(result).toBe('(async () => {\nlet x = 5\n})()'); + }); + + test('var declaration', () => { + const result = transformForAsyncExecution('var x = 5'); + expect(result).toBe('(async () => {\nvar x = 5\n})()'); + }); + + test('function declaration', () => { + const result = transformForAsyncExecution('function foo() { return 1; }'); + expect(result).toBe('(async () => {\nfunction foo() { return 1; }\n})()'); + }); + + test('class declaration', () => { + const result = transformForAsyncExecution('class Foo {}'); + expect(result).toBe('(async () => {\nclass Foo {}\n})()'); + }); + }); + + describe('mixed statements (declaration + expression)', () => { + test('const then identifier', () => { + const result = transformForAsyncExecution('const x = 5\nx'); + expect(result).toBe('(async () => {\nconst x = 5\nreturn x;\n})()'); + }); + + test('const then expression', () => { + const result = transformForAsyncExecution('const x = 5; x * 2'); + expect(result).toBe('(async () => {\nconst x = 5; return x * 2;\n})()'); + }); + + test('multiple statements ending with expression', () => { + const result = transformForAsyncExecution( + 'const a = 1\nconst b = 2\na + b' + ); + expect(result).toBe( + '(async () => {\nconst a = 1\nconst b = 2\nreturn a + b;\n})()' + ); + }); + + test('multiple statements ending with declaration', () => { + const result = transformForAsyncExecution('const a = 1\nconst b = 2'); + expect(result).toBe('(async () => {\nconst a = 1\nconst b = 2\n})()'); + }); + }); + + describe('async/await code', () => { + test('single await expression', () => { + const result = transformForAsyncExecution('await Promise.resolve(42)'); + expect(result).toBe( + '(async () => {\nreturn await Promise.resolve(42);\n})()' + ); + }); + + test('await with assignment then expression', () => { + const result = transformForAsyncExecution( + 'const x = await Promise.resolve(10)\nx * 2' + ); + expect(result).toBe( + '(async () => {\nconst x = await Promise.resolve(10)\nreturn x * 2;\n})()' + ); + }); + + test('multiple awaits ending with expression', () => { + const code = `const a = await Promise.resolve(10) +const b = await Promise.resolve(20) +a + b`; + const result = transformForAsyncExecution(code); + expect(result).toContain('return a + b;'); + expect(result).toContain('const a = await Promise.resolve(10)'); + expect(result).toContain('const b = await Promise.resolve(20)'); + }); + + test('async IIFE (already wrapped)', () => { + const result = transformForAsyncExecution('(async () => 42)()'); + expect(result).toBe('(async () => {\nreturn (async () => 42)();\n})()'); + }); + }); + + describe('edge cases', () => { + test('empty string', () => { + const result = transformForAsyncExecution(''); + expect(result).toBe('(async () => {})()'); + }); + + test('whitespace only', () => { + const result = transformForAsyncExecution(' \n '); + expect(result).toBe('(async () => {})()'); + }); + + test('multiline expression', () => { + const code = `{ + a: 1, + b: 2 +}`; + const result = transformForAsyncExecution(code); + // This is a block statement, not object literal - should not return + expect(result).toContain('(async () => {'); + expect(result).toContain('})()'); + }); + + test('parenthesized object literal', () => { + const code = `({ + a: 1, + b: 2 +})`; + const result = transformForAsyncExecution(code); + expect(result).toContain('return ({'); + }); + + test('syntax error passthrough', () => { + const result = transformForAsyncExecution('const x ='); + // Should still wrap, let vm surface the error + expect(result).toBe('(async () => {\nconst x =\n})()'); + }); + + test('control flow statements', () => { + const result = transformForAsyncExecution('if (true) { x = 1 }'); + expect(result).toBe('(async () => {\nif (true) { x = 1 }\n})()'); + }); + + test('for loop', () => { + const result = transformForAsyncExecution( + 'for (let i = 0; i < 10; i++) {}' + ); + expect(result).toBe( + '(async () => {\nfor (let i = 0; i < 10; i++) {}\n})()' + ); + }); + }); +}); diff --git a/tests/e2e/code-interpreter-workflow.test.ts b/tests/e2e/code-interpreter-workflow.test.ts index a5c8addb..442d4cd3 100644 --- a/tests/e2e/code-interpreter-workflow.test.ts +++ b/tests/e2e/code-interpreter-workflow.test.ts @@ -670,6 +670,214 @@ describe('Code Interpreter Workflow (E2E)', () => { ).toMatch(/TypeScript async error|TypeError|Error/i); }, 120000); + // ============================================================================ + // Top-Level Await (without IIFE wrappers) + // ============================================================================ + + test('should support top-level await without IIFE wrapper', 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 top-level await WITHOUT IIFE wrapper + const execResponse = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: 'await Promise.resolve({ value: 42 })', + 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.length).toBeGreaterThan(0); + const resultData = execution.results[0]; + expect(resultData.json).toEqual({ value: 42 }); + }, 120000); + + test('should return last expression value 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' }) + }); + + const context = await ctxResponse.json(); + + // Execute multi-statement code with top-level await + const execResponse = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: `const a = await Promise.resolve(10) +const b = await Promise.resolve(20) +({ sum: a + b })`, + 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.length).toBeGreaterThan(0); + const resultData = execution.results[0]; + expect(resultData.json).toEqual({ sum: 30 }); + }, 120000); + + test('should return undefined when last statement is declaration', 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 ending with declaration (no return value) + const execResponse = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: 'const x = await Promise.resolve(42)', + options: { context } + }) + }); + + expect(execResponse.status).toBe(200); + const execution = (await execResponse.json()) as ExecutionResult; + + expect(execution.error).toBeUndefined(); + // No results since result is undefined + expect(execution.results.length).toBe(0); + }, 120000); + + test('should support TypeScript top-level await', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Create TypeScript context + const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { + method: 'POST', + headers, + body: JSON.stringify({ language: 'typescript' }) + }); + + const context = await ctxResponse.json(); + + // Execute TypeScript with top-level await + const execResponse = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: `const value: number = await Promise.resolve(100) +const doubled: number = value * 2 +({ result: doubled })`, + 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.length).toBeGreaterThan(0); + const resultData = execution.results[0]; + expect(resultData.json).toEqual({ result: 200 }); + }, 120000); + + test('should handle top-level await with rejected promise', 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 top-level await with rejection + const execResponse = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: 'await Promise.reject(new Error("TLA rejection test"))', + 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.message || + execution.error.name || + execution.logs.stderr.join('') + ).toMatch(/TLA rejection test|Error/i); + }, 120000); + + test('should return simple expression value without 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 simple expression (no async) + const execResponse = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: '1 + 2 + 3', + 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.length).toBeGreaterThan(0); + // Simple number result + const resultData = execution.results[0]; + expect(resultData.text).toBe('6'); + }, 120000); + // ============================================================================ // Streaming Execution // ============================================================================