From 98bb6bd5c92f3b31e2575ebf56a06328fc2e2f13 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:09:16 +0000 Subject: [PATCH 1/2] test: split SDK suites into mocked unit and opt-in e2e tiers Co-Authored-By: mish@e2b.dev --- .changeset/plenty-pugs-organize.md | 7 + .github/workflows/cli_tests.yml | 16 + .github/workflows/js_sdk_tests.yml | 39 +- .github/workflows/python_sdk_tests.yml | 15 + .github/workflows/sdk_e2e_tests.yml | 56 +++ .github/workflows/sdk_tests.yml | 6 + CONTRIBUTING.md | 20 + packages/cli/package.json | 1 + packages/cli/tests/README.md | 35 ++ .../sandbox/backend_integration.test.ts | 53 +-- .../tests/commands/sandbox/exec_pipe.test.ts | 40 +- packages/cli/tests/setup.ts | 43 ++ packages/js-sdk/package.json | 4 +- packages/js-sdk/tests/README.md | 45 +++ packages/js-sdk/tests/api/info.test.ts | 4 +- packages/js-sdk/tests/api/kill.test.ts | 23 +- packages/js-sdk/tests/api/list.test.ts | 380 ++++++++---------- packages/js-sdk/tests/api/snapshot.test.ts | 6 +- packages/js-sdk/tests/e2eFiles.mts | 21 + .../runtimes/cloudflare/vitest.config.mts | 6 +- .../{commands => }/commandHandle.test.ts | 2 +- .../tests/sandbox/commands/envVars.test.ts | 15 +- .../commands/sandboxKilledDuringRun.test.ts | 4 +- packages/js-sdk/tests/sandbox/connect.test.ts | 115 ++---- .../js-sdk/tests/sandbox/connectDebug.test.ts | 25 ++ packages/js-sdk/tests/sandbox/create.test.ts | 65 ++- .../sandbox/{files => }/entryInfo.test.ts | 4 +- .../tests/sandbox/files/signing.test.ts | 161 ++++---- packages/js-sdk/tests/sandbox/fork.test.ts | 53 +-- .../js-sdk/tests/sandbox/forkPayload.test.ts | 11 + ...lidation.test.ts => gitValidation.test.ts} | 6 +- packages/js-sdk/tests/sandbox/host.test.ts | 33 +- .../tests/sandbox/internetAccess.test.ts | 69 ++-- packages/js-sdk/tests/sandbox/kill.test.ts | 4 +- .../tests/sandbox/lifecycleBehavior.test.ts | 108 +++++ .../tests/sandbox/lifecyclePayload.test.ts | 160 ++------ packages/js-sdk/tests/sandbox/metrics.test.ts | 44 +- packages/js-sdk/tests/sandbox/network.test.ts | 158 ++++---- .../js-sdk/tests/sandbox/readFormat.test.ts | 145 +++++++ packages/js-sdk/tests/sandbox/secure.test.ts | 42 +- .../js-sdk/tests/sandbox/snapshot-api.test.ts | 91 ++--- .../js-sdk/tests/sandbox/snapshot.test.ts | 74 ++-- packages/js-sdk/tests/sandbox/timeout.test.ts | 23 +- .../js-sdk/tests/sandbox/uploadMode.test.ts | 143 +++++++ .../js-sdk/tests/sandbox/versionGates.test.ts | 85 ++++ .../sandbox/{files => }/watchHandle.test.ts | 4 +- packages/js-sdk/tests/setup.ts | 18 +- .../tests/template/backgroundBuild.test.ts | 41 +- packages/js-sdk/tests/template/build.test.ts | 27 +- packages/js-sdk/tests/template/exists.test.ts | 7 +- .../template/methods/makeSymlink.test.ts | 6 +- .../tests/template/methods/runCmd.test.ts | 8 +- .../tests/template/serialization.test.ts | 109 +++++ packages/js-sdk/tests/template/tags.test.ts | 82 +--- .../js-sdk/tests/template/tagsBuild.test.ts | 83 ++++ packages/js-sdk/vitest.config.mts | 27 ++ packages/python-sdk/pytest.ini | 5 +- packages/python-sdk/tests/README.md | 43 ++ .../tests/async/api_async/test_sbx_kill.py | 1 + .../tests/async/sandbox_async/test_create.py | 1 + .../async/sandbox_async/test_read_format.py | 100 +++++ .../async/sandbox_async/test_upload_mode.py | 108 +++++ .../async/sandbox_async/test_version_gates.py | 69 ++++ .../template_async/test_background_build.py | 1 + .../tests/async/template_async/test_exists.py | 2 + packages/python-sdk/tests/conftest.py | 25 ++ .../tests/sync/api_sync/test_sbx_kill.py | 1 + .../tests/sync/sandbox_sync/test_create.py | 1 + .../sync/sandbox_sync/test_read_format.py | 104 +++++ .../sync/sandbox_sync/test_upload_mode.py | 110 +++++ .../sync/sandbox_sync/test_version_gates.py | 70 ++++ .../template_sync/test_background_build.py | 1 + .../tests/sync/template_sync/test_exists.py | 2 + .../sync/template_sync/test_serialization.py | 104 +++++ 74 files changed, 2517 insertions(+), 1103 deletions(-) create mode 100644 .changeset/plenty-pugs-organize.md create mode 100644 .github/workflows/sdk_e2e_tests.yml create mode 100644 packages/cli/tests/README.md create mode 100644 packages/js-sdk/tests/README.md create mode 100644 packages/js-sdk/tests/e2eFiles.mts rename packages/js-sdk/tests/sandbox/{commands => }/commandHandle.test.ts (99%) create mode 100644 packages/js-sdk/tests/sandbox/connectDebug.test.ts rename packages/js-sdk/tests/sandbox/{files => }/entryInfo.test.ts (88%) create mode 100644 packages/js-sdk/tests/sandbox/forkPayload.test.ts rename packages/js-sdk/tests/sandbox/{git/validation.test.ts => gitValidation.test.ts} (89%) create mode 100644 packages/js-sdk/tests/sandbox/lifecycleBehavior.test.ts create mode 100644 packages/js-sdk/tests/sandbox/readFormat.test.ts create mode 100644 packages/js-sdk/tests/sandbox/uploadMode.test.ts create mode 100644 packages/js-sdk/tests/sandbox/versionGates.test.ts rename packages/js-sdk/tests/sandbox/{files => }/watchHandle.test.ts (96%) create mode 100644 packages/js-sdk/tests/template/serialization.test.ts create mode 100644 packages/js-sdk/tests/template/tagsBuild.test.ts create mode 100644 packages/python-sdk/tests/README.md create mode 100644 packages/python-sdk/tests/async/sandbox_async/test_read_format.py create mode 100644 packages/python-sdk/tests/async/sandbox_async/test_upload_mode.py create mode 100644 packages/python-sdk/tests/async/sandbox_async/test_version_gates.py create mode 100644 packages/python-sdk/tests/sync/sandbox_sync/test_read_format.py create mode 100644 packages/python-sdk/tests/sync/sandbox_sync/test_upload_mode.py create mode 100644 packages/python-sdk/tests/sync/sandbox_sync/test_version_gates.py create mode 100644 packages/python-sdk/tests/sync/template_sync/test_serialization.py diff --git a/.changeset/plenty-pugs-organize.md b/.changeset/plenty-pugs-organize.md new file mode 100644 index 0000000000..33864d450d --- /dev/null +++ b/.changeset/plenty-pugs-organize.md @@ -0,0 +1,7 @@ +--- +'@e2b/python-sdk': patch +'@e2b/cli': patch +'e2b': patch +--- + +Split the test suites into a fully mocked default tier and an opt-in `E2B_E2E=1` end-to-end tier (tests only, no runtime changes) diff --git a/.github/workflows/cli_tests.yml b/.github/workflows/cli_tests.yml index a5c8bb5fc1..13b49e9896 100644 --- a/.github/workflows/cli_tests.yml +++ b/.github/workflows/cli_tests.yml @@ -7,6 +7,11 @@ on: required: false type: string default: '' + e2e: + description: 'Run the e2e tier (drives the CLI against real sandboxes) instead of the mocked unit tier' + required: false + type: boolean + default: false secrets: E2B_API_KEY: required: true @@ -64,9 +69,20 @@ jobs: run: pnpm build working-directory: ./packages/cli + # The default tier: fully mocked, no sandboxes, no credentials needed. - name: Run tests + if: ${{ !inputs.e2e }} run: pnpm test working-directory: ./packages/cli env: E2B_API_KEY: ${{ secrets.E2B_API_KEY }} E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }} + + # The opt-in tier: drives the built CLI against real sandboxes. + - name: Run e2e tests + if: ${{ inputs.e2e }} + run: pnpm test:e2e + working-directory: ./packages/cli + env: + E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }} diff --git a/.github/workflows/js_sdk_tests.yml b/.github/workflows/js_sdk_tests.yml index 3a08f0dcb8..c7c9937c08 100644 --- a/.github/workflows/js_sdk_tests.yml +++ b/.github/workflows/js_sdk_tests.yml @@ -12,6 +12,11 @@ on: required: false type: boolean default: false + e2e: + description: 'Run the e2e tier (provisions sandboxes and builds templates) instead of the mocked unit tier' + required: false + type: boolean + default: false secrets: E2B_API_KEY: required: true @@ -83,21 +88,22 @@ jobs: pnpm install --frozen-lockfile # Only the Node runtime runs the vitest `browser` project, which drives - # Chromium through Playwright. + # Chromium through Playwright — and that project is e2e (it provisions a + # sandbox from a browser bundle). - name: Get Playwright version - if: matrix.runtime == 'node' + if: matrix.runtime == 'node' && inputs.e2e id: playwright-version run: echo "version=$(node -p "require('playwright/package.json').version")" >> "$GITHUB_OUTPUT" - name: Cache Playwright browsers - if: matrix.runtime == 'node' + if: matrix.runtime == 'node' && inputs.e2e uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ matrix.os == 'windows-latest' && '~/AppData/Local/ms-playwright' || '~/.cache/ms-playwright' }} key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} - name: Install Playwright Chromium - if: matrix.runtime == 'node' + if: matrix.runtime == 'node' && inputs.e2e run: pnpm run playwright:install # The unit bundle test and the Cloudflare deploy config fail in CI when @@ -105,32 +111,43 @@ jobs: - name: Test build run: pnpm build + # The default tier: fully mocked, no sandboxes, no credentials needed. - name: Run Node tests - if: matrix.runtime == 'node' + if: matrix.runtime == 'node' && !inputs.e2e run: pnpm test env: E2B_API_KEY: ${{ secrets.E2B_API_KEY }} E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }} + # The opt-in tier: real sandboxes, envd round-trips and template builds. + - name: Run Node e2e tests + if: matrix.runtime == 'node' && inputs.e2e + run: | + pnpm test:e2e + pnpm test:browser + env: + E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }} + - name: Install Bun - if: matrix.runtime == 'bun' + if: matrix.runtime == 'bun' && !inputs.e2e uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - name: Run test suite under Bun - if: matrix.runtime == 'bun' + if: matrix.runtime == 'bun' && !inputs.e2e run: pnpm test:bun env: E2B_API_KEY: ${{ secrets.E2B_API_KEY }} E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }} - name: Install Deno - if: matrix.runtime == 'deno' + if: matrix.runtime == 'deno' && !inputs.e2e uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5 with: deno-version: v${{ env.TOOL_VERSION_DENO }} - name: Run test suite under Deno - if: matrix.runtime == 'deno' + if: matrix.runtime == 'deno' && !inputs.e2e run: pnpm test:deno env: E2B_API_KEY: ${{ secrets.E2B_API_KEY }} @@ -138,7 +155,7 @@ jobs: # Full unit + connectionConfig suite inside workerd (vitest-pool-workers). - name: Run test suite under Cloudflare workerd - if: matrix.runtime == 'cloudflare' + if: matrix.runtime == 'cloudflare' && !inputs.e2e run: pnpm test:cf env: E2B_API_KEY: ${{ secrets.E2B_API_KEY }} @@ -148,7 +165,7 @@ jobs: # global setup (wrangler deploy --temporary, no Cloudflare credentials # needed) and deletes the worker in teardown. - name: Run Cloudflare Workers deploy tests - if: matrix.runtime == 'cloudflare-deploy' + if: matrix.runtime == 'cloudflare-deploy' && !inputs.e2e run: pnpm test:cf:deploy env: E2B_API_KEY: ${{ secrets.E2B_API_KEY }} diff --git a/.github/workflows/python_sdk_tests.yml b/.github/workflows/python_sdk_tests.yml index 741d24246b..7491c111c1 100644 --- a/.github/workflows/python_sdk_tests.yml +++ b/.github/workflows/python_sdk_tests.yml @@ -7,6 +7,11 @@ on: required: false type: string default: '' + e2e: + description: 'Run the e2e tier (provisions sandboxes and builds templates) instead of the mocked unit tier' + required: false + type: boolean + default: false secrets: E2B_API_KEY: required: true @@ -50,8 +55,18 @@ jobs: - name: Test build run: uv build + # The default tier: fully mocked, no sandboxes, no credentials needed. - name: Run tests + if: ${{ !inputs.e2e }} run: uv run pytest --verbose --numprocesses=4 env: E2B_API_KEY: ${{ secrets.E2B_API_KEY }} E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }} + + # The opt-in tier: real sandboxes, envd round-trips and template builds. + - name: Run e2e tests + if: ${{ inputs.e2e }} + run: uv run pytest -m e2e --verbose --numprocesses=4 + env: + E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }} diff --git a/.github/workflows/sdk_e2e_tests.yml b/.github/workflows/sdk_e2e_tests.yml new file mode 100644 index 0000000000..8bc4ffa587 --- /dev/null +++ b/.github/workflows/sdk_e2e_tests.yml @@ -0,0 +1,56 @@ +name: SDK E2E Tests + +# The opt-in tier. These jobs provision sandboxes, talk to envd and build +# templates against live infrastructure, so they are not part of the required +# PR checks (see sdk_tests.yml, which runs the fully mocked default tier). +# +# Run them manually from the Actions tab, or by adding the `e2e` label to a PR. +on: + workflow_dispatch: + inputs: + staging: + description: 'Run against staging instead of production' + required: false + type: boolean + default: false + pull_request: + branches: + - main + types: [opened, synchronize, reopened, labeled] + +permissions: + contents: read + +jobs: + js-e2e: + name: E2E / JS SDK + if: ${{ github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'e2e') }} + uses: ./.github/workflows/js_sdk_tests.yml + with: + e2e: true + # The e2e tier only runs under Node — the Bun/Deno/Cloudflare legs cover + # the mocked tier in the default workflow. + node-only: true + E2B_DOMAIN: ${{ inputs.staging && vars.E2B_DOMAIN_STAGING || '' }} + secrets: + E2B_API_KEY: ${{ inputs.staging && secrets.E2B_API_KEY_STAGING || secrets.E2B_API_KEY }} + + python-e2e: + name: E2E / Python SDK + if: ${{ github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'e2e') }} + uses: ./.github/workflows/python_sdk_tests.yml + with: + e2e: true + E2B_DOMAIN: ${{ inputs.staging && vars.E2B_DOMAIN_STAGING || '' }} + secrets: + E2B_API_KEY: ${{ inputs.staging && secrets.E2B_API_KEY_STAGING || secrets.E2B_API_KEY }} + + cli-e2e: + name: E2E / CLI + if: ${{ github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'e2e') }} + uses: ./.github/workflows/cli_tests.yml + with: + e2e: true + E2B_DOMAIN: ${{ inputs.staging && vars.E2B_DOMAIN_STAGING || '' }} + secrets: + E2B_API_KEY: ${{ inputs.staging && secrets.E2B_API_KEY_STAGING || secrets.E2B_API_KEY }} diff --git a/.github/workflows/sdk_tests.yml b/.github/workflows/sdk_tests.yml index 8e488c25c9..5b77fcb12a 100644 --- a/.github/workflows/sdk_tests.yml +++ b/.github/workflows/sdk_tests.yml @@ -1,5 +1,8 @@ name: SDK Tests +# The default tier: fully mocked, deterministic, no sandboxes and no template +# builds. The behavioral tests that need live infrastructure live in the opt-in +# sdk_e2e_tests.yml workflow (`e2e` PR label or manual dispatch). on: pull_request: branches: @@ -76,6 +79,9 @@ jobs: secrets: E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + # The staging legs re-run the same mocked tier against the staging domain, so + # they only catch environment-specific breakage now; backend compatibility is + # verified by the e2e workflow (`workflow_dispatch` with `staging: true`). js-tests-staging: name: Staging / JS SDK Tests needs: changes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff3e758ff9..223e32d21a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,2 +1,22 @@ # Contributing If you want to contribute, open a PR, issue, or start a discussion on our [Discord](https://discord.gg/dSBY3ms2Qr). + +## Tests + +Every package splits its tests into two tiers: + +- **unit (default)** — fully mocked, deterministic, no sandboxes and no + credentials. +- **e2e (opt-in)** — real sandboxes, envd round-trips and template builds; + requires `E2B_E2E=1` and an API key. + +| Package | Unit | E2E | +| --- | --- | --- | +| `packages/js-sdk` | `pnpm test` | `pnpm test:e2e`, `pnpm test:browser` | +| `packages/python-sdk` | `uv run pytest` | `uv run pytest -m e2e` | +| `packages/cli` | `pnpm test` | `pnpm test:e2e` | + +Details, and where a new test belongs, are in each package's +`tests/README.md`. On CI the `SDK Tests` workflow runs the unit tier for every +PR; the e2e tier runs in the opt-in `SDK E2E Tests` workflow (add the `e2e` +label to a PR or dispatch it manually). diff --git a/packages/cli/package.json b/packages/cli/package.json index b4f0c7c4ff..560ccdddbe 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -43,6 +43,7 @@ "format": "prettier --write src", "test:interactive": "pnpm build && ./dist/index.js", "test": "vitest run", + "test:e2e": "E2B_E2E=1 vitest run", "test:watch": "vitest watch", "test:coverage": "vitest run --coverage", "check-deps": "knip" diff --git a/packages/cli/tests/README.md b/packages/cli/tests/README.md new file mode 100644 index 0000000000..27b6001988 --- /dev/null +++ b/packages/cli/tests/README.md @@ -0,0 +1,35 @@ +# CLI tests + +The suite has two tiers. + +## Unit tier (default) + +```bash +pnpm test +``` + +Fully mocked (`vi.mock` over `e2b` and the CLI's API modules) or driving the +built CLI against local input only — deterministic, no sandboxes, no +credentials. It asserts on argument parsing, validation, output formatting and +the calls the CLI makes into the SDK. + +## E2E tier (opt-in) + +```bash +E2B_API_KEY=e2b_... pnpm test:e2e +``` + +Tests that drive the built CLI against a real sandbox (`exec` piping, +`backend_integration`). They need `E2B_E2E=1` (set by the script above) plus +credentials, from `E2B_API_KEY` or `~/.e2b/config.json`; without both they are +skipped. + +Use `e2eTest` (or `skipE2E` in a `beforeAll`) from [`setup.ts`](./setup.ts), +which also resolves the shared `e2eApiKey`/`e2eDomain`. `E2B_DEBUG` is a +separate axis and disables the e2e tier because it points the CLI at a local +stack. + +## CI + +`SDK Tests` runs the unit tier on every PR. The e2e tier runs in the opt-in +`SDK E2E Tests` workflow — add the `e2e` label to a PR or dispatch it manually. diff --git a/packages/cli/tests/commands/sandbox/backend_integration.test.ts b/packages/cli/tests/commands/sandbox/backend_integration.test.ts index 4af72a4312..2c268c68a8 100644 --- a/packages/cli/tests/commands/sandbox/backend_integration.test.ts +++ b/packages/cli/tests/commands/sandbox/backend_integration.test.ts @@ -1,28 +1,16 @@ -import { afterAll, beforeAll, describe, expect, test } from 'vitest' +import { afterAll, beforeAll, describe, expect } from 'vitest' import { Sandbox } from 'e2b' -import { getUserConfig } from 'src/user' import { bufferToText, - isDebug, + e2eApiKey, + e2eDomain, + e2eTest, parseEnvInt, runCli, runCliWithPipedStdin, + skipE2E, } from '../../setup' -type UserConfigWithDomain = NonNullable> & { - domain?: string - E2B_DOMAIN?: string -} - -const userConfig = safeGetUserConfig() as UserConfigWithDomain | null -const domain = - process.env.E2B_DOMAIN || - userConfig?.E2B_DOMAIN || - userConfig?.domain || - 'e2b.app' -const apiKey = process.env.E2B_API_KEY || userConfig?.projectApiKey -const shouldSkip = !apiKey || isDebug -const integrationTest = test.skipIf(shouldSkip) const templateId = process.env.E2B_CLI_BACKEND_TEMPLATE_ID || process.env.E2B_TEMPLATE_ID || @@ -35,8 +23,8 @@ const perTestTimeoutMs = parseEnvInt('E2B_CLI_BACKEND_TEST_TIMEOUT_MS', 30_000) const spawnTimeoutMs = perTestTimeoutMs const cliEnv: NodeJS.ProcessEnv = { ...process.env, - E2B_DOMAIN: domain, - E2B_API_KEY: apiKey, + E2B_DOMAIN: e2eDomain, + E2B_API_KEY: e2eApiKey, } delete cliEnv.E2B_DEBUG @@ -50,11 +38,11 @@ describe('sandbox cli backend integration', () => { let sandbox: Sandbox beforeAll(async () => { - if (shouldSkip) return + if (skipE2E) return sandbox = await Sandbox.create(templateId, { - apiKey, - domain, + apiKey: e2eApiKey, + domain: e2eDomain, timeoutMs: sandboxTimeoutMs, }) }, 30_000) @@ -71,7 +59,7 @@ describe('sandbox cli backend integration', () => { } }, 15_000) - integrationTest( + e2eTest( 'list shows the sandbox', { timeout: perTestTimeoutMs }, async () => { @@ -81,7 +69,7 @@ describe('sandbox cli backend integration', () => { } ) - integrationTest( + e2eTest( 'info shows the sandbox details', { timeout: perTestTimeoutMs }, async () => { @@ -104,7 +92,7 @@ describe('sandbox cli backend integration', () => { } ) - integrationTest( + e2eTest( 'exec runs a command without piped stdin', { timeout: perTestTimeoutMs }, async () => { @@ -122,7 +110,7 @@ describe('sandbox cli backend integration', () => { } ) - integrationTest( + e2eTest( 'exec runs a command with piped stdin', { timeout: perTestTimeoutMs }, async () => { @@ -138,7 +126,7 @@ describe('sandbox cli backend integration', () => { } ) - integrationTest( + e2eTest( 'metrics returns successfully', { timeout: perTestTimeoutMs }, async () => { @@ -153,7 +141,7 @@ describe('sandbox cli backend integration', () => { } ) - integrationTest( + e2eTest( 'kill removes the sandbox', { timeout: perTestTimeoutMs }, async () => { @@ -196,12 +184,3 @@ function sandboxExistsInList( const parsed = JSON.parse(text) as Array<{ sandboxId?: string }> return parsed.some((item) => item.sandboxId === sandboxId) } - -function safeGetUserConfig(): ReturnType | null { - try { - return getUserConfig() - } catch (err) { - console.warn(`Failed to read ~/.e2b/config.json: ${String(err)}`) - return null - } -} diff --git a/packages/cli/tests/commands/sandbox/exec_pipe.test.ts b/packages/cli/tests/commands/sandbox/exec_pipe.test.ts index e57e3ce9e6..2ffccfa4ee 100644 --- a/packages/cli/tests/commands/sandbox/exec_pipe.test.ts +++ b/packages/cli/tests/commands/sandbox/exec_pipe.test.ts @@ -1,11 +1,12 @@ import { randomBytes } from 'node:crypto' -import { describe, expect, test } from 'vitest' +import { describe, expect } from 'vitest' import { Sandbox } from 'e2b' -import { getUserConfig } from 'src/user' import { type CliRunResult, bufferToText, - isDebug, + e2eApiKey, + e2eDomain, + e2eTest, parseEnvInt, runCliWithPipedStdin, } from '../../setup' @@ -17,20 +18,6 @@ type PipeCase = { timeoutMs?: number } -type UserConfigWithDomain = NonNullable> & { - domain?: string - E2B_DOMAIN?: string -} - -const userConfig = safeGetUserConfig() as UserConfigWithDomain | null -const domain = - process.env.E2B_DOMAIN || - userConfig?.E2B_DOMAIN || - userConfig?.domain || - 'e2b.app' -const apiKey = process.env.E2B_API_KEY || userConfig?.projectApiKey -const shouldSkip = !apiKey || isDebug -const integrationTest = test.skipIf(shouldSkip) const templateId = process.env.E2B_PIPE_TEMPLATE_ID || process.env.E2B_TEMPLATE_ID || @@ -47,8 +34,8 @@ const defaultCmdTimeoutMs = parseEnvInt( ) const cliEnv: NodeJS.ProcessEnv = { ...process.env, - E2B_DOMAIN: domain, - E2B_API_KEY: apiKey, + E2B_DOMAIN: e2eDomain, + E2B_API_KEY: e2eApiKey, } delete cliEnv.E2B_DEBUG @@ -100,13 +87,13 @@ const largeBinaryCases: PipeCase[] = [ ] describe('sandbox exec stdin piping (integration)', () => { - integrationTest( + e2eTest( 'pipes stdin to remote command', { timeout: testTimeoutMs }, async () => { const sandbox = await Sandbox.create(templateId, { - apiKey, - domain, + apiKey: e2eApiKey, + domain: e2eDomain, timeoutMs: sandboxTimeoutMs, }) @@ -177,12 +164,3 @@ function assertExecSucceeded( throw new Error(`${name} failed with rc=${result.status} stderr=${stderr}`) } } - -function safeGetUserConfig(): ReturnType | null { - try { - return getUserConfig() - } catch (err) { - console.warn(`Failed to read ~/.e2b/config.json: ${String(err)}`) - return null - } -} diff --git a/packages/cli/tests/setup.ts b/packages/cli/tests/setup.ts index 32c463340d..0216d9f65d 100644 --- a/packages/cli/tests/setup.ts +++ b/packages/cli/tests/setup.ts @@ -1,8 +1,51 @@ import { execSync, spawn, spawnSync } from 'node:child_process' import path from 'node:path' +import { test } from 'vitest' + +import { getUserConfig } from 'src/user' + export const isDebug = process.env.E2B_DEBUG !== undefined +/** + * Opt-in flag for the e2e tier: tests that drive the CLI against live + * infrastructure. The default `pnpm test` run stays fully mocked. + */ +export const isE2E = process.env.E2B_E2E !== undefined + +type UserConfigWithDomain = NonNullable> & { + domain?: string + E2B_DOMAIN?: string +} + +function safeGetUserConfig(): UserConfigWithDomain | null { + try { + return getUserConfig() as UserConfigWithDomain | null + } catch (err) { + console.warn(`Failed to read ~/.e2b/config.json: ${String(err)}`) + return null + } +} + +const userConfig = safeGetUserConfig() + +export const e2eDomain = + process.env.E2B_DOMAIN || + userConfig?.E2B_DOMAIN || + userConfig?.domain || + 'e2b.app' + +export const e2eApiKey = process.env.E2B_API_KEY || userConfig?.projectApiKey + +/** + * True when the e2e tier can't run: it needs the explicit opt-in and + * credentials, and debug mode points the CLI at a local stack instead. + */ +export const skipE2E = !isE2E || !e2eApiKey || isDebug + +/** `test` for the e2e tier — skipped unless `E2B_E2E=1` and credentials are set. */ +export const e2eTest = test.skipIf(skipE2E) + type CliRunOptions = { timeoutMs: number env?: NodeJS.ProcessEnv diff --git a/packages/js-sdk/package.json b/packages/js-sdk/package.json index 9abad4bf7e..1bb79594b5 100644 --- a/packages/js-sdk/package.json +++ b/packages/js-sdk/package.json @@ -27,7 +27,9 @@ "build": "tsc --noEmit && tsdown", "dev": "tsdown --watch", "example": "tsx example.mts", - "test": "vitest run", + "test": "vitest run --project unit --project template --project connectionConfig", + "test:e2e": "E2B_E2E=1 vitest run --project e2e", + "test:browser": "E2B_E2E=1 vitest run --project browser", "generate": "npm-run-all generate:* && pnpm run format", "generate:api": "redocly bundle js-sdk --config ../../redocly.yaml -o ../../spec/openapi_generated.js-sdk.yml && openapi-typescript ../../spec/openapi_generated.js-sdk.yml -x api_key --array-length --alphabetize --default-non-nullable false --output src/api/schema.gen.ts", "generate:envd": "cd ../../spec/envd && buf generate --template buf-js.gen.yaml\n", diff --git a/packages/js-sdk/tests/README.md b/packages/js-sdk/tests/README.md new file mode 100644 index 0000000000..efb9f2c044 --- /dev/null +++ b/packages/js-sdk/tests/README.md @@ -0,0 +1,45 @@ +# JS SDK tests + +The suite has two tiers. + +## Unit tier (default) + +```bash +pnpm test +``` + +Fully mocked (msw over the API and envd endpoints), deterministic, no sandboxes, +no credentials, seconds to run. It asserts on client-side logic: request payload +shaping, config propagation, version gating, response parsing and format +switching, RPC/API error mapping, pagination, URL construction and pure +utilities. + +Vitest projects: `unit`, `template`, `connectionConfig`. The other runtimes run +the same tier: `pnpm test:bun`, `pnpm test:deno`, `pnpm test:cf`. + +## E2E tier (opt-in) + +```bash +E2B_API_KEY=e2b_... pnpm test:e2e +E2B_API_KEY=e2b_... pnpm test:browser +``` + +Everything whose assertions depend on real behavior across the RPC boundary — +process execution, filesystem round-trips, PTY semantics, git inside the VM, +sandbox lifecycle against live infrastructure and server-side template builds. +It provisions sandboxes and builds templates, so it needs `E2B_E2E=1` (set by +the scripts above) and an API key. Without the opt-in these tests are skipped. + +The file list lives in [`e2eFiles.mts`](./e2eFiles.mts) and drives both the +`e2e` project and the exclusions of the default projects, so a new behavioral +test only needs to be added there. Use `e2eTest`, `e2eBuildTemplateTest` or the +`sandboxTest` fixture from [`setup.ts`](./setup.ts) — all three skip unless +`E2B_E2E` is set. + +`E2B_DEBUG` is a separate axis: it points the SDK at a local envd instead of a +provisioned sandbox and does not enable or disable either tier. + +## CI + +`SDK Tests` runs the unit tier on every PR. The e2e tier runs in the opt-in +`SDK E2E Tests` workflow — add the `e2e` label to a PR or dispatch it manually. diff --git a/packages/js-sdk/tests/api/info.test.ts b/packages/js-sdk/tests/api/info.test.ts index 0e3ca7c8e8..fa8c381acd 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 { sandboxTest } from '../setup.js' import { Sandbox } from '../../src' -sandboxTest.skipIf(isDebug)('get sandbox info', async ({ sandbox }) => { +sandboxTest('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..fb89383883 100644 --- a/packages/js-sdk/tests/api/kill.test.ts +++ b/packages/js-sdk/tests/api/kill.test.ts @@ -1,21 +1,18 @@ import { expect } from 'vitest' -import { sandboxTest, isDebug } from '../setup.js' +import { sandboxTest } from '../setup.js' import { Sandbox } from '../../src' -sandboxTest.skipIf(isDebug)( - 'kill existing sandbox', - async ({ sandbox, sandboxTestId }) => { - await Sandbox.kill(sandbox.sandboxId) +sandboxTest('kill existing sandbox', async ({ sandbox, sandboxTestId }) => { + await Sandbox.kill(sandbox.sandboxId) - const paginator = Sandbox.list({ - query: { state: ['running'], metadata: { sandboxTestId } }, - }) - const sandboxes = await paginator.nextItems() - expect(sandboxes.map((s) => s.sandboxId)).not.toContain(sandbox.sandboxId) - } -) + const paginator = Sandbox.list({ + query: { state: ['running'], metadata: { sandboxTestId } }, + }) + const sandboxes = await paginator.nextItems() + expect(sandboxes.map((s) => s.sandboxId)).not.toContain(sandbox.sandboxId) +}) -sandboxTest.skipIf(isDebug)('kill non-existing sandbox', async () => { +sandboxTest('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 3960b9fffd..1ab9234178 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 { sandboxTest } from '../setup.js' -sandboxTest.skipIf(isDebug)( - 'list sandboxes', - async ({ sandbox, sandboxTestId }) => { - const paginator = Sandbox.list({ - query: { metadata: { sandboxTestId } }, - }) - const sandboxes = await paginator.nextItems() +sandboxTest('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 () => { +sandboxTest('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 } }) +sandboxTest('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() +sandboxTest('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)( +sandboxTest( 'paginate running sandboxes', async ({ sandbox, sandboxTestId }) => { // Create extra sandboxes @@ -122,46 +113,43 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( - 'paginate paused sandboxes', - async ({ sandbox, sandboxTestId }) => { - await sandbox.betaPause() +sandboxTest('paginate paused sandboxes', async ({ sandbox, sandboxTestId }) => { + await sandbox.betaPause() - // Create extra paused sandbox - const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) - await extraSbx.betaPause() + // Create extra paused sandbox + const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) + await extraSbx.betaPause() - try { - // Test pagination with limit - const paginator = Sandbox.list({ - limit: 1, - query: { metadata: { sandboxTestId }, state: ['paused'] }, - }) - const sandboxes = await paginator.nextItems() + try { + // Test pagination with limit + const paginator = Sandbox.list({ + limit: 1, + query: { metadata: { sandboxTestId }, state: ['paused'] }, + }) + const sandboxes = await paginator.nextItems() - // Check first page - assert.equal(sandboxes.length, 1) - assert.equal(sandboxes[0].state, 'paused') - assert.isTrue(paginator.hasNext) - assert.notEqual(paginator.nextToken, undefined) - assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId) + // Check first page + assert.equal(sandboxes.length, 1) + assert.equal(sandboxes[0].state, 'paused') + assert.isTrue(paginator.hasNext) + assert.notEqual(paginator.nextToken, undefined) + assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId) - // Get second page - const sandboxes2 = await paginator.nextItems() + // Get second page + const sandboxes2 = await paginator.nextItems() - // Check second page - assert.equal(sandboxes2.length, 1) - assert.equal(sandboxes2[0].state, 'paused') - assert.isFalse(paginator.hasNext) - assert.equal(paginator.nextToken, undefined) - assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId) - } finally { - await extraSbx.kill() - } + // Check second page + assert.equal(sandboxes2.length, 1) + assert.equal(sandboxes2[0].state, 'paused') + assert.isFalse(paginator.hasNext) + assert.equal(paginator.nextToken, undefined) + assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId) + } finally { + await extraSbx.kill() } -) +}) -sandboxTest.skipIf(isDebug)( +sandboxTest( 'paginate running and paused sandboxes', async ({ sandbox, sandboxTestId }) => { // Create extra sandbox @@ -203,40 +191,34 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( - 'paginate iterator', - async ({ sandbox, sandboxTestId }) => { - const paginator = Sandbox.list({ - query: { metadata: { sandboxTestId } }, - }) - const sandboxes: SandboxInfo[] = [] +sandboxTest('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)( - 'list sandboxes', - async ({ sandbox, sandboxTestId }) => { - const paginator = Sandbox.list({ - query: { metadata: { sandboxTestId } }, - }) - const sandboxes = await paginator.nextItems() + assert.isAtLeast(sandboxes.length, 1) + assert.isTrue(sandboxes.some((s) => s.sandboxId === sandbox.sandboxId)) +}) - assert.isAtLeast(sandboxes.length, 1) +sandboxTest('list sandboxes', async ({ sandbox, sandboxTestId }) => { + const paginator = Sandbox.list({ + query: { metadata: { sandboxTestId } }, + }) + const sandboxes = await paginator.nextItems() - const found = sandboxes.some((s) => s.sandboxId === sandbox.sandboxId) - assert.isTrue(found) - } -) + assert.isAtLeast(sandboxes.length, 1) + + const found = sandboxes.some((s) => s.sandboxId === sandbox.sandboxId) + assert.isTrue(found) +}) -sandboxTest.skipIf(isDebug)('list sandboxes with filter', async () => { +sandboxTest('list sandboxes with filter', async () => { const uniqueId = randomUUID() const extraSbx = await Sandbox.create({ metadata: { uniqueId } }) @@ -253,57 +235,51 @@ sandboxTest.skipIf(isDebug)('list sandboxes with filter', async () => { } }) -sandboxTest.skipIf(isDebug)( - 'list running sandboxes', - async ({ sandboxTestId }) => { - const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) +sandboxTest('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) +sandboxTest('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)( +sandboxTest( 'paginate running sandboxes', async ({ sandbox, sandboxTestId }) => { // Create extra sandboxes @@ -339,46 +315,43 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( - 'paginate paused sandboxes', - async ({ sandbox, sandboxTestId }) => { - await Sandbox.betaPause(sandbox.sandboxId) +sandboxTest('paginate paused sandboxes', async ({ sandbox, sandboxTestId }) => { + await Sandbox.betaPause(sandbox.sandboxId) - // Create extra paused sandbox - const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) - await Sandbox.betaPause(extraSbx.sandboxId) + // Create extra paused sandbox + const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) + await Sandbox.betaPause(extraSbx.sandboxId) - try { - // Test pagination with limit - const paginator = Sandbox.list({ - limit: 1, - query: { metadata: { sandboxTestId }, state: ['paused'] }, - }) - const sandboxes = await paginator.nextItems() + try { + // Test pagination with limit + const paginator = Sandbox.list({ + limit: 1, + query: { metadata: { sandboxTestId }, state: ['paused'] }, + }) + const sandboxes = await paginator.nextItems() - // Check first page - assert.equal(sandboxes.length, 1) - assert.equal(sandboxes[0].state, 'paused') - assert.isTrue(paginator.hasNext) - assert.notEqual(paginator.nextToken, undefined) - assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId) + // Check first page + assert.equal(sandboxes.length, 1) + assert.equal(sandboxes[0].state, 'paused') + assert.isTrue(paginator.hasNext) + assert.notEqual(paginator.nextToken, undefined) + assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId) - // Get second page - const sandboxes2 = await paginator.nextItems() + // Get second page + const sandboxes2 = await paginator.nextItems() - // Check second page - assert.equal(sandboxes2.length, 1) - assert.equal(sandboxes2[0].state, 'paused') - assert.isFalse(paginator.hasNext) - assert.equal(paginator.nextToken, undefined) - assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId) - } finally { - await extraSbx.kill() - } + // Check second page + assert.equal(sandboxes2.length, 1) + assert.equal(sandboxes2[0].state, 'paused') + assert.isFalse(paginator.hasNext) + assert.equal(paginator.nextToken, undefined) + assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId) + } finally { + await extraSbx.kill() } -) +}) -sandboxTest.skipIf(isDebug)( +sandboxTest( 'paginate running and paused sandboxes', async ({ sandbox, sandboxTestId }) => { // Create extra sandbox @@ -421,20 +394,17 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( - 'paginate iterator', - async ({ sandbox, sandboxTestId }) => { - const paginator = Sandbox.list({ - query: { metadata: { sandboxTestId } }, - }) - const sandboxes: SandboxInfo[] = [] +sandboxTest('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..f5997f2d70 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 { sandboxTest } from '../setup.js' import { Sandbox } from '../../src' -sandboxTest.skipIf(isDebug)('pause sandbox', async ({ sandbox }) => { +sandboxTest('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 }) => { +sandboxTest('resume sandbox', async ({ sandbox }) => { await Sandbox.pause(sandbox.sandboxId) assert.isFalse( await sandbox.isRunning(), diff --git a/packages/js-sdk/tests/e2eFiles.mts b/packages/js-sdk/tests/e2eFiles.mts new file mode 100644 index 0000000000..e3aa330577 --- /dev/null +++ b/packages/js-sdk/tests/e2eFiles.mts @@ -0,0 +1,21 @@ +/** + * The e2e tier: files whose assertions depend on real behavior across the RPC + * boundary in envd or the control plane — process execution, filesystem + * round-trips, PTY semantics, git inside the VM, sandbox lifecycle against live + * infrastructure and server-side template builds. They provision sandboxes, so + * they only run with `E2B_E2E=1` and credentials (`pnpm test:e2e`). + * + * Everything else is fully mocked and runs by default. The directories below + * hold behavioral tests only — the client-side logic that used to live next to + * them (commandHandle, entryInfo, watchHandle, gitValidation) sits one level up. + */ +export const e2eFiles = [ + 'tests/api/{info,kill,list,snapshot}.test.ts', + 'tests/sandbox/commands/**/*.test.ts', + 'tests/sandbox/files/**/*.test.ts', + 'tests/sandbox/git/**/*.test.ts', + 'tests/sandbox/pty/**/*.test.ts', + 'tests/sandbox/{connect,create,fork,host,internetAccess,kill,lifecycleBehavior,metrics,network,secure,snapshot,snapshot-api,timeout}.test.ts', + 'tests/template/{backgroundBuild,build,exists,tagsBuild}.test.ts', + 'tests/template/methods/{makeSymlink,runCmd}.test.ts', +] diff --git a/packages/js-sdk/tests/runtimes/cloudflare/vitest.config.mts b/packages/js-sdk/tests/runtimes/cloudflare/vitest.config.mts index 85cae1af53..d633c0f9c8 100644 --- a/packages/js-sdk/tests/runtimes/cloudflare/vitest.config.mts +++ b/packages/js-sdk/tests/runtimes/cloudflare/vitest.config.mts @@ -2,6 +2,8 @@ import { cloudflareTest } from '@cloudflare/vitest-pool-workers' import { config } from 'dotenv' import { defineConfig } from 'vitest/config' +import { e2eFiles } from '../../e2eFiles.mjs' + const env = config() // Error names thrown by src/errors.ts (plus CommandExitError) — the shapes @@ -56,6 +58,8 @@ export default defineConfig({ // virtual filesystem can never see (and throws in CI when the file is // "missing"); the Node unit project keeps running it. 'tests/bundle/**', + // The e2e tier provisions sandboxes; workerd only runs the mocked tier. + ...e2eFiles, ], globals: false, testTimeout: 30_000, @@ -82,7 +86,7 @@ export default defineConfig({ // workerd's teardown error for in-flight streams when a test kills // the sandbox mid-request. message === 'Network connection lost.' || - // Stub rejection from tests/sandbox/git/validation.test.ts. + // Stub rejection from tests/sandbox/gitValidation.test.ts. message === 'commands.run should not be called') if (expectedRejection) return false }, 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..f3fdced7f4 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 { 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') + sandboxTest('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..4d57ca0c88 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 { sandboxTest } from '../../setup.js' -sandboxTest.skipIf(isDebug)( +sandboxTest( '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..58012af58a 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 { isDebug, e2eTest, sandboxTest, 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 () => { +e2eTest('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()) +sandboxTest('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() +sandboxTest('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 }) +e2eTest('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)( +sandboxTest( '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..53bf6d6033 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 { e2eTest, template } from '../setup.js' -test.skipIf(isDebug)('create', async () => { +e2eTest('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 () => { +e2eTest('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']> - > = [] +e2eTest('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..ff54de22fa 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 { sandboxTest } from '../../setup' describe('file signing', () => { sandboxTest.override({ @@ -9,46 +9,40 @@ describe('file signing', () => { }, }) - sandboxTest.skipIf(isDebug)( - 'test access file with expired signing', - async ({ sandbox }) => { - await sandbox.files.write('hello.txt', 'hello world') + sandboxTest('test access file with expired signing', async ({ sandbox }) => { + await sandbox.files.write('hello.txt', 'hello world') - const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt', { - useSignatureExpiration: -10_000, - }) + const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt', { + useSignatureExpiration: -10_000, + }) - 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, 401) - assert.deepEqual(JSON.parse(resBody), { - code: 401, - message: 'signature is already expired', - }) - } - ) + assert.equal(resStatus, 401) + assert.deepEqual(JSON.parse(resBody), { + code: 401, + message: 'signature is already expired', + }) + }) - sandboxTest.skipIf(isDebug)( - 'test access file with valid signing', - async ({ sandbox }) => { - await sandbox.files.write('hello.txt', 'hello world') + sandboxTest('test access file with valid signing', async ({ sandbox }) => { + await sandbox.files.write('hello.txt', 'hello world') - const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt', { - useSignatureExpiration: 10_000, - }) + const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt', { + useSignatureExpiration: 10_000, + }) - 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') - } - ) + assert.equal(resStatus, 200) + assert.equal(resBody, 'hello world') + }) - sandboxTest.skipIf(isDebug)( + sandboxTest( 'test access file with valid signing as root', async ({ sandbox }) => { await sandbox.files.write('hello.txt', 'hello world', { user: 'root' }) @@ -67,31 +61,28 @@ describe('file signing', () => { } ) - sandboxTest.skipIf(isDebug)( - 'test upload file with valid signing', - async ({ sandbox }) => { - const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', { - useSignatureExpiration: 10_000, - }) - - const form = new FormData() - form.append('file', 'file content') - - const res = await fetch(fileUrlWithSigning, { - method: 'POST', - body: form, - }) - const resBody = await res.text() - const resStatus = res.status - - assert.equal(resStatus, 200) - assert.deepEqual(JSON.parse(resBody), [ - { name: 'hello.txt', path: '/home/user/hello.txt', type: 'file' }, - ]) - } - ) + sandboxTest('test upload file with valid signing', async ({ sandbox }) => { + const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', { + useSignatureExpiration: 10_000, + }) + + const form = new FormData() + form.append('file', 'file content') + + const res = await fetch(fileUrlWithSigning, { + method: 'POST', + body: form, + }) + const resBody = await res.text() + const resStatus = res.status + + assert.equal(resStatus, 200) + assert.deepEqual(JSON.parse(resBody), [ + { name: 'hello.txt', path: '/home/user/hello.txt', type: 'file' }, + ]) + }) - sandboxTest.skipIf(isDebug)( + sandboxTest( 'test upload file with valid signing as root user', async ({ sandbox }) => { const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', { @@ -116,37 +107,31 @@ describe('file signing', () => { } ) - sandboxTest.skipIf(isDebug)( - 'test upload file with invalid signing', - async ({ sandbox }) => { - const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', { - useSignatureExpiration: -100_000, - }) - - const form = new FormData() - form.append('file', 'file content') - - const res = await fetch(fileUrlWithSigning, { - method: 'POST', - body: form, - }) - const resBody = await res.text() - const resStatus = res.status - - assert.equal(resStatus, 401) - assert.deepEqual(JSON.parse(resBody), { - code: 401, - message: 'signature is already expired', - }) - } - ) + sandboxTest('test upload file with invalid signing', async ({ sandbox }) => { + const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', { + useSignatureExpiration: -100_000, + }) + + const form = new FormData() + form.append('file', 'file content') + + const res = await fetch(fileUrlWithSigning, { + method: 'POST', + body: form, + }) + const resBody = await res.text() + const resStatus = res.status + + assert.equal(resStatus, 401) + assert.deepEqual(JSON.parse(resBody), { + code: 401, + message: 'signature is already expired', + }) + }) - sandboxTest.skipIf(isDebug)( - 'test command run with secured sbx', - async ({ sandbox }) => { - const response = await sandbox.commands.run('echo Hello World!') + sandboxTest('test command run with secured sbx', async ({ sandbox }) => { + const response = await sandbox.commands.run('echo Hello World!') - assert.equal(response.stdout, 'Hello World!\n') - } - ) + assert.equal(response.stdout, 'Hello World!\n') + }) }) diff --git a/packages/js-sdk/tests/sandbox/fork.test.ts b/packages/js-sdk/tests/sandbox/fork.test.ts index da3d30b9a6..9d9c076a03 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 { e2eTest, sandboxTest } 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 }) => { +sandboxTest('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) +sandboxTest('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)( +sandboxTest( '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 () => { +e2eTest('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..5a3649d9ab 100644 --- a/packages/js-sdk/tests/sandbox/host.test.ts +++ b/packages/js-sdk/tests/sandbox/host.test.ts @@ -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}` +sandboxTest('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..e21dd7cdea 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 { 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') - } - ) + sandboxTest('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) - } + sandboxTest('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') - } - ) + sandboxTest('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..0094a30675 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 { sandboxTest } from '../setup.js' -sandboxTest.skipIf(isDebug)('kill', async ({ sandbox, sandboxTestId }) => { +sandboxTest('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..c2fe71ce64 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 { sandboxTest, 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) +sandboxTest('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() +}) + +sandboxTest( '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..a408872914 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 { 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)( + sandboxTest( '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" + sandboxTest('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)( + sandboxTest( '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') - } - ) + sandboxTest('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,37 +124,34 @@ describe('allowPublicTraffic=false', () => { }, }) - sandboxTest.skipIf(isDebug)( - 'sandbox requires traffic access token', - async ({ sandbox }) => { - // Verify the sandbox was created successfully and has a traffic access token - assert(sandbox.trafficAccessToken) + sandboxTest('sandbox requires traffic access token', async ({ sandbox }) => { + // Verify the sandbox was created successfully and has a traffic access token + assert(sandbox.trafficAccessToken) - // Start a simple HTTP server in the sandbox - const port = 8080 - sandbox.commands.run(`python3 -m http.server ${port}`, { - background: true, - }) + // 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)}` - // Test 1: Request without traffic access token should fail with 403 - const response1 = await fetch(sandboxUrl) - assert.equal(response1.status, 403) + // Test 1: Request without traffic access token should fail with 403 + const response1 = await fetch(sandboxUrl) + assert.equal(response1.status, 403) - // Test 2: Request with valid traffic access token should succeed - const response2 = await fetch(sandboxUrl, { - headers: { - 'e2b-traffic-access-token': sandbox.trafficAccessToken, - }, - }) - assert.equal(response2.status, 200) - } - ) + // Test 2: Request with valid traffic access token should succeed + const response2 = await fetch(sandboxUrl, { + headers: { + 'e2b-traffic-access-token': sandbox.trafficAccessToken, + }, + }) + assert.equal(response2.status, 200) + }) }) describe('allowPublicTraffic=true', () => { @@ -172,26 +163,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, - }) + sandboxTest('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 +188,7 @@ describe('firewall transform injects headers', () => { // Port the httpbin template's start command listens on. const httpbinPort = 8080 - sandboxTest.skipIf(isDebug)( + sandboxTest( '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 +245,7 @@ describe('firewall transform injects headers', () => { }) describe('updateNetwork applies new egress rules', () => { - sandboxTest.skipIf(isDebug)( + sandboxTest( 'denies a previously reachable IP after update', async ({ sandbox }) => { // Baseline: 8.8.8.8 is reachable. @@ -294,7 +282,7 @@ describe('updateNetwork clears existing rules when fields are omitted', () => { }, }) - sandboxTest.skipIf(isDebug)( + sandboxTest( 'omitting fields replaces all egress rules', async ({ sandbox }) => { // Baseline from create-time config: 8.8.8.8 denied. @@ -329,7 +317,7 @@ describe('maskRequestHost option', () => { }, }) - sandboxTest.skipIf(isDebug)( + sandboxTest( '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..431718e49d --- /dev/null +++ b/packages/js-sdk/tests/sandbox/readFormat.test.ts @@ -0,0 +1,145 @@ +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 { NotFoundError } from '../../src/errors' +import { 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 = '0.6.4'): 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('0.3.9').files.read('/home/user/hello.txt') + + assert.equal(lastQuery?.get('username'), 'user') +}) + +test('omits the username on newer envd', async () => { + await sandbox('0.4.0').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 NotFoundError 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( + NotFoundError + ) + await expect( + files.read('/home/user/missing.txt', { format: 'stream' }) + ).rejects.toThrowError(NotFoundError) +}) diff --git a/packages/js-sdk/tests/sandbox/secure.test.ts b/packages/js-sdk/tests/sandbox/secure.test.ts index 90a8712941..fdc370f3fd 100644 --- a/packages/js-sdk/tests/sandbox/secure.test.ts +++ b/packages/js-sdk/tests/sandbox/secure.test.ts @@ -1,6 +1,6 @@ -import { assert, test, describe } from 'vitest' +import { assert, describe } from 'vitest' import { getSignature, Sandbox } from '../../src' -import { sandboxTest, isDebug } from '../setup' +import { e2eTest, sandboxTest } from '../setup' import { randomUUID, createHash } from 'node:crypto' describe('secure sandbox', () => { @@ -10,33 +10,27 @@ describe('secure sandbox', () => { }, }) - sandboxTest.skipIf(isDebug)( - 'test access file with signing', - async ({ sandbox }) => { - await sandbox.files.write('hello.txt', 'hello world') + sandboxTest('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') - } - ) + 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) + sandboxTest('try to re-connect to sandbox', async ({ sandbox }) => { + const sbxReconnect = await Sandbox.connect(sandbox.sandboxId) - await sbxReconnect.files.write('hello.txt', 'hello world') - } - ) + await sbxReconnect.files.write('hello.txt', 'hello world') + }) }) -test.skipIf(isDebug)('signing generation', async () => { +e2eTest('signing generation', async () => { const operation = 'read' const path = '/home/user/hello.txt' const user = 'root' @@ -63,7 +57,7 @@ test.skipIf(isDebug)('signing generation', async () => { assert.deepEqual(readSignatureExpected, readSignatureReceived) }) -test.skipIf(isDebug)('signing generation with expiration', async () => { +e2eTest('signing generation with expiration', async () => { const operation = 'read' const path = '/home/user/hello.txt' const user = 'root' @@ -95,7 +89,7 @@ test.skipIf(isDebug)('signing generation with expiration', async () => { assert.deepEqual(readSignatureExpected, readSignatureReceived) }) -test.skipIf(isDebug)('static signing key comparison', async () => { +e2eTest('static signing key comparison', async () => { const operation = 'read' const path = 'hello.txt' const user = 'user' diff --git a/packages/js-sdk/tests/sandbox/snapshot-api.test.ts b/packages/js-sdk/tests/sandbox/snapshot-api.test.ts index 3c6432af65..3fe22467d7 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 { sandboxTest } 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') +sandboxTest('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)( +sandboxTest( 'create sandbox from snapshot', async ({ sandbox, sandboxTestId }) => { const testContent = 'content from original sandbox' @@ -50,7 +47,7 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( +sandboxTest( '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 }) => { +sandboxTest('list snapshots', async ({ sandbox }) => { // Create a snapshot const snapshot = await sandbox.createSnapshot() @@ -121,45 +118,39 @@ sandboxTest.skipIf(isDebug)('list snapshots', async ({ sandbox }) => { } }) -sandboxTest.skipIf(isDebug)( - 'list snapshots for specific sandbox', - async ({ sandbox }) => { - // Create a snapshot - const snapshot = await sandbox.createSnapshot() +sandboxTest('list snapshots for specific sandbox', async ({ sandbox }) => { + // Create a snapshot + const snapshot = await sandbox.createSnapshot() - try { - // List snapshots for this sandbox using instance method - const paginator = sandbox.listSnapshots() - const snapshots = await paginator.nextItems() + try { + // List snapshots for this sandbox using instance method + const paginator = sandbox.listSnapshots() + const snapshots = await paginator.nextItems() - // Should find our snapshot - const found = snapshots.find((s) => s.snapshotId === snapshot.snapshotId) - assert.isDefined(found) - } finally { - await Sandbox.deleteSnapshot(snapshot.snapshotId) - } + // Should find our snapshot + const found = snapshots.find((s) => s.snapshotId === snapshot.snapshotId) + assert.isDefined(found) + } finally { + await Sandbox.deleteSnapshot(snapshot.snapshotId) } -) +}) -sandboxTest.skipIf(isDebug)( - 'create a named snapshot', - async ({ sandbox, sandboxTestId }) => { - const snapshotName = `snap-${sandboxTestId}` +sandboxTest('create a named snapshot', async ({ sandbox, sandboxTestId }) => { + const snapshotName = `snap-${sandboxTestId}` - const snapshot = await sandbox.createSnapshot({ name: snapshotName }) + const snapshot = await sandbox.createSnapshot({ name: snapshotName }) - try { - assert.isString(snapshot.snapshotId) - assert.isArray(snapshot.names) - assert.isTrue(snapshot.names.length > 0) - assert.isTrue(snapshot.names.some((n) => n.includes(snapshotName))) - } finally { - await Sandbox.deleteSnapshot(snapshot.snapshotId) - } + try { + assert.isString(snapshot.snapshotId) + assert.isArray(snapshot.names) + assert.isTrue(snapshot.names.length > 0) + assert.isTrue(snapshot.names.some((n) => n.includes(snapshotName))) + } finally { + await Sandbox.deleteSnapshot(snapshot.snapshotId) } -) +}) -sandboxTest.skipIf(isDebug)( +sandboxTest( 'list snapshots filtered by name', async ({ sandbox, sandboxTestId }) => { const snapshotName = `snap-filter-${sandboxTestId}` @@ -187,7 +178,7 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)('delete snapshot', async ({ sandbox }) => { +sandboxTest('delete snapshot', async ({ sandbox }) => { const snapshot = await sandbox.createSnapshot() // Delete should succeed @@ -199,7 +190,7 @@ sandboxTest.skipIf(isDebug)('delete snapshot', async ({ sandbox }) => { assert.isFalse(deletedAgain) }) -sandboxTest.skipIf(isDebug)( +sandboxTest( '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..d65d57d25d 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 { sandboxTest } from '../setup.js' -sandboxTest.skipIf(isDebug)( - 'pause and resume a sandbox', - async ({ sandbox }) => { - assert.isTrue(await sandbox.isRunning()) +sandboxTest('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)( + sandboxTest( '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,36 +49,33 @@ describe('pause and resume with env vars', () => { ) }) -sandboxTest.skipIf(isDebug)( - 'pause and resume a sandbox with file', - async ({ sandbox }) => { - const filename = 'test_snapshot.txt' - const content = 'This is a snapshot test file.' +sandboxTest('pause and resume a sandbox with file', async ({ sandbox }) => { + const filename = 'test_snapshot.txt' + const content = 'This is a snapshot test file.' - const info = await sandbox.files.write(filename, content) - assert.equal(info.name, filename) - assert.equal(info.type, 'file') - assert.equal(info.path, `/home/user/${filename}`) + const info = await sandbox.files.write(filename, content) + assert.equal(info.name, filename) + assert.equal(info.type, 'file') + assert.equal(info.path, `/home/user/${filename}`) - const exists = await sandbox.files.exists(filename) - assert.isTrue(exists) - const readContent = await sandbox.files.read(filename) - assert.equal(readContent, content) + const exists = await sandbox.files.exists(filename) + assert.isTrue(exists) + const readContent = await sandbox.files.read(filename) + assert.equal(readContent, content) - await sandbox.pause() - assert.isFalse(await sandbox.isRunning()) + await sandbox.pause() + assert.isFalse(await sandbox.isRunning()) - await sandbox.connect() - assert.isTrue(await sandbox.isRunning()) + await sandbox.connect() + assert.isTrue(await sandbox.isRunning()) - const exists2 = await sandbox.files.exists(filename) - assert.isTrue(exists2) - const readContent2 = await sandbox.files.read(filename) - assert.equal(readContent2, content) - } -) + const exists2 = await sandbox.files.exists(filename) + assert.isTrue(exists2) + const readContent2 = await sandbox.files.read(filename) + assert.equal(readContent2, content) +}) -sandboxTest.skipIf(isDebug)( +sandboxTest( 'pause and resume a sandbox with ongoing long running process', async ({ sandbox }) => { const cmd = await sandbox.commands.run('sleep 3600', { background: true }) @@ -105,7 +99,7 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( +sandboxTest( 'pause and resume a sandbox with completed long running process', async ({ sandbox }) => { const filename = 'test_long_running.txt' @@ -137,7 +131,7 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( +sandboxTest( 'pause and resume a sandbox with http server', async ({ sandbox }) => { await sandbox.commands.run('python3 -m http.server 8000', { @@ -163,7 +157,7 @@ sandboxTest.skipIf(isDebug)( } ) -sandboxTest.skipIf(isDebug)( +sandboxTest( '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..841d1b2cf2 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 { sandboxTest, wait } from '../setup.js' -sandboxTest.skipIf(isDebug)('shorten timeout', async ({ sandbox }) => { +sandboxTest('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) +sandboxTest('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 }) => { +sandboxTest('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..928268d81c --- /dev/null +++ b/packages/js-sdk/tests/sandbox/uploadMode.test.ts @@ -0,0 +1,143 @@ +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 { TemplateError } from '../../src/errors' +import { 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 = '0.6.4'): 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('0.5.6').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') +}) + +test('rejects metadata below ENVD_FILE_METADATA', async () => { + await expect( + sandbox('0.6.1').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..05d80a21e2 --- /dev/null +++ b/packages/js-sdk/tests/sandbox/versionGates.test.ts @@ -0,0 +1,85 @@ +import { assert, describe, expect, test } from 'vitest' + +import { ConnectionConfig, Sandbox } from '../../src' +import { SandboxError, TemplateError } from '../../src/errors' +import { TEST_API_KEY } from '../setup' + +/** + * The version gates reject unsupported options before any request leaves the + * SDK, so a sandbox handle pointed at a nonexistent domain is enough. + */ +function sandboxWithEnvd(envdVersion: string): Sandbox { + const config = new ConnectionConfig({ apiKey: TEST_API_KEY }) + return new Sandbox({ + ...config, + sandboxId: 'sbx-version-gate', + sandboxDomain: 'sandbox.e2b.dev', + envdVersion, + envdAccessToken: 'token', + }) +} + +describe('commands', () => { + test('rejects stdin:false below ENVD_COMMANDS_STDIN', async () => { + const sandbox = sandboxWithEnvd('0.2.4') + + await expect( + sandbox.commands.run('echo hello', { stdin: false }) + ).rejects.toThrowError(SandboxError) + }) + + test('reports the envd version in the error message', async () => { + const sandbox = sandboxWithEnvd('0.2.4') + + await sandbox.commands.run('echo hello', { stdin: false }).then( + () => assert.fail('expected the version gate to reject'), + (err: Error) => assert.include(err.message, '0.2.4') + ) + }) +}) + +describe('watchDir', () => { + const noop = () => {} + + test('rejects recursive below ENVD_VERSION_RECURSIVE_WATCH', async () => { + const sandbox = sandboxWithEnvd('0.1.3') + + 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('0.6.2') + + 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('0.6.3') + + 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 RPC and fails on the network + // instead — the point is that it is not a TemplateError. + const sandbox = sandboxWithEnvd('0.6.4') + + await sandbox.files + .watchDir('/home/user', noop, { + recursive: true, + includeEntry: true, + allowNetworkMounts: true, + requestTimeoutMs: 1_000, + }) + .then( + () => assert.fail('expected the request to fail without a sandbox'), + (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/js-sdk/tests/setup.ts b/packages/js-sdk/tests/setup.ts index 91d427c3cc..9789057a62 100644 --- a/packages/js-sdk/tests/setup.ts +++ b/packages/js-sdk/tests/setup.ts @@ -70,8 +70,10 @@ async function buildTemplate( export const sandboxTest = base.extend({ template, sandboxTestId: [ - // eslint-disable-next-line no-empty-pattern - async ({}, use) => { + async ({ skip }, use) => { + // Every sandboxTest provisions a real sandbox, so the whole fixture is + // opt-in — see the e2e tier in tests/README.md. + skip(!isE2E, E2E_SKIP_REASON) const id = `test-${generateRandomString()}` await use(id) }, @@ -143,8 +145,20 @@ export const volumeTest = base.extend({ ], }) +/** Runs against a local envd instead of a provisioned sandbox. */ export const isDebug = process.env.E2B_DEBUG !== undefined +/** Opt-in for the e2e tier: tests that need real infrastructure. */ +export const isE2E = process.env.E2B_E2E !== undefined + +const E2E_SKIP_REASON = 'set E2B_E2E=1 to run the e2e tier' + +/** A test that needs real infrastructure — skipped unless E2B_E2E is set. */ +export const e2eTest = base.skipIf(!isE2E) + +/** A template build against real infrastructure — skipped unless E2B_E2E is set. */ +export const e2eBuildTemplateTest = buildTemplateTest.skipIf(!isE2E) + /** Placeholder API key with a valid format for tests that don't hit the API. */ export const TEST_API_KEY = `e2b_${'0'.repeat(40)}` diff --git a/packages/js-sdk/tests/template/backgroundBuild.test.ts b/packages/js-sdk/tests/template/backgroundBuild.test.ts index 0e798b47cd..f8ad08f2d7 100644 --- a/packages/js-sdk/tests/template/backgroundBuild.test.ts +++ b/packages/js-sdk/tests/template/backgroundBuild.test.ts @@ -1,25 +1,30 @@ import { randomUUID } from 'node:crypto' -import { expect, test } from 'vitest' +import { expect } from 'vitest' import { Template, waitForTimeout } from '../../src' +import { e2eTest } from '../setup' -test('build template in background', async () => { - const template = Template() - .fromImage('ubuntu:22.04') - .skipCache() - .runCmd('sleep 5') // Add a delay to ensure build takes time - .setStartCmd('echo "Hello"', waitForTimeout(10_000)) +e2eTest( + 'build template in background', + async () => { + const template = Template() + .fromImage('ubuntu:22.04') + .skipCache() + .runCmd('sleep 5') // Add a delay to ensure build takes time + .setStartCmd('echo "Hello"', waitForTimeout(10_000)) - const name = `e2b-test:v1-${randomUUID()}` + const name = `e2b-test:v1-${randomUUID()}` - const buildInfo = await Template.buildInBackground(template, name, { - cpuCount: 1, - memoryMB: 1024, - }) + const buildInfo = await Template.buildInBackground(template, name, { + cpuCount: 1, + memoryMB: 1024, + }) - // Should return quickly (within a few seconds), not wait for the full build - expect(buildInfo).toBeDefined() + // Should return quickly (within a few seconds), not wait for the full build + expect(buildInfo).toBeDefined() - // Verify the build is actually running - const status = await Template.getBuildStatus(buildInfo) - expect(status.status).toEqual('building') -}, 10_000) + // Verify the build is actually running + const status = await Template.getBuildStatus(buildInfo) + expect(status.status).toEqual('building') + }, + 10_000 +) diff --git a/packages/js-sdk/tests/template/build.test.ts b/packages/js-sdk/tests/template/build.test.ts index 3e06263cee..0bdd42ead1 100644 --- a/packages/js-sdk/tests/template/build.test.ts +++ b/packages/js-sdk/tests/template/build.test.ts @@ -3,7 +3,7 @@ import os from 'node:os' import path from 'node:path' import { afterAll, beforeAll } from 'vitest' import { defaultBuildLogger, Template, waitForTimeout } from '../../src' -import { buildTemplateTest } from '../setup' +import { e2eBuildTemplateTest } from '../setup' // The file context lives in a temp directory so a test run never writes into // the repository tree. It is created in beforeAll rather than at module load so @@ -35,7 +35,7 @@ afterAll(() => { } }) -buildTemplateTest('build template', async ({ buildTemplate }) => { +e2eBuildTemplateTest('build template', async ({ buildTemplate }) => { const template = Template({ fileContextPath: contextPath }) // using base image to avoid re-building ubuntu:22.04 image .fromBaseImage() @@ -47,7 +47,7 @@ buildTemplateTest('build template', async ({ buildTemplate }) => { await buildTemplate(template, { skipCache: true }, defaultBuildLogger()) }) -buildTemplateTest( +e2eBuildTemplateTest( 'build template from base template', async ({ buildTemplate }) => { const template = Template().fromTemplate('base') @@ -55,17 +55,20 @@ buildTemplateTest( } ) -buildTemplateTest('build template with symlinks', async ({ buildTemplate }) => { - const template = Template({ fileContextPath: contextPath }) - .fromImage('ubuntu:22.04') - .skipCache() - .copy('folder/*', 'folder', { forceUpload: true }) - .runCmd('cat folder/symlink.txt') +e2eBuildTemplateTest( + 'build template with symlinks', + async ({ buildTemplate }) => { + const template = Template({ fileContextPath: contextPath }) + .fromImage('ubuntu:22.04') + .skipCache() + .copy('folder/*', 'folder', { forceUpload: true }) + .runCmd('cat folder/symlink.txt') - await buildTemplate(template) -}) + await buildTemplate(template) + } +) -buildTemplateTest( +e2eBuildTemplateTest( 'build template with resolveSymlinks', async ({ buildTemplate }) => { const template = Template({ fileContextPath: contextPath }) diff --git a/packages/js-sdk/tests/template/exists.test.ts b/packages/js-sdk/tests/template/exists.test.ts index aa62857c72..50112da0a7 100644 --- a/packages/js-sdk/tests/template/exists.test.ts +++ b/packages/js-sdk/tests/template/exists.test.ts @@ -1,13 +1,14 @@ import { randomUUID } from 'node:crypto' -import { expect, test } from 'vitest' +import { expect } from 'vitest' import { Template } from '../../src' +import { e2eTest } from '../setup' -test('check if base template name exists', async () => { +e2eTest('check if base template name exists', async () => { const exists = await Template.exists('base') expect(exists).toBe(true) }) -test('check non existing name', async () => { +e2eTest('check non existing name', async () => { const nonExistingName = `nonexistent-${randomUUID()}` const exists = await Template.exists(nonExistingName) expect(exists).toBe(false) diff --git a/packages/js-sdk/tests/template/methods/makeSymlink.test.ts b/packages/js-sdk/tests/template/methods/makeSymlink.test.ts index 67a1c816a7..27f7ae55a6 100644 --- a/packages/js-sdk/tests/template/methods/makeSymlink.test.ts +++ b/packages/js-sdk/tests/template/methods/makeSymlink.test.ts @@ -1,7 +1,7 @@ import { Template } from '../../../src' -import { buildTemplateTest } from '../../setup' +import { e2eBuildTemplateTest } from '../../setup' -buildTemplateTest('make symlink', async ({ buildTemplate }) => { +e2eBuildTemplateTest('make symlink', async ({ buildTemplate }) => { const template = Template() .fromImage('ubuntu:22.04') .skipCache() @@ -11,7 +11,7 @@ buildTemplateTest('make symlink', async ({ buildTemplate }) => { await buildTemplate(template) }) -buildTemplateTest('make symlink (force)', async ({ buildTemplate }) => { +e2eBuildTemplateTest('make symlink (force)', async ({ buildTemplate }) => { const template = Template() .fromImage('ubuntu:22.04') .makeSymlink('.bashrc', '.bashrc.local') diff --git a/packages/js-sdk/tests/template/methods/runCmd.test.ts b/packages/js-sdk/tests/template/methods/runCmd.test.ts index 340a226a74..6d3e26c1ab 100644 --- a/packages/js-sdk/tests/template/methods/runCmd.test.ts +++ b/packages/js-sdk/tests/template/methods/runCmd.test.ts @@ -1,8 +1,8 @@ import { expect } from 'vitest' import { Template } from '../../../src' -import { buildTemplateTest } from '../../setup' +import { e2eBuildTemplateTest } from '../../setup' -buildTemplateTest('run command', async ({ buildTemplate }) => { +e2eBuildTemplateTest('run command', async ({ buildTemplate }) => { const template = Template() .fromImage('ubuntu:22.04') .skipCache() @@ -11,7 +11,7 @@ buildTemplateTest('run command', async ({ buildTemplate }) => { await buildTemplate(template) }) -buildTemplateTest( +e2eBuildTemplateTest( 'run command as a different user', async ({ buildTemplate }) => { const template = Template() @@ -23,7 +23,7 @@ buildTemplateTest( } ) -buildTemplateTest( +e2eBuildTemplateTest( 'run command as user that does not exist', async ({ buildTemplate }) => { const template = Template() diff --git a/packages/js-sdk/tests/template/serialization.test.ts b/packages/js-sdk/tests/template/serialization.test.ts new file mode 100644 index 0000000000..a965176249 --- /dev/null +++ b/packages/js-sdk/tests/template/serialization.test.ts @@ -0,0 +1,109 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, assert, beforeAll, expect, test } from 'vitest' + +import { Template } from '../../src' +import { calculateFilesHash } from '../../src/template/utils' + +let contextPath: string + +beforeAll(async () => { + contextPath = await mkdtemp(join(tmpdir(), 'template-serialization-')) + await writeFile(join(contextPath, 'app.txt'), 'hello') + await writeFile(join(contextPath, 'other.txt'), 'hello') +}) + +afterAll(async () => { + await rm(contextPath, { recursive: true, force: true }) +}) + +const filesHash = (src: string, dest: string) => + calculateFilesHash(src, dest, contextPath, [], false, undefined) + +test('hash is stable and content-dependent', async () => { + const before = await filesHash('app.txt', '/app/') + assert.equal(await filesHash('app.txt', '/app/'), before) + + await writeFile(join(contextPath, 'app.txt'), 'hello again') + const after = await filesHash('app.txt', '/app/') + + assert.notEqual(after, before) + assert.match(after, /^[0-9a-f]{64}$/) +}) + +test('hash covers the source and destination paths', async () => { + // Identical content, different instruction — the hash seeds on `COPY src dest`. + assert.notEqual( + await filesHash('app.txt', '/app/'), + await filesHash('other.txt', '/app/') + ) + assert.notEqual( + await filesHash('app.txt', '/app/'), + await filesHash('app.txt', '/srv/') + ) +}) + +test('hashing a source that matches no file fails', async () => { + await expect(filesHash('nope.txt', '/app/')).rejects.toThrow() +}) + +test('serializes a build payload from the builder', async () => { + const template = Template({ fileContextPath: contextPath }) + .fromImage('ubuntu:22.04') + .runCmd('echo hello') + .setWorkdir('/app') + .setStartCmd('python main.py', 'curl -f http://localhost:8000') + + const payload = JSON.parse(await Template.toJSON(template, false)) + + assert.equal(payload.fromImage, 'ubuntu:22.04') + assert.equal(payload.startCmd, 'python main.py') + assert.equal(payload.readyCmd, 'curl -f http://localhost:8000') + assert.isUndefined(payload.fromTemplate) + assert.deepEqual( + payload.steps.map((step: { type: string }) => step.type), + ['RUN', 'WORKDIR'] + ) +}) + +test('serializes fromTemplate instead of fromImage', async () => { + const payload = JSON.parse( + await Template.toJSON(Template().fromTemplate('base')) + ) + + assert.equal(payload.fromTemplate, 'base') + assert.isUndefined(payload.fromImage) +}) + +test('serializes a registry config next to the image', async () => { + const template = Template().fromImage('registry.example.com/app:latest', { + username: 'user', + password: 'pass', + }) + + const payload = JSON.parse(await Template.toJSON(template)) + + assert.equal(payload.fromImage, 'registry.example.com/app:latest') + assert.equal(payload.fromImageRegistry.type, 'registry') + assert.equal(payload.fromImageRegistry.username, 'user') +}) + +test('computeHashes adds the copy hash to the payload', async () => { + const template = Template({ fileContextPath: contextPath }) + .fromImage('ubuntu:22.04') + .copy('app.txt', '/app/') + + const withoutHashes = JSON.parse(await Template.toJSON(template, false)) + const withHashes = JSON.parse(await Template.toJSON(template, true)) + + const copyStep = (payload: { + steps: { type: string; filesHash?: string }[] + }) => payload.steps.find((step) => step.type === 'COPY') + + assert.isUndefined(copyStep(withoutHashes)?.filesHash) + assert.equal( + copyStep(withHashes)?.filesHash, + await filesHash('app.txt', '/app/') + ) +}) diff --git a/packages/js-sdk/tests/template/tags.test.ts b/packages/js-sdk/tests/template/tags.test.ts index 21e2166709..bab1da320b 100644 --- a/packages/js-sdk/tests/template/tags.test.ts +++ b/packages/js-sdk/tests/template/tags.test.ts @@ -1,11 +1,10 @@ -import { randomUUID } from 'node:crypto' import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest' import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { Template } from '../../src' -import { apiUrl, buildTemplateTest, isDebug } from '../setup' +import { apiUrl } from '../setup' // Mock handlers for tag API endpoints const mockHandlers = [ @@ -121,82 +120,3 @@ describe('Template tags unit tests', () => { }) }) }) - -// Integration tests -buildTemplateTest.skipIf(isDebug)( - 'build template with tags, assign and delete', - { timeout: 300_000 }, - async ({ buildTemplate }) => { - const templateName = 'e2b-tags-test' - const initialTag = `${templateName}:v1-${randomUUID()}` - - // Build a template with initial tag - const template = Template().fromBaseImage() - const buildInfo = await buildTemplate(template, { name: initialTag }) - - expect(buildInfo.buildId).toBeTruthy() - expect(buildInfo.templateId).toBeTruthy() - - // Assign additional tags (just tag names, not full alias:tag format) - const tagInfo = await Template.assignTags(initialTag, [ - 'production', - 'latest', - ]) - - expect(tagInfo.buildId).toBeTruthy() - expect(tagInfo.tags).toContain('production') - expect(tagInfo.tags).toContain('latest') - } -) - -buildTemplateTest.skipIf(isDebug)( - 'assign single tag to existing template', - { timeout: 300_000 }, - async ({ buildTemplate }) => { - const templateName = 'e2b-tags-test' - const initialTag = `${templateName}:v1-${randomUUID()}` - - const template = Template().fromBaseImage() - await buildTemplate(template, { name: initialTag }) - - // Assign single tag (just tag name, not full alias:tag format) - const tagInfo = await Template.assignTags(initialTag, 'stable') - - expect(tagInfo.buildId).toBeTruthy() - expect(tagInfo.tags).toContain('stable') - } -) - -buildTemplateTest.skipIf(isDebug)( - 'rejects invalid tag format - missing alias', - { timeout: 300_000 }, - async ({ buildTemplate }) => { - const templateName = 'e2b-tags-test' - const initialTag = `${templateName}:v1-${randomUUID()}` - - const template = Template().fromBaseImage() - await buildTemplate(template, { name: initialTag }) - - // Tag without alias (starts with colon) should be rejected - await expect( - Template.assignTags(initialTag, ':invalid-tag') - ).rejects.toThrow() - } -) - -buildTemplateTest.skipIf(isDebug)( - 'rejects invalid tag format - missing tag', - { timeout: 300_000 }, - async ({ buildTemplate }) => { - const templateName = 'e2b-tags-test' - const initialTag = `${templateName}:v1-${randomUUID()}` - - const template = Template().fromBaseImage() - await buildTemplate(template, { name: initialTag }) - - // Tag without tag portion (ends with colon) should be rejected - await expect( - Template.assignTags(initialTag, `${templateName}:`) - ).rejects.toThrow() - } -) diff --git a/packages/js-sdk/tests/template/tagsBuild.test.ts b/packages/js-sdk/tests/template/tagsBuild.test.ts new file mode 100644 index 0000000000..c2a79a2b34 --- /dev/null +++ b/packages/js-sdk/tests/template/tagsBuild.test.ts @@ -0,0 +1,83 @@ +import { randomUUID } from 'node:crypto' +import { expect } from 'vitest' + +import { Template } from '../../src' +import { e2eBuildTemplateTest } from '../setup' + +e2eBuildTemplateTest( + 'build template with tags, assign and delete', + { timeout: 300_000 }, + async ({ buildTemplate }) => { + const templateName = 'e2b-tags-test' + const initialTag = `${templateName}:v1-${randomUUID()}` + + // Build a template with initial tag + const template = Template().fromBaseImage() + const buildInfo = await buildTemplate(template, { name: initialTag }) + + expect(buildInfo.buildId).toBeTruthy() + expect(buildInfo.templateId).toBeTruthy() + + // Assign additional tags (just tag names, not full alias:tag format) + const tagInfo = await Template.assignTags(initialTag, [ + 'production', + 'latest', + ]) + + expect(tagInfo.buildId).toBeTruthy() + expect(tagInfo.tags).toContain('production') + expect(tagInfo.tags).toContain('latest') + } +) + +e2eBuildTemplateTest( + 'assign single tag to existing template', + { timeout: 300_000 }, + async ({ buildTemplate }) => { + const templateName = 'e2b-tags-test' + const initialTag = `${templateName}:v1-${randomUUID()}` + + const template = Template().fromBaseImage() + await buildTemplate(template, { name: initialTag }) + + // Assign single tag (just tag name, not full alias:tag format) + const tagInfo = await Template.assignTags(initialTag, 'stable') + + expect(tagInfo.buildId).toBeTruthy() + expect(tagInfo.tags).toContain('stable') + } +) + +e2eBuildTemplateTest( + 'rejects invalid tag format - missing alias', + { timeout: 300_000 }, + async ({ buildTemplate }) => { + const templateName = 'e2b-tags-test' + const initialTag = `${templateName}:v1-${randomUUID()}` + + const template = Template().fromBaseImage() + await buildTemplate(template, { name: initialTag }) + + // Tag without alias (starts with colon) should be rejected + await expect( + Template.assignTags(initialTag, ':invalid-tag') + ).rejects.toThrow() + } +) + +e2eBuildTemplateTest( + 'rejects invalid tag format - missing tag', + { timeout: 300_000 }, + async ({ buildTemplate }) => { + const templateName = 'e2b-tags-test' + const initialTag = `${templateName}:v1-${randomUUID()}` + + const template = Template().fromBaseImage() + await buildTemplate(template, { name: initialTag }) + + // Tag without tag portion (ends with colon) should be rejected + await expect( + Template.assignTags(initialTag, `${templateName}:`) + ).rejects.toThrow() + } +) diff --git a/packages/js-sdk/vitest.config.mts b/packages/js-sdk/vitest.config.mts index 63439b0112..10bb0a1cc7 100644 --- a/packages/js-sdk/vitest.config.mts +++ b/packages/js-sdk/vitest.config.mts @@ -2,7 +2,10 @@ import { defineConfig } from 'vitest/config' import { playwright } from '@vitest/browser-playwright' import { config } from 'dotenv' +import { e2eFiles } from './tests/e2eFiles.mjs' + const env = config() + export default defineConfig({ test: { projects: [ @@ -14,6 +17,7 @@ export default defineConfig({ 'tests/runtimes/**', 'tests/template/**', 'tests/connectionConfig.test.ts', + ...e2eFiles, ], // Isolation is required: several suites patch global fetch via msw // and rely on module mocks (vi.doMock / vi.resetModules). Under @@ -38,6 +42,8 @@ export default defineConfig({ }, { test: { + // Provisions a real sandbox from a browser bundle, so it belongs to + // the e2e tier: run with `pnpm test:browser`. name: 'browser', include: ['tests/runtimes/browser/**/*.{test,spec}.tsx'], browser: { @@ -57,10 +63,31 @@ export default defineConfig({ test: { name: 'template', include: ['tests/template/**/*.test.ts'], + exclude: e2eFiles, + globals: false, + testTimeout: 180_000, + environment: 'node', + setupFiles: ['tests/globalFetchFallback.setup.ts'], + }, + }, + { + test: { + // Opt-in tier: run with `pnpm test:e2e` (needs E2B_E2E=1 and + // credentials). Excluded from the default `pnpm test` run. + name: 'e2e', + include: e2eFiles, + isolate: true, globals: false, testTimeout: 180_000, environment: 'node', setupFiles: ['tests/globalFetchFallback.setup.ts'], + deps: { + interopDefault: true, + }, + env: { + ...(process.env as Record), + ...env.parsed, + }, }, }, { diff --git a/packages/python-sdk/pytest.ini b/packages/python-sdk/pytest.ini index bb043ba0ad..3bd3c7306b 100644 --- a/packages/python-sdk/pytest.ini +++ b/packages/python-sdk/pytest.ini @@ -2,11 +2,14 @@ [pytest] markers = skip_debug: skip test if E2B_DEBUG is set. + e2e: test needs live infrastructure (sandboxes, template builds) and credentials; excluded by default, run with `-m e2e`. asyncio_mode=auto asyncio_default_fixture_loop_scope=session asyncio_default_test_loop_scope=session -addopts = "--import-mode=importlib" +# The default tier is fully mocked: the e2e marker (see tests/conftest.py) is +# excluded unless the run asks for it with `-m e2e`, which overrides this. +addopts = --import-mode=importlib -m "not e2e" # Makes shared test helpers (e.g. envd_frame_server) importable under importlib mode. pythonpath = tests timeout = 30 diff --git a/packages/python-sdk/tests/README.md b/packages/python-sdk/tests/README.md new file mode 100644 index 0000000000..bae2d4a058 --- /dev/null +++ b/packages/python-sdk/tests/README.md @@ -0,0 +1,43 @@ +# Python SDK tests + +The suite has two tiers. + +## Unit tier (default) + +```bash +uv run pytest +``` + +Fully mocked (`httpx.MockTransport` and monkeypatched generated API modules), +deterministic, no sandboxes, no credentials, seconds to run. It asserts on +client-side logic: request payload shaping, config propagation, version gating, +response parsing and format switching, RPC/API error mapping, pagination, URL +construction and pure utilities. + +`pytest.ini` sets `addopts = -m "not e2e"`, so the e2e tier is excluded unless +you ask for it. + +## E2E tier (opt-in) + +```bash +E2B_API_KEY=e2b_... uv run pytest -m e2e +``` + +Everything whose assertions depend on real behavior across the RPC boundary — +process execution, filesystem round-trips, PTY semantics, git inside the VM, +sandbox lifecycle against live infrastructure and server-side template builds. +It provisions sandboxes and builds templates, so it needs an API key. + +Tests land in this tier automatically when they use one of the live fixtures +(`sandbox`, `sandbox_factory`, `async_sandbox`, `async_sandbox_factory`, `build`, +`async_build`) — see `pytest_collection_modifyitems` in +[`conftest.py`](./conftest.py). A test that calls live APIs without such a +fixture needs an explicit `@pytest.mark.e2e`. + +`skip_debug`/`E2B_DEBUG` is a separate axis: it points the SDK at a local envd +instead of a provisioned sandbox and does not enable or disable either tier. + +## CI + +`SDK Tests` runs the unit tier on every PR. The e2e tier runs in the opt-in +`SDK E2E Tests` workflow — add the `e2e` label to a PR or dispatch it manually. 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..143d991c38 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_read_format.py @@ -0,0 +1,100 @@ +"""Async counterpart of `tests/sync/sandbox_sync/test_read_format.py`.""" + +from typing import List + +import httpx +import pytest +from packaging.version import Version + +import e2b.sandbox_async.filesystem.filesystem as filesystem_module +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 = "0.6.4", + 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="0.3.9") + + 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..f1beb96fda --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_upload_mode.py @@ -0,0 +1,108 @@ +"""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 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 = "0.6.4", +) -> 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="0.5.6") + + 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..b10925f4a6 --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_version_gates.py @@ -0,0 +1,69 @@ +"""Async counterpart of `tests/sync/sandbox_sync/test_version_gates.py`.""" + +import httpx +import pytest +from packaging.version import Version + +from e2b.connection_config import ConnectionConfig +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("0.2.9", 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("0.1.3", 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("0.6.2", 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("0.6.3", 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("0.6.1", 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/async/template_async/test_background_build.py b/packages/python-sdk/tests/async/template_async/test_background_build.py index 16690c7232..e7f130bdfe 100644 --- a/packages/python-sdk/tests/async/template_async/test_background_build.py +++ b/packages/python-sdk/tests/async/template_async/test_background_build.py @@ -5,6 +5,7 @@ from e2b import AsyncTemplate, wait_for_timeout +@pytest.mark.e2e @pytest.mark.skip_debug() @pytest.mark.timeout(10) async def test_build_in_background_should_start_build_and_return_info(): diff --git a/packages/python-sdk/tests/async/template_async/test_exists.py b/packages/python-sdk/tests/async/template_async/test_exists.py index 6da5609470..88338d5473 100644 --- a/packages/python-sdk/tests/async/template_async/test_exists.py +++ b/packages/python-sdk/tests/async/template_async/test_exists.py @@ -5,6 +5,7 @@ from e2b import AsyncTemplate +@pytest.mark.e2e @pytest.mark.skip_debug() async def test_check_base_template_name_exists(): """Test that the base template name exists.""" @@ -12,6 +13,7 @@ async def test_check_base_template_name_exists(): assert exists is True +@pytest.mark.e2e @pytest.mark.skip_debug() async def test_check_non_existing_name(): """Test that a non-existing name returns False.""" diff --git a/packages/python-sdk/tests/conftest.py b/packages/python-sdk/tests/conftest.py index fa8f4a0949..c447741f3b 100644 --- a/packages/python-sdk/tests/conftest.py +++ b/packages/python-sdk/tests/conftest.py @@ -40,6 +40,31 @@ def test_api_key() -> str: return "e2b_" + "0" * 40 +# Fixtures that provision live infrastructure: a sandbox on real compute or a +# server-side template build. Any test requesting one belongs to the e2e tier, +# which `pytest.ini` excludes by default (`-m "not e2e"`); run it with +# `pytest -m e2e` and credentials. Tests that reach the control plane without +# these fixtures carry an explicit `@pytest.mark.e2e`. +E2E_FIXTURES = frozenset( + { + "sandbox", + "sandbox_factory", + "async_sandbox", + "async_sandbox_factory", + "build", + "async_build", + } +) + + +def pytest_collection_modifyitems(items): + for item in items: + if isinstance(item, pytest.Function) and not E2E_FIXTURES.isdisjoint( + item.fixturenames + ): + item.add_marker(pytest.mark.e2e) + + @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): outcome = yield 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..38b63eb6f1 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_read_format.py @@ -0,0 +1,104 @@ +"""`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 + +import e2b.sandbox_sync.filesystem.filesystem as filesystem_module +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 = "0.6.4", + 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="0.3.9") + + 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..6d98aa99c0 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_upload_mode.py @@ -0,0 +1,110 @@ +"""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 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 = "0.6.4", +) -> 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="0.5.6") + + 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..d41671cc9c --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_version_gates.py @@ -0,0 +1,70 @@ +"""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 e2b.connection_config import ConnectionConfig +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("0.2.9", 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("0.1.3", 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("0.6.2", 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("0.6.3", 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("0.6.1", test_api_key) + + with pytest.raises(TemplateException, match="File metadata requires"): + filesystem.write("/home/user/a.txt", "hello", metadata={"key": "value"}) diff --git a/packages/python-sdk/tests/sync/template_sync/test_background_build.py b/packages/python-sdk/tests/sync/template_sync/test_background_build.py index f5d41db4e4..30f3b3faf8 100644 --- a/packages/python-sdk/tests/sync/template_sync/test_background_build.py +++ b/packages/python-sdk/tests/sync/template_sync/test_background_build.py @@ -5,6 +5,7 @@ from e2b import Template, wait_for_timeout +@pytest.mark.e2e @pytest.mark.skip_debug() @pytest.mark.timeout(10) def test_build_in_background_should_start_build_and_return_info(): diff --git a/packages/python-sdk/tests/sync/template_sync/test_exists.py b/packages/python-sdk/tests/sync/template_sync/test_exists.py index 641b58ba08..fd0123fa19 100644 --- a/packages/python-sdk/tests/sync/template_sync/test_exists.py +++ b/packages/python-sdk/tests/sync/template_sync/test_exists.py @@ -5,6 +5,7 @@ from e2b import Template +@pytest.mark.e2e @pytest.mark.skip_debug() def test_check_base_template_name_exists(): """Test that the base template name exists.""" @@ -12,6 +13,7 @@ def test_check_base_template_name_exists(): assert exists is True +@pytest.mark.e2e @pytest.mark.skip_debug() def test_check_non_existing_name(): """Test that a non-existing name returns False.""" diff --git a/packages/python-sdk/tests/sync/template_sync/test_serialization.py b/packages/python-sdk/tests/sync/template_sync/test_serialization.py new file mode 100644 index 0000000000..fb0d5f74dc --- /dev/null +++ b/packages/python-sdk/tests/sync/template_sync/test_serialization.py @@ -0,0 +1,104 @@ +"""Template payload serialization and copy-file hashing — pure client logic. + +Mirrors `tests/template/serialization.test.ts` in the JS SDK: no build is +started, only the JSON the SDK would send and the hash it derives from the +local file context. +""" + +import json +from pathlib import Path + +import pytest + +from e2b import Template +from e2b.template.utils import calculate_files_hash + + +@pytest.fixture() +def context_path(tmp_path: Path) -> Path: + (tmp_path / "app.txt").write_text("hello") + (tmp_path / "other.txt").write_text("hello") + return tmp_path + + +def _files_hash(context_path: Path, src: str, dest: str) -> str: + return calculate_files_hash(src, dest, str(context_path), [], False, None) + + +def test_hash_is_stable_and_content_dependent(context_path: Path): + before = _files_hash(context_path, "app.txt", "/app/") + assert _files_hash(context_path, "app.txt", "/app/") == before + + (context_path / "app.txt").write_text("hello again") + after = _files_hash(context_path, "app.txt", "/app/") + + assert after != before + assert len(after) == 64 + assert set(after) <= set("0123456789abcdef") + + +def test_hash_covers_the_source_and_destination_paths(context_path: Path): + # Identical content, different instruction — the hash seeds on `COPY src dest`. + assert _files_hash(context_path, "app.txt", "/app/") != _files_hash( + context_path, "other.txt", "/app/" + ) + assert _files_hash(context_path, "app.txt", "/app/") != _files_hash( + context_path, "app.txt", "/srv/" + ) + + +def test_hashing_a_source_that_matches_no_file_fails(context_path: Path): + with pytest.raises(ValueError): + _files_hash(context_path, "nope.txt", "/app/") + + +def test_serializes_a_build_payload_from_the_builder(context_path: Path): + template = ( + Template(file_context_path=context_path) + .from_image("ubuntu:22.04") + .run_cmd("echo hello") + .set_workdir("/app") + .set_start_cmd("python main.py", "curl -f http://localhost:8000") + ) + + payload = json.loads(Template.to_json(template)) + + assert payload["fromImage"] == "ubuntu:22.04" + assert payload["startCmd"] == "python main.py" + assert payload["readyCmd"] == "curl -f http://localhost:8000" + assert payload.get("fromTemplate") is None + assert [step["type"] for step in payload["steps"]] == ["RUN", "WORKDIR"] + + +def test_serializes_from_template_instead_of_from_image(): + payload = json.loads(Template.to_json(Template().from_template("base"))) + + assert payload["fromTemplate"] == "base" + assert payload.get("fromImage") is None + + +def test_serializes_a_registry_config_next_to_the_image(): + template = Template().from_image( + "registry.example.com/app:latest", + username="user", + password="pass", + ) + + payload = json.loads(Template.to_json(template)) + + assert payload["fromImage"] == "registry.example.com/app:latest" + assert payload["fromImageRegistry"]["type"] == "registry" + assert payload["fromImageRegistry"]["username"] == "user" + + +def test_copy_step_carries_the_files_hash(context_path: Path): + template = ( + Template(file_context_path=context_path) + .from_image("ubuntu:22.04") + .copy("app.txt", "/app/") + ) + + payload = json.loads(Template.to_json(template)) + copy_step = next(step for step in payload["steps"] if step["type"] == "COPY") + + assert copy_step["filesHash"] == _files_hash(context_path, "app.txt", "/app/") From 2004d2fc05e6346ac964e9aee09bec023ec4daab Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:16:56 +0000 Subject: [PATCH 2/2] test: address review on the unit/e2e test tier split Co-Authored-By: mish@e2b.dev --- packages/cli/package.json | 2 +- packages/cli/tests/setup.ts | 4 +- packages/cli/vitest.e2e.config.mts | 17 ++ packages/js-sdk/package.json | 4 +- packages/js-sdk/tests/README.md | 12 +- packages/js-sdk/tests/api/info.test.ts | 4 +- packages/js-sdk/tests/api/kill.test.ts | 23 +-- packages/js-sdk/tests/api/list.test.ts | 152 +++++++++-------- packages/js-sdk/tests/api/snapshot.test.ts | 6 +- packages/js-sdk/tests/e2eFiles.mts | 1 + .../tests/sandbox/commands/envVars.test.ts | 4 +- .../commands/sandboxKilledDuringRun.test.ts | 4 +- packages/js-sdk/tests/sandbox/connect.test.ts | 12 +- packages/js-sdk/tests/sandbox/create.test.ts | 8 +- .../tests/sandbox/files/signing.test.ts | 161 ++++++++++-------- packages/js-sdk/tests/sandbox/fork.test.ts | 10 +- packages/js-sdk/tests/sandbox/host.test.ts | 4 +- .../tests/sandbox/internetAccess.test.ts | 8 +- packages/js-sdk/tests/sandbox/kill.test.ts | 4 +- packages/js-sdk/tests/sandbox/metrics.test.ts | 6 +- packages/js-sdk/tests/sandbox/network.test.ts | 69 ++++---- .../js-sdk/tests/sandbox/readFormat.test.ts | 19 ++- packages/js-sdk/tests/sandbox/secure.test.ts | 87 +--------- .../tests/sandbox/secureSignature.test.ts | 87 ++++++++++ .../js-sdk/tests/sandbox/snapshot-api.test.ts | 70 ++++---- .../js-sdk/tests/sandbox/snapshot.test.ts | 57 ++++--- packages/js-sdk/tests/sandbox/timeout.test.ts | 8 +- .../js-sdk/tests/sandbox/uploadMode.test.ts | 27 ++- .../js-sdk/tests/sandbox/versionGates.test.ts | 58 +++++-- packages/js-sdk/tests/setup.ts | 27 ++- .../tests/template/serialization.test.ts | 7 +- packages/js-sdk/tests/volume/mount.test.ts | 39 +++++ .../js-sdk/tests/volume/mountPayload.test.ts | 56 ++++++ packages/js-sdk/vitest.config.mts | 4 + packages/python-sdk/pytest.ini | 1 + packages/python-sdk/tests/README.md | 9 +- .../async/sandbox_async/test_read_format.py | 15 +- .../async/sandbox_async/test_upload_mode.py | 14 +- .../async/sandbox_async/test_version_gates.py | 25 ++- .../async/template_async/test_stacktrace.py | 5 + .../tests/async/volume_async/test_mount.py | 35 ++++ packages/python-sdk/tests/conftest.py | 13 +- packages/python-sdk/tests/envd_versions.py | 13 ++ .../tests/shared/volume/test_mount_payload.py | 77 +++++++++ .../sync/sandbox_sync/test_read_format.py | 15 +- .../sync/sandbox_sync/test_upload_mode.py | 14 +- .../sync/sandbox_sync/test_version_gates.py | 25 ++- .../sync/template_sync/test_serialization.py | 12 +- .../sync/template_sync/test_stacktrace.py | 5 + .../tests/sync/volume_sync/test_mount.py | 33 ++++ 50 files changed, 939 insertions(+), 433 deletions(-) create mode 100644 packages/cli/vitest.e2e.config.mts create mode 100644 packages/js-sdk/tests/sandbox/secureSignature.test.ts create mode 100644 packages/js-sdk/tests/volume/mount.test.ts create mode 100644 packages/js-sdk/tests/volume/mountPayload.test.ts create mode 100644 packages/python-sdk/tests/async/volume_async/test_mount.py create mode 100644 packages/python-sdk/tests/envd_versions.py create mode 100644 packages/python-sdk/tests/shared/volume/test_mount_payload.py create mode 100644 packages/python-sdk/tests/sync/volume_sync/test_mount.py diff --git a/packages/cli/package.json b/packages/cli/package.json index 560ccdddbe..4ac9a7aebe 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -43,7 +43,7 @@ "format": "prettier --write src", "test:interactive": "pnpm build && ./dist/index.js", "test": "vitest run", - "test:e2e": "E2B_E2E=1 vitest run", + "test:e2e": "vitest run --config vitest.e2e.config.mts", "test:watch": "vitest watch", "test:coverage": "vitest run --coverage", "check-deps": "knip" diff --git a/packages/cli/tests/setup.ts b/packages/cli/tests/setup.ts index 0216d9f65d..ed6455e6a7 100644 --- a/packages/cli/tests/setup.ts +++ b/packages/cli/tests/setup.ts @@ -29,11 +29,13 @@ function safeGetUserConfig(): UserConfigWithDomain | null { const userConfig = safeGetUserConfig() +const DEFAULT_E2E_DOMAIN = 'e2b.dev' + export const e2eDomain = process.env.E2B_DOMAIN || userConfig?.E2B_DOMAIN || userConfig?.domain || - 'e2b.app' + DEFAULT_E2E_DOMAIN export const e2eApiKey = process.env.E2B_API_KEY || userConfig?.projectApiKey diff --git a/packages/cli/vitest.e2e.config.mts b/packages/cli/vitest.e2e.config.mts new file mode 100644 index 0000000000..fd01f141cc --- /dev/null +++ b/packages/cli/vitest.e2e.config.mts @@ -0,0 +1,17 @@ +import { defineConfig, mergeConfig } from 'vitest/config' + +import base from './vitest.config' + +// Opt-in tier: `pnpm test:e2e`. The flag the tests gate on is set here rather +// than in the package script, which would need POSIX-only `VAR=value` syntax +// and break on Windows. +export default mergeConfig( + base, + defineConfig({ + test: { + env: { + E2B_E2E: '1', + }, + }, + }) +) diff --git a/packages/js-sdk/package.json b/packages/js-sdk/package.json index 1bb79594b5..3ea0166d65 100644 --- a/packages/js-sdk/package.json +++ b/packages/js-sdk/package.json @@ -28,8 +28,8 @@ "dev": "tsdown --watch", "example": "tsx example.mts", "test": "vitest run --project unit --project template --project connectionConfig", - "test:e2e": "E2B_E2E=1 vitest run --project e2e", - "test:browser": "E2B_E2E=1 vitest run --project browser", + "test:e2e": "vitest run --project e2e", + "test:browser": "vitest run --project browser", "generate": "npm-run-all generate:* && pnpm run format", "generate:api": "redocly bundle js-sdk --config ../../redocly.yaml -o ../../spec/openapi_generated.js-sdk.yml && openapi-typescript ../../spec/openapi_generated.js-sdk.yml -x api_key --array-length --alphabetize --default-non-nullable false --output src/api/schema.gen.ts", "generate:envd": "cd ../../spec/envd && buf generate --template buf-js.gen.yaml\n", diff --git a/packages/js-sdk/tests/README.md b/packages/js-sdk/tests/README.md index efb9f2c044..45401758a1 100644 --- a/packages/js-sdk/tests/README.md +++ b/packages/js-sdk/tests/README.md @@ -34,11 +34,21 @@ The file list lives in [`e2eFiles.mts`](./e2eFiles.mts) and drives both the `e2e` project and the exclusions of the default projects, so a new behavioral test only needs to be added there. Use `e2eTest`, `e2eBuildTemplateTest` or the `sandboxTest` fixture from [`setup.ts`](./setup.ts) — all three skip unless -`E2B_E2E` is set. +`E2B_E2E` is set — and their `hostedTest`/`hostedSandboxTest` variants when a +local envd can't stand in for the real thing (control plane, traffic proxy, +snapshots), which additionally skip under `E2B_DEBUG`. `E2B_DEBUG` is a separate axis: it points the SDK at a local envd instead of a provisioned sandbox and does not enable or disable either tier. +## Where a module's tests land + +Volume and Secret are entirely request-shaping, error mapping and pagination, so +they sit in the unit tier over an in-memory mock of their APIs. The one +exception is real mount content: `volume/mount.test.ts` writes through a mounted +volume in one sandbox and reads it back in another, which only a live mount can +exercise. + ## CI `SDK Tests` runs the unit tier on every PR. The e2e tier runs in the opt-in diff --git a/packages/js-sdk/tests/api/info.test.ts b/packages/js-sdk/tests/api/info.test.ts index fa8c381acd..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 } from '../setup.js' +import { hostedSandboxTest } from '../setup.js' import { Sandbox } from '../../src' -sandboxTest('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 fb89383883..7fdd2b887e 100644 --- a/packages/js-sdk/tests/api/kill.test.ts +++ b/packages/js-sdk/tests/api/kill.test.ts @@ -1,18 +1,21 @@ import { expect } from 'vitest' -import { sandboxTest } from '../setup.js' +import { hostedSandboxTest } from '../setup.js' import { Sandbox } from '../../src' -sandboxTest('kill existing sandbox', async ({ sandbox, sandboxTestId }) => { - await Sandbox.kill(sandbox.sandboxId) +hostedSandboxTest( + 'kill existing sandbox', + async ({ sandbox, sandboxTestId }) => { + await Sandbox.kill(sandbox.sandboxId) - const paginator = Sandbox.list({ - query: { state: ['running'], metadata: { sandboxTestId } }, - }) - const sandboxes = await paginator.nextItems() - expect(sandboxes.map((s) => s.sandboxId)).not.toContain(sandbox.sandboxId) -}) + const paginator = Sandbox.list({ + query: { state: ['running'], metadata: { sandboxTestId } }, + }) + const sandboxes = await paginator.nextItems() + expect(sandboxes.map((s) => s.sandboxId)).not.toContain(sandbox.sandboxId) + } +) -sandboxTest('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 1ab9234178..9e4e6d3dda 100644 --- a/packages/js-sdk/tests/api/list.test.ts +++ b/packages/js-sdk/tests/api/list.test.ts @@ -2,9 +2,9 @@ import { assert } from 'vitest' import { randomUUID } from 'crypto' import { Sandbox, SandboxInfo } from '../../src' -import { sandboxTest } from '../setup.js' +import { hostedSandboxTest } from '../setup.js' -sandboxTest('list sandboxes', async ({ sandbox, sandboxTestId }) => { +hostedSandboxTest('list sandboxes', async ({ sandbox, sandboxTestId }) => { const paginator = Sandbox.list({ query: { metadata: { sandboxTestId } }, }) @@ -16,7 +16,7 @@ sandboxTest('list sandboxes', async ({ sandbox, sandboxTestId }) => { assert.isTrue(found) }) -sandboxTest('list sandboxes with filter', async () => { +hostedSandboxTest('list sandboxes with filter', async () => { const uniqueId = randomUUID() const extraSbx = await Sandbox.create({ metadata: { uniqueId } }) @@ -33,7 +33,7 @@ sandboxTest('list sandboxes with filter', async () => { } }) -sandboxTest('list running sandboxes', async ({ sandboxTestId }) => { +hostedSandboxTest('list running sandboxes', async ({ sandboxTestId }) => { const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) try { @@ -54,7 +54,7 @@ sandboxTest('list running sandboxes', async ({ sandboxTestId }) => { } }) -sandboxTest('list paused sandboxes', async ({ sandboxTestId }) => { +hostedSandboxTest('list paused sandboxes', async ({ sandboxTestId }) => { // Create and pause a sandbox const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) await extraSbx.betaPause() @@ -77,7 +77,7 @@ sandboxTest('list paused sandboxes', async ({ sandboxTestId }) => { } }) -sandboxTest( +hostedSandboxTest( 'paginate running sandboxes', async ({ sandbox, sandboxTestId }) => { // Create extra sandboxes @@ -113,43 +113,46 @@ sandboxTest( } ) -sandboxTest('paginate paused sandboxes', async ({ sandbox, sandboxTestId }) => { - await sandbox.betaPause() +hostedSandboxTest( + 'paginate paused sandboxes', + async ({ sandbox, sandboxTestId }) => { + await sandbox.betaPause() - // Create extra paused sandbox - const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) - await extraSbx.betaPause() + // Create extra paused sandbox + const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) + await extraSbx.betaPause() - try { - // Test pagination with limit - const paginator = Sandbox.list({ - limit: 1, - query: { metadata: { sandboxTestId }, state: ['paused'] }, - }) - const sandboxes = await paginator.nextItems() + try { + // Test pagination with limit + const paginator = Sandbox.list({ + limit: 1, + query: { metadata: { sandboxTestId }, state: ['paused'] }, + }) + const sandboxes = await paginator.nextItems() - // Check first page - assert.equal(sandboxes.length, 1) - assert.equal(sandboxes[0].state, 'paused') - assert.isTrue(paginator.hasNext) - assert.notEqual(paginator.nextToken, undefined) - assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId) + // Check first page + assert.equal(sandboxes.length, 1) + assert.equal(sandboxes[0].state, 'paused') + assert.isTrue(paginator.hasNext) + assert.notEqual(paginator.nextToken, undefined) + assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId) - // Get second page - const sandboxes2 = await paginator.nextItems() + // Get second page + const sandboxes2 = await paginator.nextItems() - // Check second page - assert.equal(sandboxes2.length, 1) - assert.equal(sandboxes2[0].state, 'paused') - assert.isFalse(paginator.hasNext) - assert.equal(paginator.nextToken, undefined) - assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId) - } finally { - await extraSbx.kill() + // Check second page + assert.equal(sandboxes2.length, 1) + assert.equal(sandboxes2[0].state, 'paused') + assert.isFalse(paginator.hasNext) + assert.equal(paginator.nextToken, undefined) + assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId) + } finally { + await extraSbx.kill() + } } -}) +) -sandboxTest( +hostedSandboxTest( 'paginate running and paused sandboxes', async ({ sandbox, sandboxTestId }) => { // Create extra sandbox @@ -191,7 +194,7 @@ sandboxTest( } ) -sandboxTest('paginate iterator', async ({ sandbox, sandboxTestId }) => { +hostedSandboxTest('paginate iterator', async ({ sandbox, sandboxTestId }) => { const paginator = Sandbox.list({ query: { metadata: { sandboxTestId } }, }) @@ -206,7 +209,7 @@ sandboxTest('paginate iterator', async ({ sandbox, sandboxTestId }) => { assert.isTrue(sandboxes.some((s) => s.sandboxId === sandbox.sandboxId)) }) -sandboxTest('list sandboxes', async ({ sandbox, sandboxTestId }) => { +hostedSandboxTest('list sandboxes', async ({ sandbox, sandboxTestId }) => { const paginator = Sandbox.list({ query: { metadata: { sandboxTestId } }, }) @@ -218,7 +221,7 @@ sandboxTest('list sandboxes', async ({ sandbox, sandboxTestId }) => { assert.isTrue(found) }) -sandboxTest('list sandboxes with filter', async () => { +hostedSandboxTest('list sandboxes with filter', async () => { const uniqueId = randomUUID() const extraSbx = await Sandbox.create({ metadata: { uniqueId } }) @@ -235,7 +238,7 @@ sandboxTest('list sandboxes with filter', async () => { } }) -sandboxTest('list running sandboxes', async ({ sandboxTestId }) => { +hostedSandboxTest('list running sandboxes', async ({ sandboxTestId }) => { const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) try { @@ -256,7 +259,7 @@ sandboxTest('list running sandboxes', async ({ sandboxTestId }) => { } }) -sandboxTest('list paused sandboxes', async ({ sandboxTestId }) => { +hostedSandboxTest('list paused sandboxes', async ({ sandboxTestId }) => { // Create and pause a sandbox const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) await Sandbox.betaPause(extraSbx.sandboxId) @@ -279,7 +282,7 @@ sandboxTest('list paused sandboxes', async ({ sandboxTestId }) => { } }) -sandboxTest( +hostedSandboxTest( 'paginate running sandboxes', async ({ sandbox, sandboxTestId }) => { // Create extra sandboxes @@ -315,43 +318,46 @@ sandboxTest( } ) -sandboxTest('paginate paused sandboxes', async ({ sandbox, sandboxTestId }) => { - await Sandbox.betaPause(sandbox.sandboxId) +hostedSandboxTest( + 'paginate paused sandboxes', + async ({ sandbox, sandboxTestId }) => { + await Sandbox.betaPause(sandbox.sandboxId) - // Create extra paused sandbox - const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) - await Sandbox.betaPause(extraSbx.sandboxId) + // Create extra paused sandbox + const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } }) + await Sandbox.betaPause(extraSbx.sandboxId) - try { - // Test pagination with limit - const paginator = Sandbox.list({ - limit: 1, - query: { metadata: { sandboxTestId }, state: ['paused'] }, - }) - const sandboxes = await paginator.nextItems() + try { + // Test pagination with limit + const paginator = Sandbox.list({ + limit: 1, + query: { metadata: { sandboxTestId }, state: ['paused'] }, + }) + const sandboxes = await paginator.nextItems() - // Check first page - assert.equal(sandboxes.length, 1) - assert.equal(sandboxes[0].state, 'paused') - assert.isTrue(paginator.hasNext) - assert.notEqual(paginator.nextToken, undefined) - assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId) + // Check first page + assert.equal(sandboxes.length, 1) + assert.equal(sandboxes[0].state, 'paused') + assert.isTrue(paginator.hasNext) + assert.notEqual(paginator.nextToken, undefined) + assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId) - // Get second page - const sandboxes2 = await paginator.nextItems() + // Get second page + const sandboxes2 = await paginator.nextItems() - // Check second page - assert.equal(sandboxes2.length, 1) - assert.equal(sandboxes2[0].state, 'paused') - assert.isFalse(paginator.hasNext) - assert.equal(paginator.nextToken, undefined) - assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId) - } finally { - await extraSbx.kill() + // Check second page + assert.equal(sandboxes2.length, 1) + assert.equal(sandboxes2[0].state, 'paused') + assert.isFalse(paginator.hasNext) + assert.equal(paginator.nextToken, undefined) + assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId) + } finally { + await extraSbx.kill() + } } -}) +) -sandboxTest( +hostedSandboxTest( 'paginate running and paused sandboxes', async ({ sandbox, sandboxTestId }) => { // Create extra sandbox @@ -394,7 +400,7 @@ sandboxTest( } ) -sandboxTest('paginate iterator', async ({ sandbox, sandboxTestId }) => { +hostedSandboxTest('paginate iterator', async ({ sandbox, sandboxTestId }) => { const paginator = Sandbox.list({ query: { metadata: { sandboxTestId } }, }) diff --git a/packages/js-sdk/tests/api/snapshot.test.ts b/packages/js-sdk/tests/api/snapshot.test.ts index f5997f2d70..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 } from '../setup.js' +import { hostedSandboxTest } from '../setup.js' import { Sandbox } from '../../src' -sandboxTest('pause sandbox', async ({ sandbox }) => { +hostedSandboxTest('pause sandbox', async ({ sandbox }) => { await Sandbox.pause(sandbox.sandboxId) assert.isFalse( await sandbox.isRunning(), @@ -11,7 +11,7 @@ sandboxTest('pause sandbox', async ({ sandbox }) => { ) }) -sandboxTest('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/e2eFiles.mts b/packages/js-sdk/tests/e2eFiles.mts index e3aa330577..a7cd4fbd6e 100644 --- a/packages/js-sdk/tests/e2eFiles.mts +++ b/packages/js-sdk/tests/e2eFiles.mts @@ -17,5 +17,6 @@ export const e2eFiles = [ 'tests/sandbox/pty/**/*.test.ts', 'tests/sandbox/{connect,create,fork,host,internetAccess,kill,lifecycleBehavior,metrics,network,secure,snapshot,snapshot-api,timeout}.test.ts', 'tests/template/{backgroundBuild,build,exists,tagsBuild}.test.ts', + 'tests/volume/mount.test.ts', 'tests/template/methods/{makeSymlink,runCmd}.test.ts', ] diff --git a/packages/js-sdk/tests/sandbox/commands/envVars.test.ts b/packages/js-sdk/tests/sandbox/commands/envVars.test.ts index f3fdced7f4..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 } from '../../setup.js' +import { hostedSandboxTest, sandboxTest } from '../../setup.js' describe('sandbox global env vars', () => { sandboxTest.override({ @@ -9,7 +9,7 @@ describe('sandbox global env vars', () => { }, }) - sandboxTest('sandbox global env vars', async ({ sandbox }) => { + hostedSandboxTest('sandbox global env vars', async ({ sandbox }) => { const cmd = await sandbox.commands.run('echo $FOO') assert.equal(cmd.exitCode, 0) diff --git a/packages/js-sdk/tests/sandbox/commands/sandboxKilledDuringRun.test.ts b/packages/js-sdk/tests/sandbox/commands/sandboxKilledDuringRun.test.ts index 4d57ca0c88..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 } from '../../setup.js' +import { hostedSandboxTest } from '../../setup.js' -sandboxTest( +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 58012af58a..97fb21abd2 100644 --- a/packages/js-sdk/tests/sandbox/connect.test.ts +++ b/packages/js-sdk/tests/sandbox/connect.test.ts @@ -1,9 +1,9 @@ import { assert, expect } from 'vitest' import { Sandbox } from '../../src' -import { isDebug, e2eTest, sandboxTest, template } from '../setup.js' +import { hostedSandboxTest, hostedTest, isDebug, template } from '../setup.js' -e2eTest('connect', async () => { +hostedTest('connect', async () => { const sbx = await Sandbox.create(template, { timeoutMs: 10_000 }) try { @@ -20,7 +20,7 @@ e2eTest('connect', async () => { } }) -sandboxTest('connect resumes paused sandbox', async ({ sandbox }) => { +hostedSandboxTest('connect resumes paused sandbox', async ({ sandbox }) => { await sandbox.pause() assert.isFalse(await sandbox.isRunning()) @@ -28,7 +28,7 @@ sandboxTest('connect resumes paused sandbox', async ({ sandbox }) => { assert.isTrue(await resumed.isRunning()) }) -sandboxTest('connect to non-running sandbox', async ({ sandbox }) => { +hostedSandboxTest('connect to non-running sandbox', async ({ sandbox }) => { const isRunning = await sandbox.isRunning() assert.isTrue(isRunning) await sandbox.kill() @@ -41,7 +41,7 @@ sandboxTest('connect to non-running sandbox', async ({ sandbox }) => { ) }) -e2eTest('connect does not shorten timeout on running sandbox', async () => { +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 }) @@ -69,7 +69,7 @@ e2eTest('connect does not shorten timeout on running sandbox', async () => { } }) -sandboxTest( +hostedSandboxTest( 'connect extends timeout on running sandbox', async ({ sandbox }) => { // Get initial info to check endAt diff --git a/packages/js-sdk/tests/sandbox/create.test.ts b/packages/js-sdk/tests/sandbox/create.test.ts index 53bf6d6033..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 } from 'vitest' import { Sandbox } from '../../src' -import { e2eTest, template } from '../setup.js' +import { hostedTest, template } from '../setup.js' -e2eTest('create', async () => { +hostedTest('create', async () => { const sbx = await Sandbox.create(template, { timeoutMs: 5_000 }) try { const isRunning = await sbx.isRunning() @@ -15,7 +15,7 @@ e2eTest('create', async () => { } }) -e2eTest('metadata', async () => { +hostedTest('metadata', async () => { const metadata = { 'test-key': 'test-value', } @@ -33,7 +33,7 @@ e2eTest('metadata', async () => { } }) -e2eTest('MCP gateway start failure kills the created sandbox', async () => { +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< diff --git a/packages/js-sdk/tests/sandbox/files/signing.test.ts b/packages/js-sdk/tests/sandbox/files/signing.test.ts index ff54de22fa..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 } from '../../setup' +import { hostedSandboxTest, sandboxTest } from '../../setup' describe('file signing', () => { sandboxTest.override({ @@ -9,40 +9,46 @@ describe('file signing', () => { }, }) - sandboxTest('test access file with expired signing', async ({ sandbox }) => { - await sandbox.files.write('hello.txt', 'hello world') + hostedSandboxTest( + 'test access file with expired signing', + async ({ sandbox }) => { + await sandbox.files.write('hello.txt', 'hello world') - const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt', { - useSignatureExpiration: -10_000, - }) + const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt', { + useSignatureExpiration: -10_000, + }) - 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, 401) - assert.deepEqual(JSON.parse(resBody), { - code: 401, - message: 'signature is already expired', - }) - }) + assert.equal(resStatus, 401) + assert.deepEqual(JSON.parse(resBody), { + code: 401, + message: 'signature is already expired', + }) + } + ) - sandboxTest('test access file with valid signing', async ({ sandbox }) => { - await sandbox.files.write('hello.txt', 'hello world') + hostedSandboxTest( + 'test access file with valid signing', + async ({ sandbox }) => { + await sandbox.files.write('hello.txt', 'hello world') - const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt', { - useSignatureExpiration: 10_000, - }) + const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt', { + useSignatureExpiration: 10_000, + }) - 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') - }) + assert.equal(resStatus, 200) + assert.equal(resBody, 'hello world') + } + ) - sandboxTest( + hostedSandboxTest( 'test access file with valid signing as root', async ({ sandbox }) => { await sandbox.files.write('hello.txt', 'hello world', { user: 'root' }) @@ -61,28 +67,31 @@ describe('file signing', () => { } ) - sandboxTest('test upload file with valid signing', async ({ sandbox }) => { - const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', { - useSignatureExpiration: 10_000, - }) - - const form = new FormData() - form.append('file', 'file content') - - const res = await fetch(fileUrlWithSigning, { - method: 'POST', - body: form, - }) - const resBody = await res.text() - const resStatus = res.status - - assert.equal(resStatus, 200) - assert.deepEqual(JSON.parse(resBody), [ - { name: 'hello.txt', path: '/home/user/hello.txt', type: 'file' }, - ]) - }) + hostedSandboxTest( + 'test upload file with valid signing', + async ({ sandbox }) => { + const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', { + useSignatureExpiration: 10_000, + }) + + const form = new FormData() + form.append('file', 'file content') - sandboxTest( + const res = await fetch(fileUrlWithSigning, { + method: 'POST', + body: form, + }) + const resBody = await res.text() + const resStatus = res.status + + assert.equal(resStatus, 200) + assert.deepEqual(JSON.parse(resBody), [ + { name: 'hello.txt', path: '/home/user/hello.txt', type: 'file' }, + ]) + } + ) + + hostedSandboxTest( 'test upload file with valid signing as root user', async ({ sandbox }) => { const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', { @@ -107,31 +116,37 @@ describe('file signing', () => { } ) - sandboxTest('test upload file with invalid signing', async ({ sandbox }) => { - const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', { - useSignatureExpiration: -100_000, - }) - - const form = new FormData() - form.append('file', 'file content') - - const res = await fetch(fileUrlWithSigning, { - method: 'POST', - body: form, - }) - const resBody = await res.text() - const resStatus = res.status - - assert.equal(resStatus, 401) - assert.deepEqual(JSON.parse(resBody), { - code: 401, - message: 'signature is already expired', - }) - }) + hostedSandboxTest( + 'test upload file with invalid signing', + async ({ sandbox }) => { + const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', { + useSignatureExpiration: -100_000, + }) + + const form = new FormData() + form.append('file', 'file content') - sandboxTest('test command run with secured sbx', async ({ sandbox }) => { - const response = await sandbox.commands.run('echo Hello World!') + const res = await fetch(fileUrlWithSigning, { + method: 'POST', + body: form, + }) + const resBody = await res.text() + const resStatus = res.status - assert.equal(response.stdout, 'Hello World!\n') - }) + assert.equal(resStatus, 401) + assert.deepEqual(JSON.parse(resBody), { + code: 401, + message: 'signature is already expired', + }) + } + ) + + hostedSandboxTest( + 'test command run with secured sbx', + async ({ sandbox }) => { + const response = await sandbox.commands.run('echo Hello World!') + + assert.equal(response.stdout, 'Hello World!\n') + } + ) }) diff --git a/packages/js-sdk/tests/sandbox/fork.test.ts b/packages/js-sdk/tests/sandbox/fork.test.ts index 9d9c076a03..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 } from 'vitest' -import { e2eTest, sandboxTest } from '../setup.js' +import { hostedSandboxTest, hostedTest } from '../setup.js' import { Sandbox } from '../../src' import { SandboxNotFoundError } from '../../src/errors' -sandboxTest('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,7 +36,7 @@ sandboxTest('fork a sandbox', async ({ sandbox }) => { } }) -sandboxTest('fork a sandbox multiple times', async ({ sandbox }) => { +hostedSandboxTest('fork a sandbox multiple times', async ({ sandbox }) => { const forks = await sandbox.fork({ count: 2, timeoutMs: 60_000 }) assert.equal(forks.length, 2) @@ -59,7 +59,7 @@ sandboxTest('fork a sandbox multiple times', async ({ sandbox }) => { } }) -sandboxTest( +hostedSandboxTest( 'fork a sandbox by ID with the static method', async ({ sandbox }) => { const forks = await Sandbox.fork(sandbox.sandboxId) @@ -77,7 +77,7 @@ sandboxTest( } ) -e2eTest('fork a killed sandbox fails', async () => { +hostedTest('fork a killed sandbox fails', async () => { const sandbox = await Sandbox.create() await sandbox.kill() diff --git a/packages/js-sdk/tests/sandbox/host.test.ts b/packages/js-sdk/tests/sandbox/host.test.ts index 5a3649d9ab..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,7 +39,7 @@ sandboxTest( 60_000 ) -sandboxTest('ping server in non-running sandbox', async ({ sandbox }) => { +hostedSandboxTest('ping server in non-running sandbox', async ({ sandbox }) => { const host = sandbox.getHost(3000) const url = `https://${host}` diff --git a/packages/js-sdk/tests/sandbox/internetAccess.test.ts b/packages/js-sdk/tests/sandbox/internetAccess.test.ts index e21dd7cdea..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 } from '../setup.js' +import { hostedSandboxTest, sandboxTest } from '../setup.js' describe('internet access enabled', () => { sandboxTest.override({ @@ -10,7 +10,7 @@ describe('internet access enabled', () => { }, }) - sandboxTest('internet access enabled', async ({ sandbox }) => { + 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" @@ -27,7 +27,7 @@ describe('internet access disabled', () => { }, }) - sandboxTest('internet access disabled', async ({ sandbox }) => { + hostedSandboxTest('internet access disabled', async ({ sandbox }) => { // Test that internet connectivity is blocked by making a curl request try { await sandbox.commands.run( @@ -44,7 +44,7 @@ describe('internet access disabled', () => { }) describe('internet access default', () => { - sandboxTest('internet access default', async ({ sandbox }) => { + 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" diff --git a/packages/js-sdk/tests/sandbox/kill.test.ts b/packages/js-sdk/tests/sandbox/kill.test.ts index 0094a30675..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 } from '../setup.js' +import { hostedSandboxTest } from '../setup.js' -sandboxTest('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/metrics.test.ts b/packages/js-sdk/tests/sandbox/metrics.test.ts index c2fe71ce64..4fe23614c8 100644 --- a/packages/js-sdk/tests/sandbox/metrics.test.ts +++ b/packages/js-sdk/tests/sandbox/metrics.test.ts @@ -1,9 +1,9 @@ import { expect } from 'vitest' import { SandboxMetrics } from '../../src' -import { sandboxTest, wait } from '../setup.js' +import { hostedSandboxTest, wait } from '../setup.js' -sandboxTest('sbx metrics', { timeout: 60_000 }, async ({ sandbox }) => { +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++) { @@ -24,7 +24,7 @@ sandboxTest('sbx metrics', { timeout: 60_000 }, async ({ sandbox }) => { expect(metric.cpuCount).toBeDefined() }) -sandboxTest( +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 a408872914..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, 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( + hostedSandboxTest( 'allow specific IP with deny all traffic', async ({ sandbox }) => { // Test that allowed IP works @@ -43,7 +43,7 @@ describe('deny specific IP address', () => { }, }) - sandboxTest('deny specific IP address', async ({ sandbox }) => { + hostedSandboxTest('deny specific IP address', async ({ sandbox }) => { // Test that denied IP fails await expect( sandbox.commands.run( @@ -69,7 +69,7 @@ describe('deny all traffic using allTraffic selector', () => { }, }) - sandboxTest( + hostedSandboxTest( 'deny all traffic using allTraffic selector', async ({ sandbox }) => { // Test that all traffic is denied @@ -98,7 +98,7 @@ describe('allow takes precedence over deny', () => { }, }) - sandboxTest('allow takes precedence over deny', async ({ sandbox }) => { + 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" @@ -124,34 +124,37 @@ describe('allowPublicTraffic=false', () => { }, }) - sandboxTest('sandbox requires traffic access token', async ({ sandbox }) => { - // Verify the sandbox was created successfully and has a traffic access token - assert(sandbox.trafficAccessToken) + hostedSandboxTest( + 'sandbox requires traffic access token', + async ({ sandbox }) => { + // Verify the sandbox was created successfully and has a traffic access token + assert(sandbox.trafficAccessToken) - // Start a simple HTTP server in the sandbox - const port = 8080 - sandbox.commands.run(`python3 -m http.server ${port}`, { - background: true, - }) + // 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)}` - // Test 1: Request without traffic access token should fail with 403 - const response1 = await fetch(sandboxUrl) - assert.equal(response1.status, 403) + // Test 1: Request without traffic access token should fail with 403 + const response1 = await fetch(sandboxUrl) + assert.equal(response1.status, 403) - // Test 2: Request with valid traffic access token should succeed - const response2 = await fetch(sandboxUrl, { - headers: { - 'e2b-traffic-access-token': sandbox.trafficAccessToken, - }, - }) - assert.equal(response2.status, 200) - }) + // Test 2: Request with valid traffic access token should succeed + const response2 = await fetch(sandboxUrl, { + headers: { + 'e2b-traffic-access-token': sandbox.trafficAccessToken, + }, + }) + assert.equal(response2.status, 200) + } + ) }) describe('allowPublicTraffic=true', () => { @@ -163,7 +166,7 @@ describe('allowPublicTraffic=true', () => { }, }) - sandboxTest('sandbox works without token', async ({ sandbox }) => { + 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}`, { @@ -188,7 +191,7 @@ describe('firewall transform injects headers', () => { // Port the httpbin template's start command listens on. const httpbinPort = 8080 - sandboxTest( + 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 @@ -245,7 +248,7 @@ describe('firewall transform injects headers', () => { }) describe('updateNetwork applies new egress rules', () => { - sandboxTest( + hostedSandboxTest( 'denies a previously reachable IP after update', async ({ sandbox }) => { // Baseline: 8.8.8.8 is reachable. @@ -282,7 +285,7 @@ describe('updateNetwork clears existing rules when fields are omitted', () => { }, }) - sandboxTest( + hostedSandboxTest( 'omitting fields replaces all egress rules', async ({ sandbox }) => { // Baseline from create-time config: 8.8.8.8 denied. @@ -317,7 +320,7 @@ describe('maskRequestHost option', () => { }, }) - sandboxTest( + 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 index 431718e49d..7d1fca1fcc 100644 --- a/packages/js-sdk/tests/sandbox/readFormat.test.ts +++ b/packages/js-sdk/tests/sandbox/readFormat.test.ts @@ -3,8 +3,9 @@ import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { ConnectionConfig, Sandbox } from '../../src' -import { NotFoundError } from '../../src/errors' -import { TEST_API_KEY } from '../setup' +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` @@ -25,7 +26,7 @@ afterEach(() => { server.resetHandlers() }) -function sandbox(envdVersion = '0.6.4'): Sandbox { +function sandbox(envdVersion = ENVD_DEBUG_FALLBACK): Sandbox { const config = new ConnectionConfig({ apiKey: TEST_API_KEY }) return new Sandbox({ ...config, @@ -81,13 +82,15 @@ test('reads a stream', async () => { }) test('sends the default username below ENVD_DEFAULT_USER', async () => { - await sandbox('0.3.9').files.read('/home/user/hello.txt') + 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('0.4.0').files.read('/home/user/hello.txt') + await sandbox(ENVD_DEFAULT_USER).files.read('/home/user/hello.txt') assert.equal(lastQuery?.get('username'), null) }) @@ -125,7 +128,7 @@ test('returns an empty value per format for an empty file', async () => { ) }) -test('maps an envd 404 to NotFoundError for every format', async () => { +test('maps an envd 404 to FileNotFoundError for every format', async () => { server.use( http.get(`${envdUrl}/files`, () => HttpResponse.json( @@ -137,9 +140,9 @@ test('maps an envd 404 to NotFoundError for every format', async () => { const files = sandbox().files await expect(files.read('/home/user/missing.txt')).rejects.toThrowError( - NotFoundError + FileNotFoundError ) await expect( files.read('/home/user/missing.txt', { format: 'stream' }) - ).rejects.toThrowError(NotFoundError) + ).rejects.toThrowError(FileNotFoundError) }) diff --git a/packages/js-sdk/tests/sandbox/secure.test.ts b/packages/js-sdk/tests/sandbox/secure.test.ts index fdc370f3fd..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, describe } from 'vitest' -import { getSignature, Sandbox } from '../../src' -import { e2eTest, sandboxTest } from '../setup' -import { randomUUID, createHash } from 'node:crypto' +import { Sandbox } from '../../src' +import { hostedSandboxTest, sandboxTest } from '../setup' describe('secure sandbox', () => { sandboxTest.override({ @@ -10,7 +9,7 @@ describe('secure sandbox', () => { }, }) - sandboxTest('test access file with signing', async ({ sandbox }) => { + hostedSandboxTest('test access file with signing', async ({ sandbox }) => { await sandbox.files.write('hello.txt', 'hello world') const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt') @@ -23,87 +22,9 @@ describe('secure sandbox', () => { assert.equal(resBody, 'hello world') }) - sandboxTest('try to re-connect to sandbox', async ({ sandbox }) => { + hostedSandboxTest('try to re-connect to sandbox', async ({ sandbox }) => { const sbxReconnect = await Sandbox.connect(sandbox.sandboxId) await sbxReconnect.files.write('hello.txt', 'hello world') }) }) - -e2eTest('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) -}) - -e2eTest('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 = 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, - }) - - assert.deepEqual(readSignatureExpected, readSignatureReceived) -}) - -e2eTest('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 3fe22467d7..f3bb1eab32 100644 --- a/packages/js-sdk/tests/sandbox/snapshot-api.test.ts +++ b/packages/js-sdk/tests/sandbox/snapshot-api.test.ts @@ -1,9 +1,9 @@ import { assert } from 'vitest' -import { sandboxTest } from '../setup.js' +import { hostedSandboxTest } from '../setup.js' import { Sandbox } from '../../src' -sandboxTest('create a snapshot from sandbox', async ({ sandbox }) => { +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') @@ -17,7 +17,7 @@ sandboxTest('create a snapshot from sandbox', async ({ sandbox }) => { await Sandbox.deleteSnapshot(snapshot.snapshotId) }) -sandboxTest( +hostedSandboxTest( 'create sandbox from snapshot', async ({ sandbox, sandboxTestId }) => { const testContent = 'content from original sandbox' @@ -47,7 +47,7 @@ sandboxTest( } ) -sandboxTest( +hostedSandboxTest( 'create multiple sandboxes from same snapshot', async ({ sandbox, sandboxTestId }) => { const testContent = 'shared snapshot content' @@ -98,7 +98,7 @@ sandboxTest( } ) -sandboxTest('list snapshots', async ({ sandbox }) => { +hostedSandboxTest('list snapshots', async ({ sandbox }) => { // Create a snapshot const snapshot = await sandbox.createSnapshot() @@ -118,39 +118,45 @@ sandboxTest('list snapshots', async ({ sandbox }) => { } }) -sandboxTest('list snapshots for specific sandbox', async ({ sandbox }) => { - // Create a snapshot - const snapshot = await sandbox.createSnapshot() +hostedSandboxTest( + 'list snapshots for specific sandbox', + async ({ sandbox }) => { + // Create a snapshot + const snapshot = await sandbox.createSnapshot() - try { - // List snapshots for this sandbox using instance method - const paginator = sandbox.listSnapshots() - const snapshots = await paginator.nextItems() + try { + // List snapshots for this sandbox using instance method + const paginator = sandbox.listSnapshots() + const snapshots = await paginator.nextItems() - // Should find our snapshot - const found = snapshots.find((s) => s.snapshotId === snapshot.snapshotId) - assert.isDefined(found) - } finally { - await Sandbox.deleteSnapshot(snapshot.snapshotId) + // Should find our snapshot + const found = snapshots.find((s) => s.snapshotId === snapshot.snapshotId) + assert.isDefined(found) + } finally { + await Sandbox.deleteSnapshot(snapshot.snapshotId) + } } -}) +) -sandboxTest('create a named snapshot', async ({ sandbox, sandboxTestId }) => { - const snapshotName = `snap-${sandboxTestId}` +hostedSandboxTest( + 'create a named snapshot', + async ({ sandbox, sandboxTestId }) => { + const snapshotName = `snap-${sandboxTestId}` - const snapshot = await sandbox.createSnapshot({ name: snapshotName }) + const snapshot = await sandbox.createSnapshot({ name: snapshotName }) - try { - assert.isString(snapshot.snapshotId) - assert.isArray(snapshot.names) - assert.isTrue(snapshot.names.length > 0) - assert.isTrue(snapshot.names.some((n) => n.includes(snapshotName))) - } finally { - await Sandbox.deleteSnapshot(snapshot.snapshotId) + try { + assert.isString(snapshot.snapshotId) + assert.isArray(snapshot.names) + assert.isTrue(snapshot.names.length > 0) + assert.isTrue(snapshot.names.some((n) => n.includes(snapshotName))) + } finally { + await Sandbox.deleteSnapshot(snapshot.snapshotId) + } } -}) +) -sandboxTest( +hostedSandboxTest( 'list snapshots filtered by name', async ({ sandbox, sandboxTestId }) => { const snapshotName = `snap-filter-${sandboxTestId}` @@ -178,7 +184,7 @@ sandboxTest( } ) -sandboxTest('delete snapshot', async ({ sandbox }) => { +hostedSandboxTest('delete snapshot', async ({ sandbox }) => { const snapshot = await sandbox.createSnapshot() // Delete should succeed @@ -190,7 +196,7 @@ sandboxTest('delete snapshot', async ({ sandbox }) => { assert.isFalse(deletedAgain) }) -sandboxTest( +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 d65d57d25d..3ded106767 100644 --- a/packages/js-sdk/tests/sandbox/snapshot.test.ts +++ b/packages/js-sdk/tests/sandbox/snapshot.test.ts @@ -1,8 +1,8 @@ import { assert, describe } from 'vitest' -import { sandboxTest } from '../setup.js' +import { hostedSandboxTest, sandboxTest } from '../setup.js' -sandboxTest('pause and resume a sandbox', async ({ sandbox }) => { +hostedSandboxTest('pause and resume a sandbox', async ({ sandbox }) => { assert.isTrue(await sandbox.isRunning()) await sandbox.pause() @@ -22,7 +22,7 @@ describe('pause and resume with env vars', () => { }, }) - sandboxTest( + 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. @@ -49,33 +49,36 @@ describe('pause and resume with env vars', () => { ) }) -sandboxTest('pause and resume a sandbox with file', async ({ sandbox }) => { - const filename = 'test_snapshot.txt' - const content = 'This is a snapshot test file.' +hostedSandboxTest( + 'pause and resume a sandbox with file', + async ({ sandbox }) => { + const filename = 'test_snapshot.txt' + const content = 'This is a snapshot test file.' - const info = await sandbox.files.write(filename, content) - assert.equal(info.name, filename) - assert.equal(info.type, 'file') - assert.equal(info.path, `/home/user/${filename}`) + const info = await sandbox.files.write(filename, content) + assert.equal(info.name, filename) + assert.equal(info.type, 'file') + assert.equal(info.path, `/home/user/${filename}`) - const exists = await sandbox.files.exists(filename) - assert.isTrue(exists) - const readContent = await sandbox.files.read(filename) - assert.equal(readContent, content) + const exists = await sandbox.files.exists(filename) + assert.isTrue(exists) + const readContent = await sandbox.files.read(filename) + assert.equal(readContent, content) - await sandbox.pause() - assert.isFalse(await sandbox.isRunning()) + await sandbox.pause() + assert.isFalse(await sandbox.isRunning()) - await sandbox.connect() - assert.isTrue(await sandbox.isRunning()) + await sandbox.connect() + assert.isTrue(await sandbox.isRunning()) - const exists2 = await sandbox.files.exists(filename) - assert.isTrue(exists2) - const readContent2 = await sandbox.files.read(filename) - assert.equal(readContent2, content) -}) + const exists2 = await sandbox.files.exists(filename) + assert.isTrue(exists2) + const readContent2 = await sandbox.files.read(filename) + assert.equal(readContent2, content) + } +) -sandboxTest( +hostedSandboxTest( 'pause and resume a sandbox with ongoing long running process', async ({ sandbox }) => { const cmd = await sandbox.commands.run('sleep 3600', { background: true }) @@ -99,7 +102,7 @@ sandboxTest( } ) -sandboxTest( +hostedSandboxTest( 'pause and resume a sandbox with completed long running process', async ({ sandbox }) => { const filename = 'test_long_running.txt' @@ -131,7 +134,7 @@ sandboxTest( } ) -sandboxTest( +hostedSandboxTest( 'pause and resume a sandbox with http server', async ({ sandbox }) => { await sandbox.commands.run('python3 -m http.server 8000', { @@ -157,7 +160,7 @@ sandboxTest( } ) -sandboxTest( +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 841d1b2cf2..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, wait } from '../setup.js' +import { hostedSandboxTest, wait } from '../setup.js' -sandboxTest('shorten timeout', async ({ sandbox }) => { +hostedSandboxTest('shorten timeout', async ({ sandbox }) => { await sandbox.setTimeout(5000) await wait(6000) @@ -10,7 +10,7 @@ sandboxTest('shorten timeout', async ({ sandbox }) => { expect(await sandbox.isRunning()).toBeFalsy() }) -sandboxTest('shorten then lengthen timeout', async ({ sandbox }) => { +hostedSandboxTest('shorten then lengthen timeout', async ({ sandbox }) => { await sandbox.setTimeout(5000) await wait(1000) @@ -22,7 +22,7 @@ sandboxTest('shorten then lengthen timeout', async ({ sandbox }) => { expect(await sandbox.isRunning()).toBeTruthy() }) -sandboxTest('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 index 928268d81c..ac5875a0b8 100644 --- a/packages/js-sdk/tests/sandbox/uploadMode.test.ts +++ b/packages/js-sdk/tests/sandbox/uploadMode.test.ts @@ -3,8 +3,13 @@ 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 { TEST_API_KEY } from '../setup' +import { belowEnvdVersion, TEST_API_KEY } from '../setup' const sandboxId = 'sbx-upload-mode' const envdUrl = `https://49983-${sandboxId}.sandbox.e2b.dev` @@ -45,7 +50,7 @@ afterEach(() => { server.resetHandlers() }) -function sandbox(envdVersion = '0.6.4'): Sandbox { +function sandbox(envdVersion = ENVD_DEBUG_FALLBACK): Sandbox { const config = new ConnectionConfig({ apiKey: TEST_API_KEY }) return new Sandbox({ ...config, @@ -72,9 +77,11 @@ test('uploads as octet-stream when asked', async () => { }) test('falls back to multipart below ENVD_OCTET_STREAM_UPLOAD', async () => { - await sandbox('0.5.6').files.write('/home/user/hello.txt', 'hello world', { - useOctetStream: true, - }) + 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') }) @@ -110,11 +117,15 @@ test('sends metadata as request headers', async () => { 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('0.6.1').files.write('/home/user/hello.txt', 'hello world', { - metadata: { origin: 'unit-test' }, - }) + sandbox(belowEnvdVersion(ENVD_FILE_METADATA)).files.write( + '/home/user/hello.txt', + 'hello world', + { metadata: { origin: 'unit-test' } } + ) ).rejects.toThrowError(TemplateError) assert.lengthOf(uploads, 0) }) diff --git a/packages/js-sdk/tests/sandbox/versionGates.test.ts b/packages/js-sdk/tests/sandbox/versionGates.test.ts index 05d80a21e2..216b351024 100644 --- a/packages/js-sdk/tests/sandbox/versionGates.test.ts +++ b/packages/js-sdk/tests/sandbox/versionGates.test.ts @@ -1,18 +1,41 @@ -import { assert, describe, expect, test } from 'vitest' +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 { TEST_API_KEY } from '../setup' +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 pointed at a nonexistent domain is enough. + * 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: 'sbx-version-gate', + sandboxId, sandboxDomain: 'sandbox.e2b.dev', envdVersion, envdAccessToken: 'token', @@ -21,7 +44,7 @@ function sandboxWithEnvd(envdVersion: string): Sandbox { describe('commands', () => { test('rejects stdin:false below ENVD_COMMANDS_STDIN', async () => { - const sandbox = sandboxWithEnvd('0.2.4') + const sandbox = sandboxWithEnvd(belowEnvdVersion(ENVD_COMMANDS_STDIN)) await expect( sandbox.commands.run('echo hello', { stdin: false }) @@ -29,11 +52,12 @@ describe('commands', () => { }) test('reports the envd version in the error message', async () => { - const sandbox = sandboxWithEnvd('0.2.4') + 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, '0.2.4') + (err: Error) => assert.include(err.message, envdVersion) ) }) }) @@ -41,8 +65,12 @@ describe('commands', () => { 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('0.1.3') + const sandbox = sandboxWithEnvd( + belowEnvdVersion(ENVD_VERSION_RECURSIVE_WATCH) + ) await expect( sandbox.files.watchDir('/home/user', noop, { recursive: true }) @@ -50,7 +78,9 @@ describe('watchDir', () => { }) test('rejects includeEntry below ENVD_VERSION_FS_EVENT_ENTRY_INFO', async () => { - const sandbox = sandboxWithEnvd('0.6.2') + const sandbox = sandboxWithEnvd( + belowEnvdVersion(ENVD_VERSION_FS_EVENT_ENTRY_INFO) + ) await expect( sandbox.files.watchDir('/home/user', noop, { includeEntry: true }) @@ -58,7 +88,9 @@ describe('watchDir', () => { }) test('rejects allowNetworkMounts below ENVD_VERSION_WATCH_NETWORK_MOUNTS', async () => { - const sandbox = sandboxWithEnvd('0.6.3') + const sandbox = sandboxWithEnvd( + belowEnvdVersion(ENVD_VERSION_WATCH_NETWORK_MOUNTS) + ) await expect( sandbox.files.watchDir('/home/user', noop, { allowNetworkMounts: true }) @@ -66,9 +98,9 @@ describe('watchDir', () => { }) test('accepts the gated options on a supported envd', async () => { - // The gates pass, so the call proceeds to the RPC and fails on the network + // 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('0.6.4') + const sandbox = sandboxWithEnvd(ENVD_DEBUG_FALLBACK) await sandbox.files .watchDir('/home/user', noop, { @@ -78,7 +110,7 @@ describe('watchDir', () => { requestTimeoutMs: 1_000, }) .then( - () => assert.fail('expected the request to fail without a sandbox'), + () => assert.fail('expected the mocked RPC to fail'), (err: Error) => assert.notInstanceOf(err, TemplateError) ) }) diff --git a/packages/js-sdk/tests/setup.ts b/packages/js-sdk/tests/setup.ts index 9789057a62..b4a138fb46 100644 --- a/packages/js-sdk/tests/setup.ts +++ b/packages/js-sdk/tests/setup.ts @@ -156,12 +156,35 @@ const E2E_SKIP_REASON = 'set E2B_E2E=1 to run the e2e tier' /** A test that needs real infrastructure — skipped unless E2B_E2E is set. */ export const e2eTest = base.skipIf(!isE2E) -/** A template build against real infrastructure — skipped unless E2B_E2E is set. */ -export const e2eBuildTemplateTest = buildTemplateTest.skipIf(!isE2E) +/** + * A test that needs hosted infrastructure — the control plane, the traffic + * proxy, snapshots — which a local envd can't stand in for, so it stays + * skipped under E2B_DEBUG on top of the e2e opt-in. + */ +export const hostedTest = e2eTest.skipIf(isDebug) + +/** {@link sandboxTest} for a test that needs hosted infrastructure. */ +export const hostedSandboxTest = sandboxTest.skipIf(isDebug) + +/** + * A template build against real infrastructure — skipped unless E2B_E2E is + * set. Builds always run server-side, so E2B_DEBUG skips them too. + */ +export const e2eBuildTemplateTest = buildTemplateTest.skipIf(!isE2E || isDebug) /** Placeholder API key with a valid format for tests that don't hit the API. */ export const TEST_API_KEY = `e2b_${'0'.repeat(40)}` +/** + * The highest envd version below one of the `ENVD_*` thresholds, for + * exercising the reject branch of a version gate without hardcoding a version + * that stops being below the threshold when it moves. A prerelease of a + * version sorts below the version itself. + */ +export function belowEnvdVersion(version: string): string { + return `${version}-0` +} + function generateRandomString(length: number = 8): string { return Math.random() .toString(36) diff --git a/packages/js-sdk/tests/template/serialization.test.ts b/packages/js-sdk/tests/template/serialization.test.ts index a965176249..f53e232654 100644 --- a/packages/js-sdk/tests/template/serialization.test.ts +++ b/packages/js-sdk/tests/template/serialization.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterAll, assert, beforeAll, expect, test } from 'vitest' import { Template } from '../../src' +import { InstructionType } from '../../src/template/types' import { calculateFilesHash } from '../../src/template/utils' let contextPath: string @@ -45,6 +46,8 @@ test('hash covers the source and destination paths', async () => { }) test('hashing a source that matches no file fails', async () => { + // TODO: should reject with TemplateError once calculateFilesHash stops + // throwing a bare Error. await expect(filesHash('nope.txt', '/app/')).rejects.toThrow() }) @@ -63,7 +66,7 @@ test('serializes a build payload from the builder', async () => { assert.isUndefined(payload.fromTemplate) assert.deepEqual( payload.steps.map((step: { type: string }) => step.type), - ['RUN', 'WORKDIR'] + [InstructionType.RUN, InstructionType.WORKDIR] ) }) @@ -99,7 +102,7 @@ test('computeHashes adds the copy hash to the payload', async () => { const copyStep = (payload: { steps: { type: string; filesHash?: string }[] - }) => payload.steps.find((step) => step.type === 'COPY') + }) => payload.steps.find((step) => step.type === InstructionType.COPY) assert.isUndefined(copyStep(withoutHashes)?.filesHash) assert.equal( diff --git a/packages/js-sdk/tests/volume/mount.test.ts b/packages/js-sdk/tests/volume/mount.test.ts new file mode 100644 index 0000000000..17774059c1 --- /dev/null +++ b/packages/js-sdk/tests/volume/mount.test.ts @@ -0,0 +1,39 @@ +import { assert } from 'vitest' + +import { Sandbox, Volume } from '../../src' +import { hostedTest, template } from '../setup' + +/** + * Volume content persisting across sandboxes is server-side behavior — the + * mount happens on real compute, so this is the one volume test that can't be + * mocked. Everything else about volumes (CRUD, pagination, error mapping, the + * content API) is asserted against a mocked transport in the unit tier. + */ +hostedTest('a mounted volume persists content across sandboxes', async () => { + const volume = await Volume.create(`test-mount-${Date.now()}`) + + try { + const writer = await Sandbox.create(template, { + volumeMounts: { '/mnt/data': volume }, + }) + try { + await writer.files.write('/mnt/data/hello.txt', 'written by the writer') + } finally { + await writer.kill() + } + + const reader = await Sandbox.create(template, { + volumeMounts: { '/mnt/data': volume }, + }) + try { + assert.equal( + await reader.files.read('/mnt/data/hello.txt'), + 'written by the writer' + ) + } finally { + await reader.kill() + } + } finally { + await Volume.destroy(volume.volumeId) + } +}) diff --git a/packages/js-sdk/tests/volume/mountPayload.test.ts b/packages/js-sdk/tests/volume/mountPayload.test.ts new file mode 100644 index 0000000000..0a62aeaf07 --- /dev/null +++ b/packages/js-sdk/tests/volume/mountPayload.test.ts @@ -0,0 +1,56 @@ +import { afterAll, afterEach, beforeAll, expect, test } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' + +import { Sandbox, Volume } from '../../src' +import { apiUrl, TEST_API_KEY } from '../setup' + +let lastCreateBody: Record | undefined + +const server = setupServer( + http.post(apiUrl('/sandboxes'), async ({ request }) => { + lastCreateBody = (await request.json()) as Record + return HttpResponse.json({ + sandboxID: 'test-sandbox-id', + templateID: 'base', + envdVersion: '0.2.4', + }) + }) +) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterAll(() => server.close()) +afterEach(() => { + lastCreateBody = undefined + server.resetHandlers() +}) + +test('Sandbox.create omits volumeMounts when none are requested', async () => { + await Sandbox.create('base', { apiKey: TEST_API_KEY }) + + expect(lastCreateBody).not.toHaveProperty('volumeMounts') +}) + +test('Sandbox.create maps mount paths to named volume mounts', async () => { + await Sandbox.create('base', { + apiKey: TEST_API_KEY, + volumeMounts: { '/mnt/data': 'my-volume' }, + }) + + expect(lastCreateBody?.volumeMounts).toEqual([ + { name: 'my-volume', path: '/mnt/data' }, + ]) +}) + +test('Sandbox.create accepts a Volume instance as the mount source', async () => { + const volume = new Volume('vol-1', 'my-volume', 'volume-token') + + await Sandbox.create('base', { + apiKey: TEST_API_KEY, + volumeMounts: { '/mnt/data': volume }, + }) + + expect(lastCreateBody?.volumeMounts).toEqual([ + { name: 'my-volume', path: '/mnt/data' }, + ]) +}) diff --git a/packages/js-sdk/vitest.config.mts b/packages/js-sdk/vitest.config.mts index 10bb0a1cc7..b1c9781301 100644 --- a/packages/js-sdk/vitest.config.mts +++ b/packages/js-sdk/vitest.config.mts @@ -87,6 +87,10 @@ export default defineConfig({ env: { ...(process.env as Record), ...env.parsed, + // Selecting this project is the opt-in, so the flag the tests gate + // on is set here instead of in the package script, which would + // need POSIX-only `VAR=value` syntax and break on Windows. + E2B_E2E: '1', }, }, }, diff --git a/packages/python-sdk/pytest.ini b/packages/python-sdk/pytest.ini index 3bd3c7306b..2ef13ac126 100644 --- a/packages/python-sdk/pytest.ini +++ b/packages/python-sdk/pytest.ini @@ -3,6 +3,7 @@ markers = skip_debug: skip test if E2B_DEBUG is set. e2e: test needs live infrastructure (sandboxes, template builds) and credentials; excluded by default, run with `-m e2e`. + mocked: test mocks the API calls its fixtures would make, so it stays in the default tier even when it requests an e2e fixture. asyncio_mode=auto asyncio_default_fixture_loop_scope=session diff --git a/packages/python-sdk/tests/README.md b/packages/python-sdk/tests/README.md index bae2d4a058..8aeb7c12fd 100644 --- a/packages/python-sdk/tests/README.md +++ b/packages/python-sdk/tests/README.md @@ -32,7 +32,14 @@ Tests land in this tier automatically when they use one of the live fixtures (`sandbox`, `sandbox_factory`, `async_sandbox`, `async_sandbox_factory`, `build`, `async_build`) — see `pytest_collection_modifyitems` in [`conftest.py`](./conftest.py). A test that calls live APIs without such a -fixture needs an explicit `@pytest.mark.e2e`. +fixture needs an explicit `@pytest.mark.e2e`; conversely, a test that mocks the +API calls its fixture would make (e.g. the template stacktrace tests) opts back +into the default tier with `@pytest.mark.mocked`. + +Serialization and hashing are shared synchronous logic, so +`tests/sync/template_sync/test_serialization.py` has no async mirror; the same +goes for the request-shaping tests under `tests/shared/`, which cover both +clients in one file. `skip_debug`/`E2B_DEBUG` is a separate axis: it points the SDK at a local envd instead of a provisioned sandbox and does not enable or disable either tier. 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 index 143d991c38..b2b7616e98 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_read_format.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_read_format.py @@ -6,7 +6,13 @@ 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 @@ -19,7 +25,7 @@ def _filesystem( monkeypatch, api_key: str, requests: List[httpx.Request], - envd_version: str = "0.6.4", + envd_version: str = str(ENVD_DEBUG_FALLBACK), status_code: int = 200, ) -> Filesystem: def handler(request: httpx.Request) -> httpx.Response: @@ -77,7 +83,12 @@ async def test_read_returns_stream(monkeypatch, test_api_key): 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="0.3.9") + filesystem = _filesystem( + monkeypatch, + test_api_key, + requests, + envd_version=below_envd_version(ENVD_DEFAULT_USER), + ) await filesystem.read("/home/user/a.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 index f1beb96fda..1c0096e1ff 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_upload_mode.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_upload_mode.py @@ -6,6 +6,12 @@ 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 @@ -16,7 +22,7 @@ def _filesystem( api_key: str, requests: List[httpx.Request], - envd_version: str = "0.6.4", + envd_version: str = str(ENVD_DEBUG_FALLBACK), ) -> Filesystem: def handler(request: httpx.Request) -> httpx.Response: requests.append(request) @@ -65,7 +71,11 @@ async def test_file_like_data_defaults_to_octet_stream(test_api_key): 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="0.5.6") + 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")) 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 index b10925f4a6..7cc99afe1c 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_version_gates.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_version_gates.py @@ -4,7 +4,16 @@ 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 @@ -35,35 +44,41 @@ def _filesystem(envd_version: str, api_key: str) -> Filesystem: async def test_run_rejects_disabling_stdin_below_envd_commands_stdin(test_api_key): - commands = _commands("0.2.9", 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("0.1.3", 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("0.6.2", 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("0.6.3", 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("0.6.1", 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/async/template_async/test_stacktrace.py b/packages/python-sdk/tests/async/template_async/test_stacktrace.py index 63c3e5e7ac..95812b3069 100644 --- a/packages/python-sdk/tests/async/template_async/test_stacktrace.py +++ b/packages/python-sdk/tests/async/template_async/test_stacktrace.py @@ -48,6 +48,11 @@ } +# Every build API call is mocked below, so these stay in the default unit tier +# despite requesting the `build` fixture. +pytestmark = pytest.mark.mocked + + @pytest.fixture(autouse=True) def mock_template_build(monkeypatch): async def mock_request_build( diff --git a/packages/python-sdk/tests/async/volume_async/test_mount.py b/packages/python-sdk/tests/async/volume_async/test_mount.py new file mode 100644 index 0000000000..ab6616a184 --- /dev/null +++ b/packages/python-sdk/tests/async/volume_async/test_mount.py @@ -0,0 +1,35 @@ +"""Async counterpart of `tests/sync/volume_sync/test_mount.py`.""" + +from uuid import uuid4 + +import pytest + +from e2b import AsyncSandbox, AsyncVolume + + +@pytest.mark.e2e +@pytest.mark.skip_debug() +async def test_mounted_volume_persists_content_across_sandboxes(template): + volume = await AsyncVolume.create(f"test-mount-{uuid4()}") + + try: + writer = await AsyncSandbox.create( + template, volume_mounts={"/mnt/data": volume} + ) + try: + await writer.files.write("/mnt/data/hello.txt", "written by the writer") + finally: + await writer.kill() + + reader = await AsyncSandbox.create( + template, volume_mounts={"/mnt/data": volume} + ) + try: + assert ( + await reader.files.read("/mnt/data/hello.txt") + == "written by the writer" + ) + finally: + await reader.kill() + finally: + await AsyncVolume.destroy(volume.volume_id) diff --git a/packages/python-sdk/tests/conftest.py b/packages/python-sdk/tests/conftest.py index c447741f3b..884f09fea6 100644 --- a/packages/python-sdk/tests/conftest.py +++ b/packages/python-sdk/tests/conftest.py @@ -44,7 +44,8 @@ def test_api_key() -> str: # server-side template build. Any test requesting one belongs to the e2e tier, # which `pytest.ini` excludes by default (`-m "not e2e"`); run it with # `pytest -m e2e` and credentials. Tests that reach the control plane without -# these fixtures carry an explicit `@pytest.mark.e2e`. +# these fixtures carry an explicit `@pytest.mark.e2e`, and tests that mock the +# fixture's API calls opt back out with `@pytest.mark.mocked`. E2E_FIXTURES = frozenset( { "sandbox", @@ -59,9 +60,13 @@ def test_api_key() -> str: def pytest_collection_modifyitems(items): for item in items: - if isinstance(item, pytest.Function) and not E2E_FIXTURES.isdisjoint( - item.fixturenames - ): + if not isinstance(item, pytest.Function): + continue + # `pytest.mark.mocked` opts out: the test replaces the API calls the + # fixture would make, so nothing is provisioned. + if item.get_closest_marker("mocked"): + continue + if not E2E_FIXTURES.isdisjoint(item.fixturenames): item.add_marker(pytest.mark.e2e) diff --git a/packages/python-sdk/tests/envd_versions.py b/packages/python-sdk/tests/envd_versions.py new file mode 100644 index 0000000000..6404ed6d02 --- /dev/null +++ b/packages/python-sdk/tests/envd_versions.py @@ -0,0 +1,13 @@ +"""Helpers for testing the SDK's envd version gates.""" + +from packaging.version import Version + + +def below_envd_version(version: Version) -> str: + """The highest envd version below one of the `ENVD_*` thresholds. + + Lets a gate's reject branch be exercised without hardcoding a version that + stops being below the threshold when it moves — a release candidate of a + version sorts below the version itself. + """ + return f"{version}rc1" diff --git a/packages/python-sdk/tests/shared/volume/test_mount_payload.py b/packages/python-sdk/tests/shared/volume/test_mount_payload.py new file mode 100644 index 0000000000..b25e528a9e --- /dev/null +++ b/packages/python-sdk/tests/shared/volume/test_mount_payload.py @@ -0,0 +1,77 @@ +"""Volume mounts in the sandbox create request — pure client-side shaping. + +Mirrors `tests/volume/mountPayload.test.ts` in the JS SDK. The mount itself is +server-side behavior and is covered by the e2e tier (`test_mount.py`). +""" + +from types import SimpleNamespace +from typing import Any, Dict +from unittest.mock import AsyncMock, Mock + +from e2b import AsyncSandbox, Sandbox, Volume +from e2b.api.client.api.sandboxes import post_sandboxes +from e2b.api.client.models import Sandbox as SandboxModel + + +def _created_sandbox(): + return SimpleNamespace( + status_code=200, + parsed=SandboxModel( + client_id="client-id", + envd_version="0.2.4", + sandbox_id="sbx-test", + template_id="template-id", + ), + ) + + +def _sync_request_body(monkeypatch, api_key: str, volume_mounts) -> Dict[str, Any]: + request = Mock(return_value=_created_sandbox()) + monkeypatch.setattr(post_sandboxes, "sync_detailed", request) + + Sandbox.create(api_key=api_key, volume_mounts=volume_mounts) + + return request.call_args.kwargs["body"].to_dict() + + +async def _async_request_body( + monkeypatch, api_key: str, volume_mounts +) -> Dict[str, Any]: + request = AsyncMock(return_value=_created_sandbox()) + monkeypatch.setattr(post_sandboxes, "asyncio_detailed", request) + + await AsyncSandbox.create(api_key=api_key, volume_mounts=volume_mounts) + + return request.call_args.kwargs["body"].to_dict() + + +def test_create_omits_volume_mounts_when_none_are_requested(monkeypatch, test_api_key): + body = _sync_request_body(monkeypatch, test_api_key, None) + + assert "volumeMounts" not in body + + +def test_create_maps_mount_paths_to_named_volume_mounts(monkeypatch, test_api_key): + body = _sync_request_body(monkeypatch, test_api_key, {"/mnt/data": "my-volume"}) + + assert body["volumeMounts"] == [{"name": "my-volume", "path": "/mnt/data"}] + + +def test_create_accepts_a_volume_instance_as_the_mount_source( + monkeypatch, test_api_key +): + volume = Volume("vol-1", "my-volume", "volume-token") + + body = _sync_request_body(monkeypatch, test_api_key, {"/mnt/data": volume}) + + assert body["volumeMounts"] == [{"name": "my-volume", "path": "/mnt/data"}] + + +async def test_async_create_maps_mount_paths_to_named_volume_mounts( + monkeypatch, test_api_key +): + body = await _async_request_body( + monkeypatch, test_api_key, {"/mnt/data": "my-volume"} + ) + + assert body["volumeMounts"] == [{"name": "my-volume", "path": "/mnt/data"}] 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 index 38b63eb6f1..cc4c8c5379 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_read_format.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_read_format.py @@ -11,7 +11,13 @@ 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 @@ -24,7 +30,7 @@ def _filesystem( monkeypatch, api_key: str, requests: List[httpx.Request], - envd_version: str = "0.6.4", + envd_version: str = str(ENVD_DEBUG_FALLBACK), status_code: int = 200, ) -> Filesystem: def handler(request: httpx.Request) -> httpx.Response: @@ -81,7 +87,12 @@ def test_read_returns_stream(monkeypatch, test_api_key): 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="0.3.9") + filesystem = _filesystem( + monkeypatch, + test_api_key, + requests, + envd_version=below_envd_version(ENVD_DEFAULT_USER), + ) filesystem.read("/home/user/a.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 index 6d98aa99c0..3ac1d9fcc9 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_upload_mode.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_upload_mode.py @@ -8,6 +8,12 @@ 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 @@ -18,7 +24,7 @@ def _filesystem( api_key: str, requests: List[httpx.Request], - envd_version: str = "0.6.4", + envd_version: str = str(ENVD_DEBUG_FALLBACK), ) -> Filesystem: def handler(request: httpx.Request) -> httpx.Response: requests.append(request) @@ -67,7 +73,11 @@ def test_file_like_data_defaults_to_octet_stream(test_api_key): 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="0.5.6") + 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")) 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 index d41671cc9c..d6f2159b02 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_version_gates.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_version_gates.py @@ -9,7 +9,16 @@ 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 @@ -36,35 +45,41 @@ def _filesystem(envd_version: str, api_key: str) -> Filesystem: def test_run_rejects_disabling_stdin_below_envd_commands_stdin(test_api_key): - commands = _commands("0.2.9", 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("0.1.3", 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("0.6.2", 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("0.6.3", 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("0.6.1", 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"}) diff --git a/packages/python-sdk/tests/sync/template_sync/test_serialization.py b/packages/python-sdk/tests/sync/template_sync/test_serialization.py index fb0d5f74dc..c0a7336ea3 100644 --- a/packages/python-sdk/tests/sync/template_sync/test_serialization.py +++ b/packages/python-sdk/tests/sync/template_sync/test_serialization.py @@ -11,6 +11,7 @@ import pytest from e2b import Template +from e2b.template.types import InstructionType from e2b.template.utils import calculate_files_hash @@ -48,6 +49,8 @@ def test_hash_covers_the_source_and_destination_paths(context_path: Path): def test_hashing_a_source_that_matches_no_file_fails(context_path: Path): + # TODO: should raise TemplateException once calculate_files_hash stops + # raising a bare ValueError. with pytest.raises(ValueError): _files_hash(context_path, "nope.txt", "/app/") @@ -67,7 +70,10 @@ def test_serializes_a_build_payload_from_the_builder(context_path: Path): assert payload["startCmd"] == "python main.py" assert payload["readyCmd"] == "curl -f http://localhost:8000" assert payload.get("fromTemplate") is None - assert [step["type"] for step in payload["steps"]] == ["RUN", "WORKDIR"] + assert [step["type"] for step in payload["steps"]] == [ + InstructionType.RUN, + InstructionType.WORKDIR, + ] def test_serializes_from_template_instead_of_from_image(): @@ -99,6 +105,8 @@ def test_copy_step_carries_the_files_hash(context_path: Path): ) payload = json.loads(Template.to_json(template)) - copy_step = next(step for step in payload["steps"] if step["type"] == "COPY") + copy_step = next( + step for step in payload["steps"] if step["type"] == InstructionType.COPY + ) assert copy_step["filesHash"] == _files_hash(context_path, "app.txt", "/app/") diff --git a/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py b/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py index f416c84ee4..b4dd35db77 100644 --- a/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py +++ b/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py @@ -48,6 +48,11 @@ } +# Every build API call is mocked below, so these stay in the default unit tier +# despite requesting the `build` fixture. +pytestmark = pytest.mark.mocked + + @pytest.fixture(autouse=True) def mock_template_build(monkeypatch): def mock_request_build( diff --git a/packages/python-sdk/tests/sync/volume_sync/test_mount.py b/packages/python-sdk/tests/sync/volume_sync/test_mount.py new file mode 100644 index 0000000000..8f52894c08 --- /dev/null +++ b/packages/python-sdk/tests/sync/volume_sync/test_mount.py @@ -0,0 +1,33 @@ +"""Volume content persisting across sandboxes — real mounts, real compute. + +Everything else about volumes (CRUD, pagination, error mapping, the content +API) is asserted against a mocked transport in the default tier; only the mount +behavior needs live infrastructure. Mirrors `tests/volume/mount.test.ts`. +""" + +from uuid import uuid4 + +import pytest + +from e2b import Sandbox, Volume + + +@pytest.mark.e2e +@pytest.mark.skip_debug() +def test_mounted_volume_persists_content_across_sandboxes(template): + volume = Volume.create(f"test-mount-{uuid4()}") + + try: + writer = Sandbox.create(template, volume_mounts={"/mnt/data": volume}) + try: + writer.files.write("/mnt/data/hello.txt", "written by the writer") + finally: + writer.kill() + + reader = Sandbox.create(template, volume_mounts={"/mnt/data": volume}) + try: + assert reader.files.read("/mnt/data/hello.txt") == "written by the writer" + finally: + reader.kill() + finally: + Volume.destroy(volume.volume_id)