diff --git a/packages/js-sdk/tests/api/info.test.ts b/packages/js-sdk/tests/api/info.test.ts index 0e3ca7c8e8..7fa194b583 100644 --- a/packages/js-sdk/tests/api/info.test.ts +++ b/packages/js-sdk/tests/api/info.test.ts @@ -1,9 +1,9 @@ import { expect } from 'vitest' -import { sandboxTest, isDebug } from '../setup.js' +import { hostedSandboxTest } from '../setup.js' import { Sandbox } from '../../src' -sandboxTest.skipIf(isDebug)('get sandbox info', async ({ sandbox }) => { +hostedSandboxTest('get sandbox info', async ({ sandbox }) => { const info = await Sandbox.getInfo(sandbox.sandboxId) expect(info).toBeDefined() expect(info.sandboxId).toBe(sandbox.sandboxId) diff --git a/packages/js-sdk/tests/api/kill.test.ts b/packages/js-sdk/tests/api/kill.test.ts index 04cd45f835..7fdd2b887e 100644 --- a/packages/js-sdk/tests/api/kill.test.ts +++ b/packages/js-sdk/tests/api/kill.test.ts @@ -1,9 +1,9 @@ import { expect } from 'vitest' -import { sandboxTest, isDebug } from '../setup.js' +import { hostedSandboxTest } from '../setup.js' import { Sandbox } from '../../src' -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'kill existing sandbox', async ({ sandbox, sandboxTestId }) => { await Sandbox.kill(sandbox.sandboxId) @@ -16,6 +16,6 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)('kill non-existing sandbox', async () => { +hostedSandboxTest('kill non-existing sandbox', async () => { await expect(Sandbox.kill('nonexistingsandbox')).resolves.toBe(false) }) diff --git a/packages/js-sdk/tests/api/list.test.ts b/packages/js-sdk/tests/api/list.test.ts index 52cb8a9f24..6614b50e3a 100644 --- a/packages/js-sdk/tests/api/list.test.ts +++ b/packages/js-sdk/tests/api/list.test.ts @@ -2,24 +2,21 @@ import { assert } from 'vitest' import { randomUUID } from 'crypto' import { Sandbox, SandboxInfo } from '../../src' -import { sandboxTest, isDebug } from '../setup.js' +import { hostedSandboxTest } from '../setup.js' -sandboxTest.skipIf(isDebug)( - 'list sandboxes', - async ({ sandbox, sandboxTestId }) => { - const paginator = Sandbox.list({ - query: { metadata: { sandboxTestId } }, - }) - const sandboxes = await paginator.nextItems() +hostedSandboxTest('list sandboxes', async ({ sandbox, sandboxTestId }) => { + const paginator = Sandbox.list({ + query: { metadata: { sandboxTestId } }, + }) + const sandboxes = await paginator.nextItems() - assert.isAtLeast(sandboxes.length, 1) + assert.isAtLeast(sandboxes.length, 1) - const found = sandboxes.some((s) => s.sandboxId === sandbox.sandboxId) - assert.isTrue(found) - } -) + const found = sandboxes.some((s) => s.sandboxId === sandbox.sandboxId) + assert.isTrue(found) +}) -sandboxTest.skipIf(isDebug)('list sandboxes with filter', async () => { +hostedSandboxTest('list sandboxes with filter', async () => { const uniqueId = randomUUID() const extraSbx = await Sandbox.create({ metadata: { uniqueId } }) @@ -36,57 +33,51 @@ sandboxTest.skipIf(isDebug)('list sandboxes with filter', async () => { } }) -sandboxTest.skipIf(isDebug)( - 'list running sandboxes', - async ({ sandboxTestId }) => { - const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) +hostedSandboxTest('list running sandboxes', async ({ sandboxTestId }) => { + const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) - try { - const paginator = Sandbox.list({ - query: { metadata: { sandboxTestId }, state: ['running'] }, - }) - const sandboxes = await paginator.nextItems() + try { + const paginator = Sandbox.list({ + query: { metadata: { sandboxTestId }, state: ['running'] }, + }) + const sandboxes = await paginator.nextItems() - assert.isAtLeast(sandboxes.length, 1) + assert.isAtLeast(sandboxes.length, 1) - // Verify our running sandbox is in the list - const found = sandboxes.some( - (s) => s.sandboxId === extraSbx.sandboxId && s.state === 'running' - ) - assert.isTrue(found) - } finally { - await extraSbx.kill() - } + // Verify our running sandbox is in the list + const found = sandboxes.some( + (s) => s.sandboxId === extraSbx.sandboxId && s.state === 'running' + ) + assert.isTrue(found) + } finally { + await extraSbx.kill() } -) +}) -sandboxTest.skipIf(isDebug)( - 'list paused sandboxes', - async ({ sandboxTestId }) => { - // Create and pause a sandbox - const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) - await extraSbx.betaPause() +hostedSandboxTest('list paused sandboxes', async ({ sandboxTestId }) => { + // Create and pause a sandbox + const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) + await extraSbx.betaPause() - try { - const paginator = Sandbox.list({ - query: { metadata: { sandboxTestId }, state: ['paused'] }, - }) - const sandboxes = await paginator.nextItems() + try { + const paginator = Sandbox.list({ + query: { metadata: { sandboxTestId }, state: ['paused'] }, + }) + const sandboxes = await paginator.nextItems() - assert.isAtLeast(sandboxes.length, 1) + assert.isAtLeast(sandboxes.length, 1) - // Verify our paused sandbox is in the list - const found = sandboxes.some( - (s) => s.sandboxId === extraSbx.sandboxId && s.state === 'paused' - ) - assert.isTrue(found) - } finally { - await extraSbx.kill() - } + // Verify our paused sandbox is in the list + const found = sandboxes.some( + (s) => s.sandboxId === extraSbx.sandboxId && s.state === 'paused' + ) + assert.isTrue(found) + } finally { + await extraSbx.kill() } -) +}) -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'paginate running sandboxes', async ({ sandbox, sandboxTestId }) => { // Create extra sandboxes @@ -122,7 +113,7 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'paginate paused sandboxes', async ({ sandbox, sandboxTestId }) => { await sandbox.betaPause() @@ -161,7 +152,7 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'paginate running and paused sandboxes', async ({ sandbox, sandboxTestId }) => { // Create extra sandbox @@ -203,25 +194,22 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( - 'paginate iterator', - async ({ sandbox, sandboxTestId }) => { - const paginator = Sandbox.list({ - query: { metadata: { sandboxTestId } }, - }) - const sandboxes: SandboxInfo[] = [] +hostedSandboxTest('paginate iterator', async ({ sandbox, sandboxTestId }) => { + const paginator = Sandbox.list({ + query: { metadata: { sandboxTestId } }, + }) + const sandboxes: SandboxInfo[] = [] - while (paginator.hasNext) { - const sbxs = await paginator.nextItems() - sandboxes.push(...sbxs) - } - - assert.isAtLeast(sandboxes.length, 1) - assert.isTrue(sandboxes.some((s) => s.sandboxId === sandbox.sandboxId)) + while (paginator.hasNext) { + const sbxs = await paginator.nextItems() + sandboxes.push(...sbxs) } -) -sandboxTest.skipIf(isDebug)( + assert.isAtLeast(sandboxes.length, 1) + assert.isTrue(sandboxes.some((s) => s.sandboxId === sandbox.sandboxId)) +}) + +hostedSandboxTest( 'list sandboxes with order', async ({ sandbox, sandboxTestId }) => { const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) @@ -250,7 +238,7 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'list sandboxes started after', async ({ sandbox, sandboxTestId }) => { const info = await sandbox.getInfo() @@ -279,7 +267,7 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'list sandboxes with template filter', async ({ sandbox, sandboxTestId }) => { const info = await sandbox.getInfo() @@ -300,22 +288,19 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( - 'list sandboxes', - async ({ sandbox, sandboxTestId }) => { - const paginator = Sandbox.list({ - query: { metadata: { sandboxTestId } }, - }) - const sandboxes = await paginator.nextItems() +hostedSandboxTest('list sandboxes', async ({ sandbox, sandboxTestId }) => { + const paginator = Sandbox.list({ + query: { metadata: { sandboxTestId } }, + }) + const sandboxes = await paginator.nextItems() - assert.isAtLeast(sandboxes.length, 1) + assert.isAtLeast(sandboxes.length, 1) - const found = sandboxes.some((s) => s.sandboxId === sandbox.sandboxId) - assert.isTrue(found) - } -) + const found = sandboxes.some((s) => s.sandboxId === sandbox.sandboxId) + assert.isTrue(found) +}) -sandboxTest.skipIf(isDebug)('list sandboxes with filter', async () => { +hostedSandboxTest('list sandboxes with filter', async () => { const uniqueId = randomUUID() const extraSbx = await Sandbox.create({ metadata: { uniqueId } }) @@ -332,57 +317,51 @@ sandboxTest.skipIf(isDebug)('list sandboxes with filter', async () => { } }) -sandboxTest.skipIf(isDebug)( - 'list running sandboxes', - async ({ sandboxTestId }) => { - const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) +hostedSandboxTest('list running sandboxes', async ({ sandboxTestId }) => { + const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) - try { - const paginator = Sandbox.list({ - query: { metadata: { sandboxTestId }, state: ['running'] }, - }) - const sandboxes = await paginator.nextItems() + try { + const paginator = Sandbox.list({ + query: { metadata: { sandboxTestId }, state: ['running'] }, + }) + const sandboxes = await paginator.nextItems() - assert.isAtLeast(sandboxes.length, 1) + assert.isAtLeast(sandboxes.length, 1) - // Verify our running sandbox is in the list - const found = sandboxes.some( - (s) => s.sandboxId === extraSbx.sandboxId && s.state === 'running' - ) - assert.isTrue(found) - } finally { - await extraSbx.kill() - } + // Verify our running sandbox is in the list + const found = sandboxes.some( + (s) => s.sandboxId === extraSbx.sandboxId && s.state === 'running' + ) + assert.isTrue(found) + } finally { + await extraSbx.kill() } -) +}) -sandboxTest.skipIf(isDebug)( - 'list paused sandboxes', - async ({ sandboxTestId }) => { - // Create and pause a sandbox - const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) - await Sandbox.betaPause(extraSbx.sandboxId) +hostedSandboxTest('list paused sandboxes', async ({ sandboxTestId }) => { + // Create and pause a sandbox + const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) + await Sandbox.betaPause(extraSbx.sandboxId) - try { - const paginator = Sandbox.list({ - query: { metadata: { sandboxTestId }, state: ['paused'] }, - }) - const sandboxes = await paginator.nextItems() + try { + const paginator = Sandbox.list({ + query: { metadata: { sandboxTestId }, state: ['paused'] }, + }) + const sandboxes = await paginator.nextItems() - assert.isAtLeast(sandboxes.length, 1) + assert.isAtLeast(sandboxes.length, 1) - // Verify our paused sandbox is in the list - const found = sandboxes.some( - (s) => s.sandboxId === extraSbx.sandboxId && s.state === 'paused' - ) - assert.isTrue(found) - } finally { - await extraSbx.kill() - } + // Verify our paused sandbox is in the list + const found = sandboxes.some( + (s) => s.sandboxId === extraSbx.sandboxId && s.state === 'paused' + ) + assert.isTrue(found) + } finally { + await extraSbx.kill() } -) +}) -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'paginate running sandboxes', async ({ sandbox, sandboxTestId }) => { // Create extra sandboxes @@ -418,7 +397,7 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'paginate paused sandboxes', async ({ sandbox, sandboxTestId }) => { await Sandbox.betaPause(sandbox.sandboxId) @@ -457,7 +436,7 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'paginate running and paused sandboxes', async ({ sandbox, sandboxTestId }) => { // Create extra sandbox @@ -500,20 +479,17 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( - 'paginate iterator', - async ({ sandbox, sandboxTestId }) => { - const paginator = Sandbox.list({ - query: { metadata: { sandboxTestId } }, - }) - const sandboxes: SandboxInfo[] = [] +hostedSandboxTest('paginate iterator', async ({ sandbox, sandboxTestId }) => { + const paginator = Sandbox.list({ + query: { metadata: { sandboxTestId } }, + }) + const sandboxes: SandboxInfo[] = [] - while (paginator.hasNext) { - const sbxs = await paginator.nextItems() - sandboxes.push(...sbxs) - } - - assert.isAtLeast(sandboxes.length, 1) - assert.isTrue(sandboxes.some((s) => s.sandboxId === sandbox.sandboxId)) + while (paginator.hasNext) { + const sbxs = await paginator.nextItems() + sandboxes.push(...sbxs) } -) + + assert.isAtLeast(sandboxes.length, 1) + assert.isTrue(sandboxes.some((s) => s.sandboxId === sandbox.sandboxId)) +}) diff --git a/packages/js-sdk/tests/api/snapshot.test.ts b/packages/js-sdk/tests/api/snapshot.test.ts index 828389d143..c0956458da 100644 --- a/packages/js-sdk/tests/api/snapshot.test.ts +++ b/packages/js-sdk/tests/api/snapshot.test.ts @@ -1,9 +1,9 @@ import { assert } from 'vitest' -import { sandboxTest, isDebug } from '../setup.js' +import { hostedSandboxTest } from '../setup.js' import { Sandbox } from '../../src' -sandboxTest.skipIf(isDebug)('pause sandbox', async ({ sandbox }) => { +hostedSandboxTest('pause sandbox', async ({ sandbox }) => { await Sandbox.pause(sandbox.sandboxId) assert.isFalse( await sandbox.isRunning(), @@ -11,7 +11,7 @@ sandboxTest.skipIf(isDebug)('pause sandbox', async ({ sandbox }) => { ) }) -sandboxTest.skipIf(isDebug)('resume sandbox', async ({ sandbox }) => { +hostedSandboxTest('resume sandbox', async ({ sandbox }) => { await Sandbox.pause(sandbox.sandboxId) assert.isFalse( await sandbox.isRunning(), diff --git a/packages/js-sdk/tests/sandbox/commands/commandHandle.test.ts b/packages/js-sdk/tests/sandbox/commandHandle.test.ts similarity index 99% rename from packages/js-sdk/tests/sandbox/commands/commandHandle.test.ts rename to packages/js-sdk/tests/sandbox/commandHandle.test.ts index eec91b7b5a..203e3cc47d 100644 --- a/packages/js-sdk/tests/sandbox/commands/commandHandle.test.ts +++ b/packages/js-sdk/tests/sandbox/commandHandle.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { CommandHandle } from '../../../src/sandbox/commands/commandHandle' +import { CommandHandle } from '../../src/sandbox/commands/commandHandle' type EventKind = 'stdout' | 'stderr' | 'pty' diff --git a/packages/js-sdk/tests/sandbox/commands/envVars.test.ts b/packages/js-sdk/tests/sandbox/commands/envVars.test.ts index 6a898cd31d..42a8dcc327 100644 --- a/packages/js-sdk/tests/sandbox/commands/envVars.test.ts +++ b/packages/js-sdk/tests/sandbox/commands/envVars.test.ts @@ -1,6 +1,6 @@ import { assert, describe } from 'vitest' -import { sandboxTest, isDebug } from '../../setup.js' +import { hostedSandboxTest, sandboxTest } from '../../setup.js' describe('sandbox global env vars', () => { sandboxTest.override({ @@ -9,15 +9,12 @@ describe('sandbox global env vars', () => { }, }) - sandboxTest.skipIf(isDebug)( - 'sandbox global env vars', - async ({ sandbox }) => { - const cmd = await sandbox.commands.run('echo $FOO') + hostedSandboxTest('sandbox global env vars', async ({ sandbox }) => { + const cmd = await sandbox.commands.run('echo $FOO') - assert.equal(cmd.exitCode, 0) - assert.equal(cmd.stdout.trim(), 'bar') - } - ) + assert.equal(cmd.exitCode, 0) + assert.equal(cmd.stdout.trim(), 'bar') + }) }) sandboxTest('bash command scoped env vars', async ({ sandbox }) => { diff --git a/packages/js-sdk/tests/sandbox/commands/sandboxKilledDuringRun.test.ts b/packages/js-sdk/tests/sandbox/commands/sandboxKilledDuringRun.test.ts index 511a7a38a2..325506f946 100644 --- a/packages/js-sdk/tests/sandbox/commands/sandboxKilledDuringRun.test.ts +++ b/packages/js-sdk/tests/sandbox/commands/sandboxKilledDuringRun.test.ts @@ -1,9 +1,9 @@ import { expect } from 'vitest' import { TimeoutError } from '../../../src/index.js' -import { sandboxTest, isDebug } from '../../setup.js' +import { hostedSandboxTest } from '../../setup.js' -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'killing the sandbox while a command is running throws an actionable error', async ({ sandbox }) => { const cmd = await sandbox.commands.run('sleep 60', { background: true }) diff --git a/packages/js-sdk/tests/sandbox/connect.test.ts b/packages/js-sdk/tests/sandbox/connect.test.ts index 9b7ca5f5f6..97fb21abd2 100644 --- a/packages/js-sdk/tests/sandbox/connect.test.ts +++ b/packages/js-sdk/tests/sandbox/connect.test.ts @@ -1,31 +1,9 @@ -import { assert, test, expect, vi } from 'vitest' +import { assert, expect } from 'vitest' import { Sandbox } from '../../src' -import { isDebug, sandboxTest, template } from '../setup.js' +import { hostedSandboxTest, hostedTest, isDebug, template } from '../setup.js' -test('connect in debug mode does not call the API', async () => { - const fetchSpy = vi.fn(() => { - throw new Error('unexpected request in debug mode') - }) - vi.stubGlobal('fetch', fetchSpy) - - try { - const sbx = await Sandbox.connect('debug-sandbox-id', { - debug: true, - apiKey: 'test-api-key', - }) - assert.equal(sbx.sandboxId, 'debug-sandbox-id') - - const sameSbx = await sbx.connect() - assert.strictEqual(sameSbx, sbx) - - expect(fetchSpy).not.toHaveBeenCalled() - } finally { - vi.unstubAllGlobals() - } -}) - -test.skipIf(isDebug)('connect', async () => { +hostedTest('connect', async () => { const sbx = await Sandbox.create(template, { timeoutMs: 10_000 }) try { @@ -42,65 +20,56 @@ test.skipIf(isDebug)('connect', async () => { } }) -sandboxTest.skipIf(isDebug)( - 'connect resumes paused sandbox', - async ({ sandbox }) => { - await sandbox.pause() - assert.isFalse(await sandbox.isRunning()) +hostedSandboxTest('connect resumes paused sandbox', async ({ sandbox }) => { + await sandbox.pause() + assert.isFalse(await sandbox.isRunning()) - const resumed = await Sandbox.connect(sandbox.sandboxId) - assert.isTrue(await resumed.isRunning()) - } -) + const resumed = await Sandbox.connect(sandbox.sandboxId) + assert.isTrue(await resumed.isRunning()) +}) -sandboxTest.skipIf(isDebug)( - 'connect to non-running sandbox', - async ({ sandbox }) => { - const isRunning = await sandbox.isRunning() - assert.isTrue(isRunning) - await sandbox.kill() +hostedSandboxTest('connect to non-running sandbox', async ({ sandbox }) => { + const isRunning = await sandbox.isRunning() + assert.isTrue(isRunning) + await sandbox.kill() - const connectPromise = Sandbox.connect(sandbox.sandboxId) - await expect(connectPromise).rejects.toThrowError( - expect.objectContaining({ - name: 'SandboxNotFoundError', - }) - ) - } -) + const connectPromise = Sandbox.connect(sandbox.sandboxId) + await expect(connectPromise).rejects.toThrowError( + expect.objectContaining({ + name: 'SandboxNotFoundError', + }) + ) +}) -test.skipIf(isDebug)( - 'connect does not shorten timeout on running sandbox', - async () => { - // Create sandbox with a 300 second timeout - const sbx = await Sandbox.create(template, { timeoutMs: 300_000 }) +hostedTest('connect does not shorten timeout on running sandbox', async () => { + // Create sandbox with a 300 second timeout + const sbx = await Sandbox.create(template, { timeoutMs: 300_000 }) - try { - const isRunning = await sbx.isRunning() - assert.isTrue(isRunning) + try { + const isRunning = await sbx.isRunning() + assert.isTrue(isRunning) - // Get initial info to check endAt - const infoBefore = await Sandbox.getInfo(sbx.sandboxId) + // Get initial info to check endAt + const infoBefore = await Sandbox.getInfo(sbx.sandboxId) - // Connect with a shorter timeout (10 seconds) - await Sandbox.connect(sbx.sandboxId, { timeoutMs: 10_000 }) + // Connect with a shorter timeout (10 seconds) + await Sandbox.connect(sbx.sandboxId, { timeoutMs: 10_000 }) - // Get info after connection - const infoAfter = await sbx.getInfo() + // Get info after connection + const infoAfter = await sbx.getInfo() - // The endAt time should not have been shortened. It should be the same - assert.equal( - infoAfter.endAt.getTime(), - infoBefore.endAt.getTime(), - `Timeout was shortened: before=${infoBefore.endAt.toISOString()}, after=${infoAfter.endAt.toISOString()}` - ) - } finally { - await sbx.kill() - } + // The endAt time should not have been shortened. It should be the same + assert.equal( + infoAfter.endAt.getTime(), + infoBefore.endAt.getTime(), + `Timeout was shortened: before=${infoBefore.endAt.toISOString()}, after=${infoAfter.endAt.toISOString()}` + ) + } finally { + await sbx.kill() } -) +}) -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'connect extends timeout on running sandbox', async ({ sandbox }) => { // Get initial info to check endAt diff --git a/packages/js-sdk/tests/sandbox/connectDebug.test.ts b/packages/js-sdk/tests/sandbox/connectDebug.test.ts new file mode 100644 index 0000000000..8515657f5c --- /dev/null +++ b/packages/js-sdk/tests/sandbox/connectDebug.test.ts @@ -0,0 +1,25 @@ +import { assert, expect, test, vi } from 'vitest' + +import { Sandbox } from '../../src' + +test('connect in debug mode does not call the API', async () => { + const fetchSpy = vi.fn(() => { + throw new Error('unexpected request in debug mode') + }) + vi.stubGlobal('fetch', fetchSpy) + + try { + const sbx = await Sandbox.connect('debug-sandbox-id', { + debug: true, + apiKey: 'test-api-key', + }) + assert.equal(sbx.sandboxId, 'debug-sandbox-id') + + const sameSbx = await sbx.connect() + assert.strictEqual(sameSbx, sbx) + + expect(fetchSpy).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + } +}) diff --git a/packages/js-sdk/tests/sandbox/create.test.ts b/packages/js-sdk/tests/sandbox/create.test.ts index 20d491aed3..942ddfc346 100644 --- a/packages/js-sdk/tests/sandbox/create.test.ts +++ b/packages/js-sdk/tests/sandbox/create.test.ts @@ -1,9 +1,9 @@ -import { assert, expect, test } from 'vitest' +import { assert, expect } from 'vitest' import { Sandbox } from '../../src' -import { template, isDebug } from '../setup.js' +import { hostedTest, template } from '../setup.js' -test.skipIf(isDebug)('create', async () => { +hostedTest('create', async () => { const sbx = await Sandbox.create(template, { timeoutMs: 5_000 }) try { const isRunning = await sbx.isRunning() @@ -15,7 +15,7 @@ test.skipIf(isDebug)('create', async () => { } }) -test.skipIf(isDebug)('metadata', async () => { +hostedTest('metadata', async () => { const metadata = { 'test-key': 'test-value', } @@ -33,37 +33,34 @@ test.skipIf(isDebug)('metadata', async () => { } }) -test.skipIf(isDebug)( - 'MCP gateway start failure kills the created sandbox', - async () => { - const metadata = { mcpGatewayCleanupTestId: crypto.randomUUID() } - const query = { state: ['running' as const], metadata } - let remainingSandboxes: Awaited< - ReturnType['nextItems']> - > = [] +hostedTest('MCP gateway start failure kills the created sandbox', async () => { + const metadata = { mcpGatewayCleanupTestId: crypto.randomUUID() } + const query = { state: ['running' as const], metadata } + let remainingSandboxes: Awaited< + ReturnType['nextItems']> + > = [] - try { - // The base template has no mcp-gateway binary, so gateway startup - // reliably fails after the sandbox has been allocated. - await expect( - Sandbox.create(template, { - timeoutMs: 60_000, - metadata, - mcp: { invalid_server: {} } as never, - }) - ).rejects.toThrow('Failed to start MCP gateway') + try { + // The base template has no mcp-gateway binary, so gateway startup + // reliably fails after the sandbox has been allocated. + await expect( + Sandbox.create(template, { + timeoutMs: 60_000, + metadata, + mcp: { invalid_server: {} } as never, + }) + ).rejects.toThrow('Failed to start MCP gateway') - remainingSandboxes = await Sandbox.list({ query }).nextItems() - expect(remainingSandboxes).toEqual([]) - } finally { - remainingSandboxes = await Sandbox.list({ query }) - .nextItems() - .catch(() => remainingSandboxes) - await Promise.all( - remainingSandboxes.map((sandbox) => - Sandbox.kill(sandbox.sandboxId).catch(() => false) - ) + remainingSandboxes = await Sandbox.list({ query }).nextItems() + expect(remainingSandboxes).toEqual([]) + } finally { + remainingSandboxes = await Sandbox.list({ query }) + .nextItems() + .catch(() => remainingSandboxes) + await Promise.all( + remainingSandboxes.map((sandbox) => + Sandbox.kill(sandbox.sandboxId).catch(() => false) ) - } + ) } -) +}) diff --git a/packages/js-sdk/tests/sandbox/files/entryInfo.test.ts b/packages/js-sdk/tests/sandbox/entryInfo.test.ts similarity index 88% rename from packages/js-sdk/tests/sandbox/files/entryInfo.test.ts rename to packages/js-sdk/tests/sandbox/entryInfo.test.ts index 9d8371051a..43f39c961d 100644 --- a/packages/js-sdk/tests/sandbox/files/entryInfo.test.ts +++ b/packages/js-sdk/tests/sandbox/entryInfo.test.ts @@ -4,8 +4,8 @@ import { expect, test } from 'vitest' import { EntryInfoSchema, FileType as FsFileType, -} from '../../../src/envd/filesystem/filesystem_pb' -import { FileType, mapEntryInfo } from '../../../src/sandbox/filesystem' +} from '../../src/envd/filesystem/filesystem_pb' +import { FileType, mapEntryInfo } from '../../src/sandbox/filesystem' function entry(type: FsFileType, symlinkTarget?: string) { return create(EntryInfoSchema, { diff --git a/packages/js-sdk/tests/sandbox/files/signing.test.ts b/packages/js-sdk/tests/sandbox/files/signing.test.ts index 3f65cdcfa2..49d73ed885 100644 --- a/packages/js-sdk/tests/sandbox/files/signing.test.ts +++ b/packages/js-sdk/tests/sandbox/files/signing.test.ts @@ -1,6 +1,6 @@ import { assert, describe } from 'vitest' -import { sandboxTest, isDebug } from '../../setup' +import { hostedSandboxTest, sandboxTest } from '../../setup' describe('file signing', () => { sandboxTest.override({ @@ -9,7 +9,7 @@ describe('file signing', () => { }, }) - sandboxTest.skipIf(isDebug)( + hostedSandboxTest( 'test access file with expired signing', async ({ sandbox }) => { await sandbox.files.write('hello.txt', 'hello world') @@ -30,7 +30,7 @@ describe('file signing', () => { } ) - sandboxTest.skipIf(isDebug)( + hostedSandboxTest( 'test access file with valid signing', async ({ sandbox }) => { await sandbox.files.write('hello.txt', 'hello world') @@ -48,7 +48,7 @@ describe('file signing', () => { } ) - sandboxTest.skipIf(isDebug)( + hostedSandboxTest( 'test access file with valid signing as root', async ({ sandbox }) => { await sandbox.files.write('hello.txt', 'hello world', { user: 'root' }) @@ -67,7 +67,7 @@ describe('file signing', () => { } ) - sandboxTest.skipIf(isDebug)( + hostedSandboxTest( 'test upload file with valid signing', async ({ sandbox }) => { const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', { @@ -91,7 +91,7 @@ describe('file signing', () => { } ) - sandboxTest.skipIf(isDebug)( + hostedSandboxTest( 'test upload file with valid signing as root user', async ({ sandbox }) => { const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', { @@ -116,7 +116,7 @@ describe('file signing', () => { } ) - sandboxTest.skipIf(isDebug)( + hostedSandboxTest( 'test upload file with invalid signing', async ({ sandbox }) => { const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', { @@ -141,7 +141,7 @@ describe('file signing', () => { } ) - sandboxTest.skipIf(isDebug)( + hostedSandboxTest( 'test command run with secured sbx', async ({ sandbox }) => { const response = await sandbox.commands.run('echo Hello World!') diff --git a/packages/js-sdk/tests/sandbox/fork.test.ts b/packages/js-sdk/tests/sandbox/fork.test.ts index da3d30b9a6..e29355b726 100644 --- a/packages/js-sdk/tests/sandbox/fork.test.ts +++ b/packages/js-sdk/tests/sandbox/fork.test.ts @@ -1,10 +1,10 @@ -import { assert, expect, test } from 'vitest' +import { assert, expect } from 'vitest' -import { sandboxTest, isDebug, TEST_API_KEY } from '../setup.js' +import { hostedSandboxTest, hostedTest } from '../setup.js' import { Sandbox } from '../../src' -import { InvalidArgumentError, SandboxNotFoundError } from '../../src/errors' +import { SandboxNotFoundError } from '../../src/errors' -sandboxTest.skipIf(isDebug)('fork a sandbox', async ({ sandbox }) => { +hostedSandboxTest('fork a sandbox', async ({ sandbox }) => { await sandbox.files.write('/home/user/state.txt', 'state before fork') const forks = await sandbox.fork() @@ -36,33 +36,30 @@ sandboxTest.skipIf(isDebug)('fork a sandbox', async ({ sandbox }) => { } }) -sandboxTest.skipIf(isDebug)( - 'fork a sandbox multiple times', - async ({ sandbox }) => { - const forks = await sandbox.fork({ count: 2, timeoutMs: 60_000 }) - assert.equal(forks.length, 2) +hostedSandboxTest('fork a sandbox multiple times', async ({ sandbox }) => { + const forks = await sandbox.fork({ count: 2, timeoutMs: 60_000 }) + assert.equal(forks.length, 2) - const forkedSandboxes = forks.filter( - (fork): fork is Sandbox => fork instanceof Sandbox - ) + const forkedSandboxes = forks.filter( + (fork): fork is Sandbox => fork instanceof Sandbox + ) - try { - assert.equal(forkedSandboxes.length, 2) + try { + assert.equal(forkedSandboxes.length, 2) - const ids = new Set(forkedSandboxes.map((s) => s.sandboxId)) - assert.equal(ids.size, 2) - assert.isFalse(ids.has(sandbox.sandboxId)) + const ids = new Set(forkedSandboxes.map((s) => s.sandboxId)) + assert.equal(ids.size, 2) + assert.isFalse(ids.has(sandbox.sandboxId)) - for (const fork of forkedSandboxes) { - assert.isTrue(await fork.isRunning()) - } - } finally { - await Promise.all(forkedSandboxes.map((s) => s.kill())) + for (const fork of forkedSandboxes) { + assert.isTrue(await fork.isRunning()) } + } finally { + await Promise.all(forkedSandboxes.map((s) => s.kill())) } -) +}) -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'fork a sandbox by ID with the static method', async ({ sandbox }) => { const forks = await Sandbox.fork(sandbox.sandboxId) @@ -80,15 +77,9 @@ sandboxTest.skipIf(isDebug)( } ) -test.skipIf(isDebug)('fork a killed sandbox fails', async () => { +hostedTest('fork a killed sandbox fails', async () => { const sandbox = await Sandbox.create() await sandbox.kill() await expect(sandbox.fork()).rejects.toThrowError(SandboxNotFoundError) }) - -test('fork with count lower than 1 fails', async () => { - await expect( - Sandbox.fork('sbx-test', { count: 0, apiKey: TEST_API_KEY }) - ).rejects.toThrowError(InvalidArgumentError) -}) diff --git a/packages/js-sdk/tests/sandbox/forkPayload.test.ts b/packages/js-sdk/tests/sandbox/forkPayload.test.ts new file mode 100644 index 0000000000..4dc4bfa4d5 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/forkPayload.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from 'vitest' + +import { InvalidArgumentError } from '../../src/errors' +import { Sandbox } from '../../src' +import { TEST_API_KEY } from '../setup' + +test('fork with count lower than 1 fails', async () => { + await expect( + Sandbox.fork('sbx-test', { count: 0, apiKey: TEST_API_KEY }) + ).rejects.toThrowError(InvalidArgumentError) +}) diff --git a/packages/js-sdk/tests/sandbox/git/validation.test.ts b/packages/js-sdk/tests/sandbox/gitValidation.test.ts similarity index 89% rename from packages/js-sdk/tests/sandbox/git/validation.test.ts rename to packages/js-sdk/tests/sandbox/gitValidation.test.ts index 24bd2a9190..c11480de97 100644 --- a/packages/js-sdk/tests/sandbox/git/validation.test.ts +++ b/packages/js-sdk/tests/sandbox/gitValidation.test.ts @@ -1,8 +1,8 @@ import { test, expect } from 'vitest' -import { Git } from '../../../src/sandbox/git' -import type { Commands } from '../../../src/sandbox/commands' -import { InvalidArgumentError } from '../../../src/errors' +import { Git } from '../../src/sandbox/git' +import type { Commands } from '../../src/sandbox/commands' +import { InvalidArgumentError } from '../../src/errors' // Stub command runner that fails if a git command is actually executed — // validation must throw before reaching it. diff --git a/packages/js-sdk/tests/sandbox/host.test.ts b/packages/js-sdk/tests/sandbox/host.test.ts index 37e45f68c8..22f2f7276f 100644 --- a/packages/js-sdk/tests/sandbox/host.test.ts +++ b/packages/js-sdk/tests/sandbox/host.test.ts @@ -1,6 +1,6 @@ import { assert } from 'vitest' -import { isDebug, sandboxTest, wait } from '../setup.js' +import { hostedSandboxTest, isDebug, sandboxTest, wait } from '../setup.js' import { catchCmdExitErrorInBackground } from '../cmdHelper.js' sandboxTest( 'ping server in running sandbox', @@ -39,25 +39,22 @@ sandboxTest( 60_000 ) -sandboxTest.skipIf(isDebug)( - 'ping server in non-running sandbox', - async ({ sandbox }) => { - const host = sandbox.getHost(3000) - const url = `https://${host}` +hostedSandboxTest('ping server in non-running sandbox', async ({ sandbox }) => { + const host = sandbox.getHost(3000) + const url = `https://${host}` - await sandbox.kill() + await sandbox.kill() - const res = await fetch(url) - assert.equal(res.status, 502) + const res = await fetch(url) + assert.equal(res.status, 502) - const text = await res.text() - const json = JSON.parse(text) as { - message: string - sandboxId: string - code: number - } - assert.equal(json.message, 'The sandbox was not found') - assert.isTrue(sandbox.sandboxId.startsWith(json.sandboxId)) - assert.equal(json.code, 502) + const text = await res.text() + const json = JSON.parse(text) as { + message: string + sandboxId: string + code: number } -) + assert.equal(json.message, 'The sandbox was not found') + assert.isTrue(sandbox.sandboxId.startsWith(json.sandboxId)) + assert.equal(json.code, 502) +}) diff --git a/packages/js-sdk/tests/sandbox/internetAccess.test.ts b/packages/js-sdk/tests/sandbox/internetAccess.test.ts index 7629fbd69b..98e530477b 100644 --- a/packages/js-sdk/tests/sandbox/internetAccess.test.ts +++ b/packages/js-sdk/tests/sandbox/internetAccess.test.ts @@ -1,7 +1,7 @@ import { assert, describe } from 'vitest' import { CommandExitError } from '../../src' -import { sandboxTest, isDebug } from '../setup.js' +import { hostedSandboxTest, sandboxTest } from '../setup.js' describe('internet access enabled', () => { sandboxTest.override({ @@ -10,17 +10,14 @@ describe('internet access enabled', () => { }, }) - sandboxTest.skipIf(isDebug)( - 'internet access enabled', - async ({ sandbox }) => { - // Test internet connectivity by making a curl request to a reliable external site - const result = await sandbox.commands.run( - "curl -s -o /dev/null -w '%{http_code}' https://connectivitycheck.gstatic.com/generate_204" - ) - assert.equal(result.exitCode, 0) - assert.equal(result.stdout.trim(), '204') - } - ) + hostedSandboxTest('internet access enabled', async ({ sandbox }) => { + // Test internet connectivity by making a curl request to a reliable external site + const result = await sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://connectivitycheck.gstatic.com/generate_204" + ) + assert.equal(result.exitCode, 0) + assert.equal(result.stdout.trim(), '204') + }) }) describe('internet access disabled', () => { @@ -30,35 +27,29 @@ describe('internet access disabled', () => { }, }) - sandboxTest.skipIf(isDebug)( - 'internet access disabled', - async ({ sandbox }) => { - // Test that internet connectivity is blocked by making a curl request - try { - await sandbox.commands.run( - 'curl --connect-timeout 3 --max-time 5 -Is https://connectivitycheck.gstatic.com/generate_204' - ) - // If we reach here, the command succeeded, which means internet access is not properly disabled - assert.fail('Expected command to fail when internet access is disabled') - } catch (error) { - // The command should fail or timeout when internet access is disabled - assert.isTrue(error instanceof CommandExitError) - assert.notEqual(error.exitCode, 0) - } + hostedSandboxTest('internet access disabled', async ({ sandbox }) => { + // Test that internet connectivity is blocked by making a curl request + try { + await sandbox.commands.run( + 'curl --connect-timeout 3 --max-time 5 -Is https://connectivitycheck.gstatic.com/generate_204' + ) + // If we reach here, the command succeeded, which means internet access is not properly disabled + assert.fail('Expected command to fail when internet access is disabled') + } catch (error) { + // The command should fail or timeout when internet access is disabled + assert.isTrue(error instanceof CommandExitError) + assert.notEqual(error.exitCode, 0) } - ) + }) }) describe('internet access default', () => { - sandboxTest.skipIf(isDebug)( - 'internet access default', - async ({ sandbox }) => { - // Test internet connectivity by making a curl request to a reliable external site - const result = await sandbox.commands.run( - "curl -s -o /dev/null -w '%{http_code}' https://connectivitycheck.gstatic.com/generate_204" - ) - assert.equal(result.exitCode, 0) - assert.equal(result.stdout.trim(), '204') - } - ) + hostedSandboxTest('internet access default', async ({ sandbox }) => { + // Test internet connectivity by making a curl request to a reliable external site + const result = await sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://connectivitycheck.gstatic.com/generate_204" + ) + assert.equal(result.exitCode, 0) + assert.equal(result.stdout.trim(), '204') + }) }) diff --git a/packages/js-sdk/tests/sandbox/kill.test.ts b/packages/js-sdk/tests/sandbox/kill.test.ts index 6cb9c698b0..f330dd0ddf 100644 --- a/packages/js-sdk/tests/sandbox/kill.test.ts +++ b/packages/js-sdk/tests/sandbox/kill.test.ts @@ -1,9 +1,9 @@ import { expect } from 'vitest' import { Sandbox } from '../../src' -import { sandboxTest, isDebug } from '../setup.js' +import { hostedSandboxTest } from '../setup.js' -sandboxTest.skipIf(isDebug)('kill', async ({ sandbox, sandboxTestId }) => { +hostedSandboxTest('kill', async ({ sandbox, sandboxTestId }) => { const killed = await sandbox.kill() expect(killed).toBe(true) diff --git a/packages/js-sdk/tests/sandbox/lifecycleBehavior.test.ts b/packages/js-sdk/tests/sandbox/lifecycleBehavior.test.ts new file mode 100644 index 0000000000..1a73cec81e --- /dev/null +++ b/packages/js-sdk/tests/sandbox/lifecycleBehavior.test.ts @@ -0,0 +1,108 @@ +import { assert } from 'vitest' + +import { Sandbox } from '../../src' +import { e2eTest, template, wait } from '../setup' + +e2eTest( + 'auto-pause without auto-resume requires connect to wake', + async () => { + const sandbox = await Sandbox.create(template, { + timeoutMs: 3_000, + lifecycle: { + onTimeout: 'pause', + autoResume: false, + }, + }) + + try { + await wait(5_000) + + assert.equal((await sandbox.getInfo()).state, 'paused') + assert.isFalse(await sandbox.isRunning()) + + await sandbox.connect() + + assert.equal((await sandbox.getInfo()).state, 'running') + assert.isTrue(await sandbox.isRunning()) + } finally { + await sandbox.kill().catch(() => {}) + } + }, + 60_000 +) + +e2eTest( + 'filesystem-only auto-pause reboots on connect', + async () => { + // keepMemory:false makes the timeout auto-pause filesystem-only, so resuming + // cold-boots the sandbox from disk. + const sandbox = await Sandbox.create(template, { + timeoutMs: 3_000, + lifecycle: { onTimeout: { action: 'pause', keepMemory: false } }, + }) + + try { + const marker = 'auto-pause-fs-only' + await sandbox.files.write('/home/user/auto-pause-marker.txt', marker) + // Read via a command, not files.read: envd's non-gzip download path + // serves procfs files as an empty 200 (it sizes them by stat, which is + // 0), so clients that don't negotiate gzip — like workerd's fetch — + // silently get '' (infra#3363). + const bootBefore = ( + await sandbox.commands.run('cat /proc/sys/kernel/random/boot_id') + ).stdout.trim() + + await wait(5_000) + + assert.equal((await sandbox.getInfo()).state, 'paused') + + // A filesystem-only snapshot cannot auto-resume on traffic; connect + // resumes it by cold-booting. + await sandbox.connect() + + const persisted = ( + await sandbox.files.read('/home/user/auto-pause-marker.txt') + ).trim() + assert.equal(persisted, marker) + + const bootAfter = ( + await sandbox.commands.run('cat /proc/sys/kernel/random/boot_id') + ).stdout.trim() + assert.notEqual(bootAfter, bootBefore) + } finally { + await sandbox.kill().catch(() => {}) + } + }, + 60_000 +) + +e2eTest( + 'auto-resume wakes paused sandbox on http request', + async () => { + const sandbox = await Sandbox.create(template, { + timeoutMs: 3_000, + lifecycle: { + onTimeout: 'pause', + autoResume: true, + }, + }) + + try { + await sandbox.commands.run('python3 -m http.server 8000', { + background: true, + }) + + await wait(5_000) + + const url = `https://${sandbox.getHost(8000)}` + const res = await fetch(url, { signal: AbortSignal.timeout(15_000) }) + + assert.equal(res.status, 200) + assert.equal((await sandbox.getInfo()).state, 'running') + assert.isTrue(await sandbox.isRunning()) + } finally { + await sandbox.kill().catch(() => {}) + } + }, + 60_000 +) diff --git a/packages/js-sdk/tests/sandbox/lifecyclePayload.test.ts b/packages/js-sdk/tests/sandbox/lifecyclePayload.test.ts index 28a1999b7a..3091fc8a39 100644 --- a/packages/js-sdk/tests/sandbox/lifecyclePayload.test.ts +++ b/packages/js-sdk/tests/sandbox/lifecyclePayload.test.ts @@ -1,143 +1,33 @@ -import { assert, expect, test } from 'vitest' +import { expect, test } from 'vitest' import { InvalidArgumentError, Sandbox } from '../../src' -import { isDebug, template, wait } from '../setup.js' - -test.skipIf(isDebug)( - 'filesystem-only auto-pause cannot be combined with auto-resume', - async () => { - // A filesystem-only auto-pause snapshot can only be resumed explicitly, so - // keepMemory:false with autoResume is rejected client-side. - await expect( - Sandbox.create(template, { - timeoutMs: 3_000, - lifecycle: { - onTimeout: { action: 'pause', keepMemory: false }, - autoResume: true, - }, - }) - ).rejects.toThrowError(InvalidArgumentError) - } -) - -test.skipIf(isDebug)( - 'keepMemory is not allowed when onTimeout action is kill', - async () => { - // The discriminated union forbids keepMemory on `action: 'kill'` at compile - // time (asserted by @ts-expect-error). The runtime guard below additionally - // rejects it for untyped (JS) callers that bypass the type. - await expect( - Sandbox.create(template, { - timeoutMs: 3_000, - lifecycle: { - // @ts-expect-error keepMemory is not allowed with action: 'kill' - onTimeout: { action: 'kill', keepMemory: false }, - }, - }) - ).rejects.toThrowError(InvalidArgumentError) - } -) - -test.skipIf(isDebug)( - 'auto-pause without auto-resume requires connect to wake', - async () => { - const sandbox = await Sandbox.create(template, { - timeoutMs: 3_000, +import { TEST_API_KEY, template } from '../setup' + +test('filesystem-only auto-pause cannot be combined with auto-resume', async () => { + // A filesystem-only auto-pause snapshot can only be resumed explicitly, so + // keepMemory:false with autoResume is rejected client-side. + await expect( + Sandbox.create(template, { + apiKey: TEST_API_KEY, lifecycle: { - onTimeout: 'pause', - autoResume: false, + onTimeout: { action: 'pause', keepMemory: false }, + autoResume: true, }, }) - - try { - await wait(5_000) - - assert.equal((await sandbox.getInfo()).state, 'paused') - assert.isFalse(await sandbox.isRunning()) - - await sandbox.connect() - - assert.equal((await sandbox.getInfo()).state, 'running') - assert.isTrue(await sandbox.isRunning()) - } finally { - await sandbox.kill().catch(() => {}) - } - }, - 60_000 -) - -test.skipIf(isDebug)( - 'filesystem-only auto-pause reboots on connect', - async () => { - // keepMemory:false makes the timeout auto-pause filesystem-only, so resuming - // cold-boots the sandbox from disk. - const sandbox = await Sandbox.create(template, { - timeoutMs: 3_000, - lifecycle: { onTimeout: { action: 'pause', keepMemory: false } }, - }) - - try { - const marker = 'auto-pause-fs-only' - await sandbox.files.write('/home/user/auto-pause-marker.txt', marker) - // Read via a command, not files.read: envd's non-gzip download path - // serves procfs files as an empty 200 (it sizes them by stat, which is - // 0), so clients that don't negotiate gzip — like workerd's fetch — - // silently get '' (infra#3363). - const bootBefore = ( - await sandbox.commands.run('cat /proc/sys/kernel/random/boot_id') - ).stdout.trim() - - await wait(5_000) - - assert.equal((await sandbox.getInfo()).state, 'paused') - - // A filesystem-only snapshot cannot auto-resume on traffic; connect - // resumes it by cold-booting. - await sandbox.connect() - - const persisted = ( - await sandbox.files.read('/home/user/auto-pause-marker.txt') - ).trim() - assert.equal(persisted, marker) - - const bootAfter = ( - await sandbox.commands.run('cat /proc/sys/kernel/random/boot_id') - ).stdout.trim() - assert.notEqual(bootAfter, bootBefore) - } finally { - await sandbox.kill().catch(() => {}) - } - }, - 60_000 -) - -test.skipIf(isDebug)( - 'auto-resume wakes paused sandbox on http request', - async () => { - const sandbox = await Sandbox.create(template, { - timeoutMs: 3_000, + ).rejects.toThrowError(InvalidArgumentError) +}) + +test('keepMemory is not allowed when onTimeout action is kill', async () => { + // The discriminated union forbids keepMemory on `action: 'kill'` at compile + // time (asserted by @ts-expect-error). The runtime guard below additionally + // rejects it for untyped (JS) callers that bypass the type. + await expect( + Sandbox.create(template, { + apiKey: TEST_API_KEY, lifecycle: { - onTimeout: 'pause', - autoResume: true, + // @ts-expect-error keepMemory is not allowed with action: 'kill' + onTimeout: { action: 'kill', keepMemory: false }, }, }) - - try { - await sandbox.commands.run('python3 -m http.server 8000', { - background: true, - }) - - await wait(5_000) - - const url = `https://${sandbox.getHost(8000)}` - const res = await fetch(url, { signal: AbortSignal.timeout(15_000) }) - - assert.equal(res.status, 200) - assert.equal((await sandbox.getInfo()).state, 'running') - assert.isTrue(await sandbox.isRunning()) - } finally { - await sandbox.kill().catch(() => {}) - } - }, - 60_000 -) + ).rejects.toThrowError(InvalidArgumentError) +}) diff --git a/packages/js-sdk/tests/sandbox/metrics.test.ts b/packages/js-sdk/tests/sandbox/metrics.test.ts index d7dbca551b..4fe23614c8 100644 --- a/packages/js-sdk/tests/sandbox/metrics.test.ts +++ b/packages/js-sdk/tests/sandbox/metrics.test.ts @@ -1,34 +1,30 @@ import { expect } from 'vitest' import { SandboxMetrics } from '../../src' -import { sandboxTest, isDebug, wait } from '../setup.js' +import { hostedSandboxTest, wait } from '../setup.js' -sandboxTest.skipIf(isDebug)( - 'sbx metrics', - { timeout: 60_000 }, - async ({ sandbox }) => { - // Wait for the sandbox to have some metrics - let metrics: SandboxMetrics[] = [] - for (let i = 0; i < 60; i++) { - metrics = await sandbox.getMetrics() - if (metrics.length > 0) { - break - } - await wait(500) +hostedSandboxTest('sbx metrics', { timeout: 60_000 }, async ({ sandbox }) => { + // Wait for the sandbox to have some metrics + let metrics: SandboxMetrics[] = [] + for (let i = 0; i < 60; i++) { + metrics = await sandbox.getMetrics() + if (metrics.length > 0) { + break } - - expect(metrics.length).toBeGreaterThan(0) - const metric = metrics[0] - expect(metric.diskTotal).toBeDefined() - expect(metric.diskUsed).toBeDefined() - expect(metric.memTotal).toBeDefined() - expect(metric.memUsed).toBeDefined() - expect(metric.cpuUsedPct).toBeDefined() - expect(metric.cpuCount).toBeDefined() + await wait(500) } -) -sandboxTest.skipIf(isDebug)( + expect(metrics.length).toBeGreaterThan(0) + const metric = metrics[0] + expect(metric.diskTotal).toBeDefined() + expect(metric.diskUsed).toBeDefined() + expect(metric.memTotal).toBeDefined() + expect(metric.memUsed).toBeDefined() + expect(metric.cpuUsedPct).toBeDefined() + expect(metric.cpuCount).toBeDefined() +}) + +hostedSandboxTest( 'sbx metrics time range', { timeout: 60_000 }, async ({ sandbox }) => { diff --git a/packages/js-sdk/tests/sandbox/network.test.ts b/packages/js-sdk/tests/sandbox/network.test.ts index 82a8f20003..2e06823ac5 100644 --- a/packages/js-sdk/tests/sandbox/network.test.ts +++ b/packages/js-sdk/tests/sandbox/network.test.ts @@ -1,7 +1,7 @@ import { assert, expect, describe } from 'vitest' import { CommandExitError, Sandbox } from '../../src' -import { sandboxTest, isDebug, template } from '../setup.js' +import { hostedSandboxTest, sandboxTest, template } from '../setup.js' import { httpbinTemplate } from '../template.js' describe('allow only 1.1.1.1', () => { @@ -14,7 +14,7 @@ describe('allow only 1.1.1.1', () => { }, }) - sandboxTest.skipIf(isDebug)( + hostedSandboxTest( 'allow specific IP with deny all traffic', async ({ sandbox }) => { // Test that allowed IP works @@ -43,24 +43,21 @@ describe('deny specific IP address', () => { }, }) - sandboxTest.skipIf(isDebug)( - 'deny specific IP address', - async ({ sandbox }) => { - // Test that denied IP fails - await expect( - sandbox.commands.run( - 'curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8' - ) - ).rejects.toBeInstanceOf(CommandExitError) - - // Test that other IPs work - const result = await sandbox.commands.run( - "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + hostedSandboxTest('deny specific IP address', async ({ sandbox }) => { + // Test that denied IP fails + await expect( + sandbox.commands.run( + 'curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8' ) - assert.equal(result.exitCode, 0) - assert.equal(result.stdout.trim(), '301') - } - ) + ).rejects.toBeInstanceOf(CommandExitError) + + // Test that other IPs work + const result = await sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert.equal(result.exitCode, 0) + assert.equal(result.stdout.trim(), '301') + }) }) describe('deny all traffic using allTraffic selector', () => { @@ -72,7 +69,7 @@ describe('deny all traffic using allTraffic selector', () => { }, }) - sandboxTest.skipIf(isDebug)( + hostedSandboxTest( 'deny all traffic using allTraffic selector', async ({ sandbox }) => { // Test that all traffic is denied @@ -101,24 +98,21 @@ describe('allow takes precedence over deny', () => { }, }) - sandboxTest.skipIf(isDebug)( - 'allow takes precedence over deny', - async ({ sandbox }) => { - // Test that 1.1.1.1 works (explicitly allowed) - const result1 = await sandbox.commands.run( - "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" - ) - assert.equal(result1.exitCode, 0) - assert.equal(result1.stdout.trim(), '301') - - // Test that 8.8.8.8 also works (explicitly allowed, takes precedence over denyOut) - const result2 = await sandbox.commands.run( - "curl -s -o /dev/null -w '%{http_code}' https://8.8.8.8" - ) - assert.equal(result2.exitCode, 0) - assert.equal(result2.stdout.trim(), '302') - } - ) + hostedSandboxTest('allow takes precedence over deny', async ({ sandbox }) => { + // Test that 1.1.1.1 works (explicitly allowed) + const result1 = await sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1" + ) + assert.equal(result1.exitCode, 0) + assert.equal(result1.stdout.trim(), '301') + + // Test that 8.8.8.8 also works (explicitly allowed, takes precedence over denyOut) + const result2 = await sandbox.commands.run( + "curl -s -o /dev/null -w '%{http_code}' https://8.8.8.8" + ) + assert.equal(result2.exitCode, 0) + assert.equal(result2.stdout.trim(), '302') + }) }) describe('allowPublicTraffic=false', () => { @@ -130,7 +124,7 @@ describe('allowPublicTraffic=false', () => { }, }) - sandboxTest.skipIf(isDebug)( + hostedSandboxTest( 'sandbox requires traffic access token', async ({ sandbox }) => { // Verify the sandbox was created successfully and has a traffic access token @@ -172,26 +166,23 @@ describe('allowPublicTraffic=true', () => { }, }) - sandboxTest.skipIf(isDebug)( - 'sandbox works without token', - async ({ sandbox }) => { - // Start a simple HTTP server in the sandbox - const port = 8080 - sandbox.commands.run(`python3 -m http.server ${port}`, { - background: true, - }) + hostedSandboxTest('sandbox works without token', async ({ sandbox }) => { + // Start a simple HTTP server in the sandbox + const port = 8080 + sandbox.commands.run(`python3 -m http.server ${port}`, { + background: true, + }) - // Wait for server to start - await new Promise((resolve) => setTimeout(resolve, 3000)) + // Wait for server to start + await new Promise((resolve) => setTimeout(resolve, 3000)) - // Get the public URL for the sandbox - const sandboxUrl = `https://${sandbox.getHost(port)}` + // Get the public URL for the sandbox + const sandboxUrl = `https://${sandbox.getHost(port)}` - // Request without traffic access token should succeed (public access enabled) - const response = await fetch(sandboxUrl) - assert.equal(response.status, 200) - } - ) + // Request without traffic access token should succeed (public access enabled) + const response = await fetch(sandboxUrl) + assert.equal(response.status, 200) + }) }) describe('firewall transform injects headers', () => { @@ -200,7 +191,7 @@ describe('firewall transform injects headers', () => { // Port the httpbin template's start command listens on. const httpbinPort = 8080 - sandboxTest.skipIf(isDebug)( + hostedSandboxTest( 'injected header is reflected by the httpbin sidecar', async ({ sandboxTestId }) => { // The transform is applied by the egress proxy on the way out of the @@ -257,7 +248,7 @@ describe('firewall transform injects headers', () => { }) describe('updateNetwork applies new egress rules', () => { - sandboxTest.skipIf(isDebug)( + hostedSandboxTest( 'denies a previously reachable IP after update', async ({ sandbox }) => { // Baseline: 8.8.8.8 is reachable. @@ -294,7 +285,7 @@ describe('updateNetwork clears existing rules when fields are omitted', () => { }, }) - sandboxTest.skipIf(isDebug)( + hostedSandboxTest( 'omitting fields replaces all egress rules', async ({ sandbox }) => { // Baseline from create-time config: 8.8.8.8 denied. @@ -329,7 +320,7 @@ describe('maskRequestHost option', () => { }, }) - sandboxTest.skipIf(isDebug)( + hostedSandboxTest( 'verify maskRequestHost modifies Host header correctly', async ({ sandbox }) => { const port = 8080 diff --git a/packages/js-sdk/tests/sandbox/readFormat.test.ts b/packages/js-sdk/tests/sandbox/readFormat.test.ts new file mode 100644 index 0000000000..7d1fca1fcc --- /dev/null +++ b/packages/js-sdk/tests/sandbox/readFormat.test.ts @@ -0,0 +1,148 @@ +import { afterAll, afterEach, assert, beforeAll, expect, test } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' + +import { ConnectionConfig, Sandbox } from '../../src' +import { ENVD_DEBUG_FALLBACK, ENVD_DEFAULT_USER } from '../../src/envd/versions' +import { FileNotFoundError } from '../../src/errors' +import { belowEnvdVersion, TEST_API_KEY } from '../setup' + +const sandboxId = 'sbx-read-format' +const envdUrl = `https://49983-${sandboxId}.sandbox.e2b.dev` + +let lastQuery: URLSearchParams | undefined + +const server = setupServer( + http.get(`${envdUrl}/files`, ({ request }) => { + lastQuery = new URL(request.url).searchParams + return HttpResponse.text('hello world') + }) +) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterAll(() => server.close()) +afterEach(() => { + lastQuery = undefined + server.resetHandlers() +}) + +function sandbox(envdVersion = ENVD_DEBUG_FALLBACK): Sandbox { + const config = new ConnectionConfig({ apiKey: TEST_API_KEY }) + return new Sandbox({ + ...config, + sandboxId, + sandboxDomain: 'sandbox.e2b.dev', + envdVersion, + envdAccessToken: 'token', + }) +} + +test('reads text by default', async () => { + const content = await sandbox().files.read('/home/user/hello.txt') + + assert.equal(content, 'hello world') + assert.equal(lastQuery?.get('path'), '/home/user/hello.txt') +}) + +test('reads bytes as Uint8Array', async () => { + const content = await sandbox().files.read('/home/user/hello.txt', { + format: 'bytes', + }) + + assert.instanceOf(content, Uint8Array) + assert.equal(new TextDecoder().decode(content), 'hello world') +}) + +test('reads a blob', async () => { + const content = await sandbox().files.read('/home/user/hello.txt', { + format: 'blob', + }) + + assert.instanceOf(content, Blob) + assert.equal(await content.text(), 'hello world') +}) + +test('reads a stream', async () => { + const content = await sandbox().files.read('/home/user/hello.txt', { + format: 'stream', + }) + + assert.instanceOf(content, ReadableStream) + + const chunks: Uint8Array[] = [] + for await (const chunk of content as unknown as AsyncIterable) { + chunks.push(chunk) + } + assert.equal( + new TextDecoder().decode( + new Uint8Array(chunks.flatMap((chunk) => Array.from(chunk))) + ), + 'hello world' + ) +}) + +test('sends the default username below ENVD_DEFAULT_USER', async () => { + await sandbox(belowEnvdVersion(ENVD_DEFAULT_USER)).files.read( + '/home/user/hello.txt' + ) + + assert.equal(lastQuery?.get('username'), 'user') +}) + +test('omits the username on newer envd', async () => { + await sandbox(ENVD_DEFAULT_USER).files.read('/home/user/hello.txt') + + assert.equal(lastQuery?.get('username'), null) +}) + +test('requests gzip when asked', async () => { + let acceptEncoding: string | null = null + server.use( + http.get(`${envdUrl}/files`, ({ request }) => { + acceptEncoding = request.headers.get('accept-encoding') + return HttpResponse.text('hello world') + }) + ) + + await sandbox().files.read('/home/user/hello.txt', { gzip: true }) + + assert.equal(acceptEncoding, 'gzip') +}) + +test('returns an empty value per format for an empty file', async () => { + server.use( + http.get(`${envdUrl}/files`, () => + HttpResponse.text('', { headers: { 'content-length': '0' } }) + ) + ) + + const files = sandbox().files + assert.equal(await files.read('/home/user/empty.txt'), '') + assert.deepEqual( + await files.read('/home/user/empty.txt', { format: 'bytes' }), + new Uint8Array(0) + ) + assert.equal( + await (await files.read('/home/user/empty.txt', { format: 'blob' })).text(), + '' + ) +}) + +test('maps an envd 404 to FileNotFoundError for every format', async () => { + server.use( + http.get(`${envdUrl}/files`, () => + HttpResponse.json( + { code: 404, message: 'file not found' }, + { status: 404 } + ) + ) + ) + + const files = sandbox().files + await expect(files.read('/home/user/missing.txt')).rejects.toThrowError( + FileNotFoundError + ) + await expect( + files.read('/home/user/missing.txt', { format: 'stream' }) + ).rejects.toThrowError(FileNotFoundError) +}) diff --git a/packages/js-sdk/tests/sandbox/secure.test.ts b/packages/js-sdk/tests/sandbox/secure.test.ts index 90a8712941..d4929818ef 100644 --- a/packages/js-sdk/tests/sandbox/secure.test.ts +++ b/packages/js-sdk/tests/sandbox/secure.test.ts @@ -1,7 +1,6 @@ -import { assert, test, describe } from 'vitest' -import { getSignature, Sandbox } from '../../src' -import { sandboxTest, isDebug } from '../setup' -import { randomUUID, createHash } from 'node:crypto' +import { assert, describe } from 'vitest' +import { Sandbox } from '../../src' +import { hostedSandboxTest, sandboxTest } from '../setup' describe('secure sandbox', () => { sandboxTest.override({ @@ -10,106 +9,22 @@ describe('secure sandbox', () => { }, }) - sandboxTest.skipIf(isDebug)( - 'test access file with signing', - async ({ sandbox }) => { - await sandbox.files.write('hello.txt', 'hello world') + hostedSandboxTest('test access file with signing', async ({ sandbox }) => { + await sandbox.files.write('hello.txt', 'hello world') - const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt') + const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt') - const res = await fetch(fileUrlWithSigning) - const resBody = await res.text() - const resStatus = res.status + const res = await fetch(fileUrlWithSigning) + const resBody = await res.text() + const resStatus = res.status - assert.equal(resStatus, 200) - assert.equal(resBody, 'hello world') - } - ) - - sandboxTest.skipIf(isDebug)( - 'try to re-connect to sandbox', - async ({ sandbox }) => { - const sbxReconnect = await Sandbox.connect(sandbox.sandboxId) - - await sbxReconnect.files.write('hello.txt', 'hello world') - } - ) -}) - -test.skipIf(isDebug)('signing generation', async () => { - const operation = 'read' - const path = '/home/user/hello.txt' - const user = 'root' - const envdAccessToken = randomUUID() - - const signatureRaw = `${path}:${operation}:${user}:${envdAccessToken}` - - const buff = Buffer.from(signatureRaw, 'utf8') - const hash = createHash('sha256').update(buff).digest() - const signature = 'v1_' + hash.toString('base64').replace(/=+$/, '') - - const readSignatureExpected = { - signature: signature, - expiration: null, - } - - const readSignatureReceived = await getSignature({ - path, - operation, - user, - envdAccessToken, + assert.equal(resStatus, 200) + assert.equal(resBody, 'hello world') }) - assert.deepEqual(readSignatureExpected, readSignatureReceived) -}) - -test.skipIf(isDebug)('signing generation with expiration', async () => { - const operation = 'read' - const path = '/home/user/hello.txt' - const user = 'root' - const envdAccessToken = randomUUID() - const expirationInSeconds = 120 + hostedSandboxTest('try to re-connect to sandbox', async ({ sandbox }) => { + const sbxReconnect = await Sandbox.connect(sandbox.sandboxId) - const signatureExpiration = expirationInSeconds - ? Math.floor(Date.now() / 1000) + expirationInSeconds - : null - const signatureRaw = `${path}:${operation}:${user}:${envdAccessToken}:${signatureExpiration?.toString()}` - - const buff = Buffer.from(signatureRaw, 'utf8') - const hash = createHash('sha256').update(buff).digest() - const signature = 'v1_' + hash.toString('base64').replace(/=+$/, '') - - const readSignatureExpected = { - signature: signature, - expiration: signatureExpiration, - } - - const readSignatureReceived = await getSignature({ - path, - operation, - user, - envdAccessToken, - expirationInSeconds, + await sbxReconnect.files.write('hello.txt', 'hello world') }) - - assert.deepEqual(readSignatureExpected, readSignatureReceived) -}) - -test.skipIf(isDebug)('static signing key comparison', async () => { - const operation = 'read' - const path = 'hello.txt' - const user = 'user' - const envdAccessToken = '0tQG31xiMp0IOQfaz9dcwi72L1CPo8e0' - - const signatureReceived = await getSignature({ - path, - operation, - user, - envdAccessToken, - }) - - assert.equal( - 'v1_gUtH/s9YCJWgCizjfUxuWfhFE4QSydOWEIIvfLwDr6E', - signatureReceived.signature - ) }) diff --git a/packages/js-sdk/tests/sandbox/secureSignature.test.ts b/packages/js-sdk/tests/sandbox/secureSignature.test.ts new file mode 100644 index 0000000000..64b353ec1e --- /dev/null +++ b/packages/js-sdk/tests/sandbox/secureSignature.test.ts @@ -0,0 +1,87 @@ +import { assert, test } from 'vitest' +import { createHash, randomUUID } from 'node:crypto' + +import { getSignature } from '../../src' + +/** + * `getSignature` derives the signature locally from the path, operation, user + * and envd access token — no sandbox involved, so this stays in the unit tier + * next to the e2e `secure.test.ts`. + */ + +test('signing generation', async () => { + const operation = 'read' + const path = '/home/user/hello.txt' + const user = 'root' + const envdAccessToken = randomUUID() + + const signatureRaw = `${path}:${operation}:${user}:${envdAccessToken}` + + const buff = Buffer.from(signatureRaw, 'utf8') + const hash = createHash('sha256').update(buff).digest() + const signature = 'v1_' + hash.toString('base64').replace(/=+$/, '') + + const readSignatureExpected = { + signature: signature, + expiration: null, + } + + const readSignatureReceived = await getSignature({ + path, + operation, + user, + envdAccessToken, + }) + + assert.deepEqual(readSignatureExpected, readSignatureReceived) +}) + +test('signing generation with expiration', async () => { + const operation = 'read' + const path = '/home/user/hello.txt' + const user = 'root' + const envdAccessToken = randomUUID() + const expirationInSeconds = 120 + + const signatureExpiration = + Math.floor(Date.now() / 1000) + expirationInSeconds + const signatureRaw = `${path}:${operation}:${user}:${envdAccessToken}:${signatureExpiration.toString()}` + + const buff = Buffer.from(signatureRaw, 'utf8') + const hash = createHash('sha256').update(buff).digest() + const signature = 'v1_' + hash.toString('base64').replace(/=+$/, '') + + const readSignatureExpected = { + signature: signature, + expiration: signatureExpiration, + } + + const readSignatureReceived = await getSignature({ + path, + operation, + user, + envdAccessToken, + expirationInSeconds, + }) + + assert.deepEqual(readSignatureExpected, readSignatureReceived) +}) + +test('static signing key comparison', async () => { + const operation = 'read' + const path = 'hello.txt' + const user = 'user' + const envdAccessToken = '0tQG31xiMp0IOQfaz9dcwi72L1CPo8e0' + + const signatureReceived = await getSignature({ + path, + operation, + user, + envdAccessToken, + }) + + assert.equal( + 'v1_gUtH/s9YCJWgCizjfUxuWfhFE4QSydOWEIIvfLwDr6E', + signatureReceived.signature + ) +}) diff --git a/packages/js-sdk/tests/sandbox/snapshot-api.test.ts b/packages/js-sdk/tests/sandbox/snapshot-api.test.ts index 3c6432af65..f3bb1eab32 100644 --- a/packages/js-sdk/tests/sandbox/snapshot-api.test.ts +++ b/packages/js-sdk/tests/sandbox/snapshot-api.test.ts @@ -1,26 +1,23 @@ import { assert } from 'vitest' -import { sandboxTest, isDebug } from '../setup.js' +import { hostedSandboxTest } from '../setup.js' import { Sandbox } from '../../src' -sandboxTest.skipIf(isDebug)( - 'create a snapshot from sandbox', - async ({ sandbox }) => { - // Write a file to the sandbox - await sandbox.files.write('/home/user/test.txt', 'snapshot test content') +hostedSandboxTest('create a snapshot from sandbox', async ({ sandbox }) => { + // Write a file to the sandbox + await sandbox.files.write('/home/user/test.txt', 'snapshot test content') - // Create a snapshot - const snapshot = await sandbox.createSnapshot() + // Create a snapshot + const snapshot = await sandbox.createSnapshot() - assert.isString(snapshot.snapshotId) - assert.isTrue(snapshot.snapshotId.length > 0) + assert.isString(snapshot.snapshotId) + assert.isTrue(snapshot.snapshotId.length > 0) - // Cleanup - await Sandbox.deleteSnapshot(snapshot.snapshotId) - } -) + // Cleanup + await Sandbox.deleteSnapshot(snapshot.snapshotId) +}) -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'create sandbox from snapshot', async ({ sandbox, sandboxTestId }) => { const testContent = 'content from original sandbox' @@ -50,7 +47,7 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'create multiple sandboxes from same snapshot', async ({ sandbox, sandboxTestId }) => { const testContent = 'shared snapshot content' @@ -101,7 +98,7 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)('list snapshots', async ({ sandbox }) => { +hostedSandboxTest('list snapshots', async ({ sandbox }) => { // Create a snapshot const snapshot = await sandbox.createSnapshot() @@ -121,7 +118,7 @@ sandboxTest.skipIf(isDebug)('list snapshots', async ({ sandbox }) => { } }) -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'list snapshots for specific sandbox', async ({ sandbox }) => { // Create a snapshot @@ -141,7 +138,7 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'create a named snapshot', async ({ sandbox, sandboxTestId }) => { const snapshotName = `snap-${sandboxTestId}` @@ -159,7 +156,7 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'list snapshots filtered by name', async ({ sandbox, sandboxTestId }) => { const snapshotName = `snap-filter-${sandboxTestId}` @@ -187,7 +184,7 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)('delete snapshot', async ({ sandbox }) => { +hostedSandboxTest('delete snapshot', async ({ sandbox }) => { const snapshot = await sandbox.createSnapshot() // Delete should succeed @@ -199,7 +196,7 @@ sandboxTest.skipIf(isDebug)('delete snapshot', async ({ sandbox }) => { assert.isFalse(deletedAgain) }) -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'snapshot preserves file system state', async ({ sandbox, sandboxTestId }) => { const appDir = '/home/user/app' diff --git a/packages/js-sdk/tests/sandbox/snapshot.test.ts b/packages/js-sdk/tests/sandbox/snapshot.test.ts index ac0dead40d..3ded106767 100644 --- a/packages/js-sdk/tests/sandbox/snapshot.test.ts +++ b/packages/js-sdk/tests/sandbox/snapshot.test.ts @@ -1,22 +1,19 @@ import { assert, describe } from 'vitest' -import { sandboxTest, isDebug } from '../setup.js' +import { hostedSandboxTest, sandboxTest } from '../setup.js' -sandboxTest.skipIf(isDebug)( - 'pause and resume a sandbox', - async ({ sandbox }) => { - assert.isTrue(await sandbox.isRunning()) +hostedSandboxTest('pause and resume a sandbox', async ({ sandbox }) => { + assert.isTrue(await sandbox.isRunning()) - await sandbox.pause() + await sandbox.pause() - assert.isFalse(await sandbox.isRunning()) + assert.isFalse(await sandbox.isRunning()) - const resumedSandbox = await sandbox.connect() - assert.equal(resumedSandbox.sandboxId, sandbox.sandboxId) + const resumedSandbox = await sandbox.connect() + assert.equal(resumedSandbox.sandboxId, sandbox.sandboxId) - assert.isTrue(await sandbox.isRunning()) - } -) + assert.isTrue(await sandbox.isRunning()) +}) describe('pause and resume with env vars', () => { sandboxTest.override({ @@ -25,7 +22,7 @@ describe('pause and resume with env vars', () => { }, }) - sandboxTest.skipIf(isDebug)( + hostedSandboxTest( 'pause and resume a sandbox with env vars', async ({ sandbox }) => { // Environment variables of a process exist at runtime, and are not stored in some file or so. @@ -52,7 +49,7 @@ describe('pause and resume with env vars', () => { ) }) -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'pause and resume a sandbox with file', async ({ sandbox }) => { const filename = 'test_snapshot.txt' @@ -81,7 +78,7 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'pause and resume a sandbox with ongoing long running process', async ({ sandbox }) => { const cmd = await sandbox.commands.run('sleep 3600', { background: true }) @@ -105,7 +102,7 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'pause and resume a sandbox with completed long running process', async ({ sandbox }) => { const filename = 'test_long_running.txt' @@ -137,7 +134,7 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'pause and resume a sandbox with http server', async ({ sandbox }) => { await sandbox.commands.run('python3 -m http.server 8000', { @@ -163,7 +160,7 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( +hostedSandboxTest( 'filesystem-only pause reboots on resume but keeps the filesystem', async ({ sandbox }) => { // Absolute path: a cold boot may not restore the template's default diff --git a/packages/js-sdk/tests/sandbox/timeout.test.ts b/packages/js-sdk/tests/sandbox/timeout.test.ts index 249666ada3..33390e5e43 100644 --- a/packages/js-sdk/tests/sandbox/timeout.test.ts +++ b/packages/js-sdk/tests/sandbox/timeout.test.ts @@ -1,8 +1,8 @@ import { expect } from 'vitest' -import { sandboxTest, isDebug, wait } from '../setup.js' +import { hostedSandboxTest, wait } from '../setup.js' -sandboxTest.skipIf(isDebug)('shorten timeout', async ({ sandbox }) => { +hostedSandboxTest('shorten timeout', async ({ sandbox }) => { await sandbox.setTimeout(5000) await wait(6000) @@ -10,22 +10,19 @@ sandboxTest.skipIf(isDebug)('shorten timeout', async ({ sandbox }) => { expect(await sandbox.isRunning()).toBeFalsy() }) -sandboxTest.skipIf(isDebug)( - 'shorten then lengthen timeout', - async ({ sandbox }) => { - await sandbox.setTimeout(5000) +hostedSandboxTest('shorten then lengthen timeout', async ({ sandbox }) => { + await sandbox.setTimeout(5000) - await wait(1000) + await wait(1000) - await sandbox.setTimeout(10000) + await sandbox.setTimeout(10000) - await wait(6000) + await wait(6000) - expect(await sandbox.isRunning()).toBeTruthy() - } -) + expect(await sandbox.isRunning()).toBeTruthy() +}) -sandboxTest.skipIf(isDebug)('get sandbox timeout', async ({ sandbox }) => { +hostedSandboxTest('get sandbox timeout', async ({ sandbox }) => { const { endAt } = await sandbox.getInfo() expect(endAt).toBeInstanceOf(Date) }) diff --git a/packages/js-sdk/tests/sandbox/uploadMode.test.ts b/packages/js-sdk/tests/sandbox/uploadMode.test.ts new file mode 100644 index 0000000000..ac5875a0b8 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/uploadMode.test.ts @@ -0,0 +1,154 @@ +import { afterAll, afterEach, assert, beforeAll, expect, test } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' + +import { ConnectionConfig, Sandbox } from '../../src' +import { + ENVD_DEBUG_FALLBACK, + ENVD_FILE_METADATA, + ENVD_OCTET_STREAM_UPLOAD, +} from '../../src/envd/versions' +import { TemplateError } from '../../src/errors' +import { belowEnvdVersion, TEST_API_KEY } from '../setup' + +const sandboxId = 'sbx-upload-mode' +const envdUrl = `https://49983-${sandboxId}.sandbox.e2b.dev` + +interface CapturedUpload { + contentType: string | null + contentEncoding: string | null + metadataHeaders: Record + body: string +} + +let uploads: CapturedUpload[] = [] + +const server = setupServer( + http.post(`${envdUrl}/files`, async ({ request }) => { + const metadataHeaders: Record = {} + request.headers.forEach((value, key) => { + if (key.toLowerCase().startsWith('x-metadata-')) { + metadataHeaders[key.toLowerCase()] = value + } + }) + uploads.push({ + contentType: request.headers.get('content-type'), + contentEncoding: request.headers.get('content-encoding'), + metadataHeaders, + body: await request.text(), + }) + return HttpResponse.json([ + { name: 'hello.txt', type: 'file', path: '/home/user/hello.txt' }, + ]) + }) +) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterAll(() => server.close()) +afterEach(() => { + uploads = [] + server.resetHandlers() +}) + +function sandbox(envdVersion = ENVD_DEBUG_FALLBACK): Sandbox { + const config = new ConnectionConfig({ apiKey: TEST_API_KEY }) + return new Sandbox({ + ...config, + sandboxId, + sandboxDomain: 'sandbox.e2b.dev', + envdVersion, + envdAccessToken: 'token', + }) +} + +test('uploads as multipart by default', async () => { + await sandbox().files.write('/home/user/hello.txt', 'hello world') + + assert.include(uploads[0].contentType ?? '', 'multipart/form-data') +}) + +test('uploads as octet-stream when asked', async () => { + await sandbox().files.write('/home/user/hello.txt', 'hello world', { + useOctetStream: true, + }) + + assert.equal(uploads[0].contentType, 'application/octet-stream') + assert.equal(uploads[0].body, 'hello world') +}) + +test('falls back to multipart below ENVD_OCTET_STREAM_UPLOAD', async () => { + await sandbox(belowEnvdVersion(ENVD_OCTET_STREAM_UPLOAD)).files.write( + '/home/user/hello.txt', + 'hello world', + { useOctetStream: true } + ) + + assert.include(uploads[0].contentType ?? '', 'multipart/form-data') +}) + +test('a stream body implies octet-stream', async () => { + const data = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('streamed')) + controller.close() + }, + }) + + await sandbox().files.write('/home/user/hello.txt', data) + + assert.equal(uploads[0].contentType, 'application/octet-stream') + assert.equal(uploads[0].body, 'streamed') +}) + +test('gzip implies octet-stream and sets Content-Encoding', async () => { + await sandbox().files.write('/home/user/hello.txt', 'hello world', { + gzip: true, + }) + + assert.equal(uploads[0].contentType, 'application/octet-stream') + assert.equal(uploads[0].contentEncoding, 'gzip') +}) + +test('sends metadata as request headers', async () => { + await sandbox().files.write('/home/user/hello.txt', 'hello world', { + metadata: { origin: 'unit-test' }, + }) + + assert.equal(uploads[0].metadataHeaders['x-metadata-origin'], 'unit-test') +}) + +// TODO: the gate should reject with InvalidArgumentError — this is +// argument validation on `sandbox.files`, not a template build. +test('rejects metadata below ENVD_FILE_METADATA', async () => { + await expect( + sandbox(belowEnvdVersion(ENVD_FILE_METADATA)).files.write( + '/home/user/hello.txt', + 'hello world', + { metadata: { origin: 'unit-test' } } + ) + ).rejects.toThrowError(TemplateError) + assert.lengthOf(uploads, 0) +}) + +test('uploads every entry of a multi-file octet-stream write', async () => { + await sandbox().files.write( + [ + { path: '/home/user/a.txt', data: 'a' }, + { path: '/home/user/b.txt', data: 'b' }, + ], + { useOctetStream: true } + ) + + assert.lengthOf(uploads, 2) + assert.deepEqual(uploads.map((upload) => upload.body).sort(), ['a', 'b']) +}) + +test('sends a single multipart request for a multi-file write', async () => { + await sandbox().files.write([ + { path: '/home/user/a.txt', data: 'a' }, + { path: '/home/user/b.txt', data: 'b' }, + ]) + + assert.lengthOf(uploads, 1) + assert.include(uploads[0].contentType ?? '', 'multipart/form-data') +}) diff --git a/packages/js-sdk/tests/sandbox/versionGates.test.ts b/packages/js-sdk/tests/sandbox/versionGates.test.ts new file mode 100644 index 0000000000..216b351024 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/versionGates.test.ts @@ -0,0 +1,117 @@ +import { afterAll, assert, beforeAll, describe, expect, test } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' + +import { ConnectionConfig, Sandbox } from '../../src' +import { SandboxError, TemplateError } from '../../src/errors' +import { + ENVD_COMMANDS_STDIN, + ENVD_DEBUG_FALLBACK, + ENVD_VERSION_FS_EVENT_ENTRY_INFO, + ENVD_VERSION_RECURSIVE_WATCH, + ENVD_VERSION_WATCH_NETWORK_MOUNTS, +} from '../../src/envd/versions' +import { belowEnvdVersion, TEST_API_KEY } from '../setup' + +const sandboxId = 'sbx-version-gate' +const envdUrl = `https://49983-${sandboxId}.sandbox.e2b.dev` + +// A gate that passes lets the call through to envd, so the RPC is mocked +// instead of reaching the network. +const server = setupServer( + http.post(`${envdUrl}/*`, () => + HttpResponse.json({ code: 14, message: 'unavailable' }, { status: 503 }) + ) +) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterAll(() => server.close()) + +/** + * The version gates reject unsupported options before any request leaves the + * SDK, so a sandbox handle over the mocked envd is enough. + */ +function sandboxWithEnvd(envdVersion: string): Sandbox { + const config = new ConnectionConfig({ apiKey: TEST_API_KEY }) + return new Sandbox({ + ...config, + sandboxId, + sandboxDomain: 'sandbox.e2b.dev', + envdVersion, + envdAccessToken: 'token', + }) +} + +describe('commands', () => { + test('rejects stdin:false below ENVD_COMMANDS_STDIN', async () => { + const sandbox = sandboxWithEnvd(belowEnvdVersion(ENVD_COMMANDS_STDIN)) + + await expect( + sandbox.commands.run('echo hello', { stdin: false }) + ).rejects.toThrowError(SandboxError) + }) + + test('reports the envd version in the error message', async () => { + const envdVersion = belowEnvdVersion(ENVD_COMMANDS_STDIN) + const sandbox = sandboxWithEnvd(envdVersion) + + await sandbox.commands.run('echo hello', { stdin: false }).then( + () => assert.fail('expected the version gate to reject'), + (err: Error) => assert.include(err.message, envdVersion) + ) + }) +}) + +describe('watchDir', () => { + const noop = () => {} + + // TODO: the gates should reject with InvalidArgumentError — this is + // argument validation on `sandbox.files`, not a template build. + test('rejects recursive below ENVD_VERSION_RECURSIVE_WATCH', async () => { + const sandbox = sandboxWithEnvd( + belowEnvdVersion(ENVD_VERSION_RECURSIVE_WATCH) + ) + + await expect( + sandbox.files.watchDir('/home/user', noop, { recursive: true }) + ).rejects.toThrowError(TemplateError) + }) + + test('rejects includeEntry below ENVD_VERSION_FS_EVENT_ENTRY_INFO', async () => { + const sandbox = sandboxWithEnvd( + belowEnvdVersion(ENVD_VERSION_FS_EVENT_ENTRY_INFO) + ) + + await expect( + sandbox.files.watchDir('/home/user', noop, { includeEntry: true }) + ).rejects.toThrowError(TemplateError) + }) + + test('rejects allowNetworkMounts below ENVD_VERSION_WATCH_NETWORK_MOUNTS', async () => { + const sandbox = sandboxWithEnvd( + belowEnvdVersion(ENVD_VERSION_WATCH_NETWORK_MOUNTS) + ) + + await expect( + sandbox.files.watchDir('/home/user', noop, { allowNetworkMounts: true }) + ).rejects.toThrowError(TemplateError) + }) + + test('accepts the gated options on a supported envd', async () => { + // The gates pass, so the call proceeds to the mocked RPC and fails there + // instead — the point is that it is not a TemplateError. + const sandbox = sandboxWithEnvd(ENVD_DEBUG_FALLBACK) + + await sandbox.files + .watchDir('/home/user', noop, { + recursive: true, + includeEntry: true, + allowNetworkMounts: true, + requestTimeoutMs: 1_000, + }) + .then( + () => assert.fail('expected the mocked RPC to fail'), + (err: Error) => assert.notInstanceOf(err, TemplateError) + ) + }) +}) diff --git a/packages/js-sdk/tests/sandbox/files/watchHandle.test.ts b/packages/js-sdk/tests/sandbox/watchHandle.test.ts similarity index 96% rename from packages/js-sdk/tests/sandbox/files/watchHandle.test.ts rename to packages/js-sdk/tests/sandbox/watchHandle.test.ts index d4c4dc4f14..1bea01301c 100644 --- a/packages/js-sdk/tests/sandbox/files/watchHandle.test.ts +++ b/packages/js-sdk/tests/sandbox/watchHandle.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from 'vitest' -import { EventType } from '../../../src/envd/filesystem/filesystem_pb' +import { EventType } from '../../src/envd/filesystem/filesystem_pb' import { FilesystemEventType, WatchHandle, -} from '../../../src/sandbox/filesystem/watchHandle' +} from '../../src/sandbox/filesystem/watchHandle' function filesystemEvent(name: string, type: EventType = EventType.WRITE) { return { diff --git a/packages/python-sdk/tests/async/api_async/test_sbx_kill.py b/packages/python-sdk/tests/async/api_async/test_sbx_kill.py index 85de035d2a..a895bcacd6 100644 --- a/packages/python-sdk/tests/async/api_async/test_sbx_kill.py +++ b/packages/python-sdk/tests/async/api_async/test_sbx_kill.py @@ -16,6 +16,7 @@ async def test_kill_existing_sandbox(async_sandbox: AsyncSandbox, sandbox_test_i assert async_sandbox.sandbox_id not in [s.sandbox_id for s in sandboxes] +@pytest.mark.e2e @pytest.mark.skip_debug() async def test_kill_non_existing_sandbox(): assert not await AsyncSandbox.kill("nonexistingsandbox") diff --git a/packages/python-sdk/tests/async/sandbox_async/test_create.py b/packages/python-sdk/tests/async/sandbox_async/test_create.py index f5615974fe..8f45976877 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_create.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_create.py @@ -39,6 +39,7 @@ async def test_metadata(async_sandbox_factory): assert False, "Sandbox not found" +@pytest.mark.e2e @pytest.mark.skip_debug() async def test_mcp_gateway_start_failure_kills_created_sandbox(template): metadata = {"mcp_gateway_cleanup_test_id": str(uuid4())} diff --git a/packages/python-sdk/tests/async/sandbox_async/test_read_format.py b/packages/python-sdk/tests/async/sandbox_async/test_read_format.py new file mode 100644 index 0000000000..b2b7616e98 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_read_format.py @@ -0,0 +1,111 @@ +"""Async counterpart of `tests/sync/sandbox_sync/test_read_format.py`.""" + +from typing import List + +import httpx +import pytest +from packaging.version import Version + +from envd_versions import below_envd_version + +import e2b.sandbox_async.filesystem.filesystem as filesystem_module +from e2b.envd.versions import ( + ENVD_DEBUG_FALLBACK, + ENVD_DEFAULT_USER, +) +from e2b.connection_config import ConnectionConfig, default_username +from e2b.exceptions import FileNotFoundException +from e2b.sandbox_async.filesystem.filesystem import Filesystem + +ENVD_URL = "https://49983-sbx-read-format.sandbox.e2b.dev" +FILE_CONTENT = "hello from envd" + + +def _filesystem( + monkeypatch, + api_key: str, + requests: List[httpx.Request], + envd_version: str = str(ENVD_DEBUG_FALLBACK), + status_code: int = 200, +) -> Filesystem: + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if status_code != 200: + return httpx.Response(status_code, json={"message": "file not found"}) + return httpx.Response(200, text=FILE_CONTENT) + + client = httpx.AsyncClient( + base_url=ENVD_URL, transport=httpx.MockTransport(handler), timeout=5 + ) + # Streamed reads use a sibling client built by `get_envd_api`; point it at + # the same mock transport. + monkeypatch.setattr( + filesystem_module, "get_envd_api", lambda *args, **kwargs: client + ) + + return Filesystem( + ENVD_URL, + Version(envd_version), + ConnectionConfig(api_key=api_key), + client, + ) + + +async def test_read_returns_text_by_default(monkeypatch, test_api_key): + requests: List[httpx.Request] = [] + filesystem = _filesystem(monkeypatch, test_api_key, requests) + + assert await filesystem.read("/home/user/a.txt") == FILE_CONTENT + + assert len(requests) == 1 + assert requests[0].url.params["path"] == "/home/user/a.txt" + assert "username" not in requests[0].url.params + # httpx sends its own Accept-Encoding; the SDK only overrides it for gzip. + assert requests[0].headers["Accept-Encoding"] != "gzip" + + +async def test_read_returns_bytes(monkeypatch, test_api_key): + filesystem = _filesystem(monkeypatch, test_api_key, []) + + content = await filesystem.read("/home/user/a.txt", format="bytes") + + assert isinstance(content, bytearray) + assert content == bytearray(FILE_CONTENT.encode()) + + +async def test_read_returns_stream(monkeypatch, test_api_key): + filesystem = _filesystem(monkeypatch, test_api_key, []) + + stream = await filesystem.read("/home/user/a.txt", format="stream") + chunks = [chunk async for chunk in stream] + assert b"".join(chunks) == FILE_CONTENT.encode() + + +async def test_read_sends_default_username_on_old_envd(monkeypatch, test_api_key): + requests: List[httpx.Request] = [] + filesystem = _filesystem( + monkeypatch, + test_api_key, + requests, + envd_version=below_envd_version(ENVD_DEFAULT_USER), + ) + + await filesystem.read("/home/user/a.txt") + + assert requests[0].url.params["username"] == default_username + + +async def test_read_negotiates_gzip(monkeypatch, test_api_key): + requests: List[httpx.Request] = [] + filesystem = _filesystem(monkeypatch, test_api_key, requests) + + await filesystem.read("/home/user/a.txt", gzip=True) + + assert requests[0].headers["Accept-Encoding"] == "gzip" + + +async def test_read_maps_404_to_file_not_found(monkeypatch, test_api_key): + filesystem = _filesystem(monkeypatch, test_api_key, [], status_code=404) + + with pytest.raises(FileNotFoundException): + await filesystem.read("/home/user/missing.txt") diff --git a/packages/python-sdk/tests/async/sandbox_async/test_upload_mode.py b/packages/python-sdk/tests/async/sandbox_async/test_upload_mode.py new file mode 100644 index 0000000000..1c0096e1ff --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_upload_mode.py @@ -0,0 +1,118 @@ +"""Async counterpart of `tests/sync/sandbox_sync/test_upload_mode.py`.""" + +import io +from typing import List + +import httpx +from packaging.version import Version + +from envd_versions import below_envd_version + +from e2b.envd.versions import ( + ENVD_DEBUG_FALLBACK, + ENVD_OCTET_STREAM_UPLOAD, +) +from e2b.connection_config import ConnectionConfig +from e2b.sandbox_async.filesystem.filesystem import Filesystem + +ENVD_URL = "https://49983-sbx-upload-mode.sandbox.e2b.dev" +WRITE_RESPONSE = [{"name": "a.txt", "path": "/home/user/a.txt", "type": "file"}] + + +def _filesystem( + api_key: str, + requests: List[httpx.Request], + envd_version: str = str(ENVD_DEBUG_FALLBACK), +) -> Filesystem: + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json=WRITE_RESPONSE) + + client = httpx.AsyncClient( + base_url=ENVD_URL, transport=httpx.MockTransport(handler), timeout=5 + ) + + return Filesystem( + ENVD_URL, + Version(envd_version), + ConnectionConfig(api_key=api_key), + client, + ) + + +async def test_in_memory_data_uploads_as_multipart(test_api_key): + requests: List[httpx.Request] = [] + filesystem = _filesystem(test_api_key, requests) + + await filesystem.write("/home/user/a.txt", "hello") + + assert requests[0].headers["Content-Type"].startswith("multipart/form-data") + assert requests[0].url.params["path"] == "/home/user/a.txt" + + +async def test_octet_stream_can_be_requested_explicitly(test_api_key): + requests: List[httpx.Request] = [] + filesystem = _filesystem(test_api_key, requests) + + await filesystem.write("/home/user/a.txt", "hello", use_octet_stream=True) + + assert requests[0].headers["Content-Type"] == "application/octet-stream" + assert requests[0].content == b"hello" + + +async def test_file_like_data_defaults_to_octet_stream(test_api_key): + requests: List[httpx.Request] = [] + filesystem = _filesystem(test_api_key, requests) + + await filesystem.write("/home/user/a.txt", io.BytesIO(b"hello")) + + assert requests[0].headers["Content-Type"] == "application/octet-stream" + + +async def test_octet_stream_falls_back_to_multipart_on_old_envd(test_api_key): + requests: List[httpx.Request] = [] + filesystem = _filesystem( + test_api_key, + requests, + envd_version=below_envd_version(ENVD_OCTET_STREAM_UPLOAD), + ) + + await filesystem.write("/home/user/a.txt", io.BytesIO(b"hello")) + + assert requests[0].headers["Content-Type"].startswith("multipart/form-data") + + +async def test_gzip_implies_octet_stream_and_sets_content_encoding(test_api_key): + requests: List[httpx.Request] = [] + filesystem = _filesystem(test_api_key, requests) + + await filesystem.write("/home/user/a.txt", "hello", gzip=True) + + assert requests[0].headers["Content-Type"] == "application/octet-stream" + assert requests[0].headers["Content-Encoding"] == "gzip" + assert requests[0].content != b"hello" + + +async def test_metadata_is_sent_as_headers(test_api_key): + requests: List[httpx.Request] = [] + filesystem = _filesystem(test_api_key, requests) + + await filesystem.write("/home/user/a.txt", "hello", metadata={"origin": "test"}) + + assert requests[0].headers["X-Metadata-origin"] == "test" + + +async def test_multi_file_multipart_upload_omits_path_param(test_api_key): + requests: List[httpx.Request] = [] + filesystem = _filesystem(test_api_key, requests) + + await filesystem.write_files( + [ + {"path": "/home/user/a.txt", "data": "a"}, + {"path": "/home/user/b.txt", "data": "b"}, + ] + ) + + assert len(requests) == 1 + assert requests[0].headers["Content-Type"].startswith("multipart/form-data") + assert "path" not in requests[0].url.params diff --git a/packages/python-sdk/tests/async/sandbox_async/test_version_gates.py b/packages/python-sdk/tests/async/sandbox_async/test_version_gates.py new file mode 100644 index 0000000000..7cc99afe1c --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_version_gates.py @@ -0,0 +1,84 @@ +"""Async counterpart of `tests/sync/sandbox_sync/test_version_gates.py`.""" + +import httpx +import pytest +from packaging.version import Version + +from envd_versions import below_envd_version + +from e2b.connection_config import ConnectionConfig +from e2b.envd.versions import ( + ENVD_COMMANDS_STDIN, + ENVD_FILE_METADATA, + ENVD_VERSION_FS_EVENT_ENTRY_INFO, + ENVD_VERSION_RECURSIVE_WATCH, + ENVD_VERSION_WATCH_NETWORK_MOUNTS, +) +from e2b.exceptions import SandboxException, TemplateException +from e2b.sandbox_async.commands.command import Commands +from e2b.sandbox_async.filesystem.filesystem import Filesystem + +ENVD_URL = "https://49983-sbx-version-gate.sandbox.e2b.dev" + + +def _on_event(event) -> None: + raise AssertionError("watch event handler should not be called") + + +def _commands(envd_version: str, api_key: str) -> Commands: + return Commands( + ENVD_URL, + ConnectionConfig(api_key=api_key), + Version(envd_version), + httpx.AsyncClient(), + ) + + +def _filesystem(envd_version: str, api_key: str) -> Filesystem: + return Filesystem( + ENVD_URL, + Version(envd_version), + ConnectionConfig(api_key=api_key), + httpx.AsyncClient(), + ) + + +async def test_run_rejects_disabling_stdin_below_envd_commands_stdin(test_api_key): + commands = _commands(below_envd_version(ENVD_COMMANDS_STDIN), test_api_key) + + with pytest.raises(SandboxException, match="can't specify stdin"): + await commands.run("echo hello", stdin=False) + + +async def test_watch_dir_rejects_recursive_on_old_envd(test_api_key): + filesystem = _filesystem( + below_envd_version(ENVD_VERSION_RECURSIVE_WATCH), test_api_key + ) + + with pytest.raises(TemplateException, match="recursive watching"): + await filesystem.watch_dir("/home/user", _on_event, recursive=True) + + +async def test_watch_dir_rejects_include_entry_on_old_envd(test_api_key): + filesystem = _filesystem( + below_envd_version(ENVD_VERSION_FS_EVENT_ENTRY_INFO), test_api_key + ) + + with pytest.raises(TemplateException, match="entry info"): + await filesystem.watch_dir("/home/user", _on_event, include_entry=True) + + +async def test_watch_dir_rejects_network_mounts_on_old_envd(test_api_key): + filesystem = _filesystem( + below_envd_version(ENVD_VERSION_WATCH_NETWORK_MOUNTS), test_api_key + ) + + with pytest.raises(TemplateException, match="network mounts"): + await filesystem.watch_dir("/home/user", _on_event, allow_network_mounts=True) + + +async def test_write_rejects_metadata_on_old_envd(test_api_key): + filesystem = _filesystem(below_envd_version(ENVD_FILE_METADATA), test_api_key) + + with pytest.raises(TemplateException, match="File metadata requires"): + await filesystem.write("/home/user/a.txt", "hello", metadata={"key": "value"}) diff --git a/packages/python-sdk/tests/sync/api_sync/test_sbx_kill.py b/packages/python-sdk/tests/sync/api_sync/test_sbx_kill.py index 56275ca1bc..1c3abb7f2d 100644 --- a/packages/python-sdk/tests/sync/api_sync/test_sbx_kill.py +++ b/packages/python-sdk/tests/sync/api_sync/test_sbx_kill.py @@ -16,6 +16,7 @@ def test_kill_existing_sandbox(sandbox: Sandbox, sandbox_test_id: str): assert sandbox.sandbox_id not in [s.sandbox_id for s in sandboxes] +@pytest.mark.e2e @pytest.mark.skip_debug() def test_kill_non_existing_sandbox(): assert not Sandbox.kill("nonexistingsandbox") diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_create.py b/packages/python-sdk/tests/sync/sandbox_sync/test_create.py index ad97fe7987..e28fee38f9 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_create.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_create.py @@ -39,6 +39,7 @@ def test_metadata(sandbox_factory): assert False, "Sandbox not found" +@pytest.mark.e2e @pytest.mark.skip_debug() def test_mcp_gateway_start_failure_kills_created_sandbox(template): metadata = {"mcp_gateway_cleanup_test_id": str(uuid4())} diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_read_format.py b/packages/python-sdk/tests/sync/sandbox_sync/test_read_format.py new file mode 100644 index 0000000000..cc4c8c5379 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_read_format.py @@ -0,0 +1,115 @@ +"""`files.read` format switching against a canned envd response — no sandbox. + +The envd file API is answered by an `httpx.MockTransport`, so the assertions +cover what the SDK sends (path/username params, gzip negotiation) and how it +shapes the response per `format`. Mirrors `tests/sandbox/readFormat.test.ts`. +""" + +from typing import List + +import httpx +import pytest +from packaging.version import Version + +from envd_versions import below_envd_version + +import e2b.sandbox_sync.filesystem.filesystem as filesystem_module +from e2b.envd.versions import ( + ENVD_DEBUG_FALLBACK, + ENVD_DEFAULT_USER, +) +from e2b.connection_config import ConnectionConfig, default_username +from e2b.exceptions import FileNotFoundException +from e2b.sandbox_sync.filesystem.filesystem import Filesystem + +ENVD_URL = "https://49983-sbx-read-format.sandbox.e2b.dev" +FILE_CONTENT = "hello from envd" + + +def _filesystem( + monkeypatch, + api_key: str, + requests: List[httpx.Request], + envd_version: str = str(ENVD_DEBUG_FALLBACK), + status_code: int = 200, +) -> Filesystem: + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if status_code != 200: + return httpx.Response(status_code, json={"message": "file not found"}) + return httpx.Response(200, text=FILE_CONTENT) + + client = httpx.Client( + base_url=ENVD_URL, transport=httpx.MockTransport(handler), timeout=5 + ) + # Streamed reads use a sibling client built by `get_envd_api`; point it at + # the same mock transport. + monkeypatch.setattr( + filesystem_module, "get_envd_api", lambda *args, **kwargs: client + ) + + return Filesystem( + ENVD_URL, + Version(envd_version), + ConnectionConfig(api_key=api_key), + client, + ) + + +def test_read_returns_text_by_default(monkeypatch, test_api_key): + requests: List[httpx.Request] = [] + filesystem = _filesystem(monkeypatch, test_api_key, requests) + + assert filesystem.read("/home/user/a.txt") == FILE_CONTENT + + assert len(requests) == 1 + assert requests[0].url.params["path"] == "/home/user/a.txt" + assert "username" not in requests[0].url.params + # httpx sends its own Accept-Encoding; the SDK only overrides it for gzip. + assert requests[0].headers["Accept-Encoding"] != "gzip" + + +def test_read_returns_bytes(monkeypatch, test_api_key): + filesystem = _filesystem(monkeypatch, test_api_key, []) + + content = filesystem.read("/home/user/a.txt", format="bytes") + + assert isinstance(content, bytearray) + assert content == bytearray(FILE_CONTENT.encode()) + + +def test_read_returns_stream(monkeypatch, test_api_key): + filesystem = _filesystem(monkeypatch, test_api_key, []) + + with filesystem.read("/home/user/a.txt", format="stream") as stream: + assert b"".join(stream) == FILE_CONTENT.encode() + + +def test_read_sends_default_username_on_old_envd(monkeypatch, test_api_key): + requests: List[httpx.Request] = [] + filesystem = _filesystem( + monkeypatch, + test_api_key, + requests, + envd_version=below_envd_version(ENVD_DEFAULT_USER), + ) + + filesystem.read("/home/user/a.txt") + + assert requests[0].url.params["username"] == default_username + + +def test_read_negotiates_gzip(monkeypatch, test_api_key): + requests: List[httpx.Request] = [] + filesystem = _filesystem(monkeypatch, test_api_key, requests) + + filesystem.read("/home/user/a.txt", gzip=True) + + assert requests[0].headers["Accept-Encoding"] == "gzip" + + +def test_read_maps_404_to_file_not_found(monkeypatch, test_api_key): + filesystem = _filesystem(monkeypatch, test_api_key, [], status_code=404) + + with pytest.raises(FileNotFoundException): + filesystem.read("/home/user/missing.txt") diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_upload_mode.py b/packages/python-sdk/tests/sync/sandbox_sync/test_upload_mode.py new file mode 100644 index 0000000000..3ac1d9fcc9 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_upload_mode.py @@ -0,0 +1,120 @@ +"""The octet-stream-vs-multipart upload decision, asserted on the request the +SDK sends to a mocked envd file API. Mirrors `tests/sandbox/uploadMode.test.ts`. +""" + +import io +from typing import List + +import httpx +from packaging.version import Version + +from envd_versions import below_envd_version + +from e2b.envd.versions import ( + ENVD_DEBUG_FALLBACK, + ENVD_OCTET_STREAM_UPLOAD, +) +from e2b.connection_config import ConnectionConfig +from e2b.sandbox_sync.filesystem.filesystem import Filesystem + +ENVD_URL = "https://49983-sbx-upload-mode.sandbox.e2b.dev" +WRITE_RESPONSE = [{"name": "a.txt", "path": "/home/user/a.txt", "type": "file"}] + + +def _filesystem( + api_key: str, + requests: List[httpx.Request], + envd_version: str = str(ENVD_DEBUG_FALLBACK), +) -> Filesystem: + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json=WRITE_RESPONSE) + + client = httpx.Client( + base_url=ENVD_URL, transport=httpx.MockTransport(handler), timeout=5 + ) + + return Filesystem( + ENVD_URL, + Version(envd_version), + ConnectionConfig(api_key=api_key), + client, + ) + + +def test_in_memory_data_uploads_as_multipart(test_api_key): + requests: List[httpx.Request] = [] + filesystem = _filesystem(test_api_key, requests) + + filesystem.write("/home/user/a.txt", "hello") + + assert requests[0].headers["Content-Type"].startswith("multipart/form-data") + assert requests[0].url.params["path"] == "/home/user/a.txt" + + +def test_octet_stream_can_be_requested_explicitly(test_api_key): + requests: List[httpx.Request] = [] + filesystem = _filesystem(test_api_key, requests) + + filesystem.write("/home/user/a.txt", "hello", use_octet_stream=True) + + assert requests[0].headers["Content-Type"] == "application/octet-stream" + assert requests[0].content == b"hello" + + +def test_file_like_data_defaults_to_octet_stream(test_api_key): + requests: List[httpx.Request] = [] + filesystem = _filesystem(test_api_key, requests) + + filesystem.write("/home/user/a.txt", io.BytesIO(b"hello")) + + assert requests[0].headers["Content-Type"] == "application/octet-stream" + + +def test_octet_stream_falls_back_to_multipart_on_old_envd(test_api_key): + requests: List[httpx.Request] = [] + filesystem = _filesystem( + test_api_key, + requests, + envd_version=below_envd_version(ENVD_OCTET_STREAM_UPLOAD), + ) + + filesystem.write("/home/user/a.txt", io.BytesIO(b"hello")) + + assert requests[0].headers["Content-Type"].startswith("multipart/form-data") + + +def test_gzip_implies_octet_stream_and_sets_content_encoding(test_api_key): + requests: List[httpx.Request] = [] + filesystem = _filesystem(test_api_key, requests) + + filesystem.write("/home/user/a.txt", "hello", gzip=True) + + assert requests[0].headers["Content-Type"] == "application/octet-stream" + assert requests[0].headers["Content-Encoding"] == "gzip" + assert requests[0].content != b"hello" + + +def test_metadata_is_sent_as_headers(test_api_key): + requests: List[httpx.Request] = [] + filesystem = _filesystem(test_api_key, requests) + + filesystem.write("/home/user/a.txt", "hello", metadata={"origin": "test"}) + + assert requests[0].headers["X-Metadata-origin"] == "test" + + +def test_multi_file_multipart_upload_omits_path_param(test_api_key): + requests: List[httpx.Request] = [] + filesystem = _filesystem(test_api_key, requests) + + filesystem.write_files( + [ + {"path": "/home/user/a.txt", "data": "a"}, + {"path": "/home/user/b.txt", "data": "b"}, + ] + ) + + assert len(requests) == 1 + assert requests[0].headers["Content-Type"].startswith("multipart/form-data") + assert "path" not in requests[0].url.params diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_version_gates.py b/packages/python-sdk/tests/sync/sandbox_sync/test_version_gates.py new file mode 100644 index 0000000000..d6f2159b02 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_version_gates.py @@ -0,0 +1,85 @@ +"""Client-side envd version gating — no sandbox, no network. + +The SDK refuses options the sandbox's envd is too old to honor before it sends +anything, so these assertions only need a `Commands`/`Filesystem` bound to a +version. Mirrors `tests/sandbox/versionGates.test.ts` in the JS SDK. +""" + +import httpx +import pytest +from packaging.version import Version + +from envd_versions import below_envd_version + +from e2b.connection_config import ConnectionConfig +from e2b.envd.versions import ( + ENVD_COMMANDS_STDIN, + ENVD_FILE_METADATA, + ENVD_VERSION_FS_EVENT_ENTRY_INFO, + ENVD_VERSION_RECURSIVE_WATCH, + ENVD_VERSION_WATCH_NETWORK_MOUNTS, +) +from e2b.exceptions import SandboxException, TemplateException +from e2b.sandbox_sync.commands.command import Commands +from e2b.sandbox_sync.filesystem.filesystem import Filesystem + +ENVD_URL = "https://49983-sbx-version-gate.sandbox.e2b.dev" + + +def _commands(envd_version: str, api_key: str) -> Commands: + return Commands( + ENVD_URL, + ConnectionConfig(api_key=api_key), + Version(envd_version), + httpx.Client(), + ) + + +def _filesystem(envd_version: str, api_key: str) -> Filesystem: + return Filesystem( + ENVD_URL, + Version(envd_version), + ConnectionConfig(api_key=api_key), + httpx.Client(), + ) + + +def test_run_rejects_disabling_stdin_below_envd_commands_stdin(test_api_key): + commands = _commands(below_envd_version(ENVD_COMMANDS_STDIN), test_api_key) + + with pytest.raises(SandboxException, match="can't specify stdin"): + commands.run("echo hello", stdin=False) + + +def test_watch_dir_rejects_recursive_on_old_envd(test_api_key): + filesystem = _filesystem( + below_envd_version(ENVD_VERSION_RECURSIVE_WATCH), test_api_key + ) + + with pytest.raises(TemplateException, match="recursive watching"): + filesystem.watch_dir("/home/user", recursive=True) + + +def test_watch_dir_rejects_include_entry_on_old_envd(test_api_key): + filesystem = _filesystem( + below_envd_version(ENVD_VERSION_FS_EVENT_ENTRY_INFO), test_api_key + ) + + with pytest.raises(TemplateException, match="entry info"): + filesystem.watch_dir("/home/user", include_entry=True) + + +def test_watch_dir_rejects_network_mounts_on_old_envd(test_api_key): + filesystem = _filesystem( + below_envd_version(ENVD_VERSION_WATCH_NETWORK_MOUNTS), test_api_key + ) + + with pytest.raises(TemplateException, match="network mounts"): + filesystem.watch_dir("/home/user", allow_network_mounts=True) + + +def test_write_rejects_metadata_on_old_envd(test_api_key): + filesystem = _filesystem(below_envd_version(ENVD_FILE_METADATA), test_api_key) + + with pytest.raises(TemplateException, match="File metadata requires"): + filesystem.write("/home/user/a.txt", "hello", metadata={"key": "value"})