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