Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions .github/workflows/js_sdk_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:
type: string
default: ''
node-only:
description: 'Run only the Node legs (skip Bun, Deno, and Cloudflare)'
description: 'Run only the Node legs (skip Bun, Deno, Cloudflare, and the browser)'
required: false
type: boolean
default: false
Expand All @@ -29,14 +29,17 @@ jobs:
strategy:
fail-fast: false
# With node-only (set by staging callers) the matrix collapses to the
# Node legs: the Bun/Deno/Cloudflare legs re-run suites the Node legs
# already cover and add sandbox/build load without extra backend
# signal, so the other runtimes are exercised against production only.
# Node legs: the Bun/Deno/Cloudflare/browser legs re-run suites the Node
# legs already cover and add sandbox/build load, so the other runtimes
# are exercised against production only. Note this does drop backend
# signal for the browser leg specifically — it's the only one that
# depends on the API's CORS headers — so a staging-only CORS regression
# surfaces on the production run rather than before merge.
matrix:
include: >-
${{ inputs.node-only
&& fromJSON('[{"runtime": "node", "os": "ubuntu-22.04"}, {"runtime": "node", "os": "windows-latest"}]')
|| fromJSON('[{"runtime": "node", "os": "ubuntu-22.04"}, {"runtime": "node", "os": "windows-latest"}, {"runtime": "bun", "os": "ubuntu-22.04"}, {"runtime": "deno", "os": "ubuntu-22.04"}, {"runtime": "cloudflare", "os": "ubuntu-22.04"}, {"runtime": "cloudflare-deploy", "os": "ubuntu-22.04"}]') }}
|| fromJSON('[{"runtime": "node", "os": "ubuntu-22.04"}, {"runtime": "node", "os": "windows-latest"}, {"runtime": "bun", "os": "ubuntu-22.04"}, {"runtime": "deno", "os": "ubuntu-22.04"}, {"runtime": "cloudflare", "os": "ubuntu-22.04"}, {"runtime": "cloudflare-deploy", "os": "ubuntu-22.04"}, {"runtime": "browser", "os": "ubuntu-22.04"}]') }}
runs-on: ${{ matrix.os }}
steps:
- name: Checkout repository
Expand Down Expand Up @@ -72,18 +75,17 @@ jobs:
run: |
pnpm install --frozen-lockfile

# Only the Node runtime runs the vitest `browser` project, which drives
# Chromium through Playwright.
# Only the browser leg needs Chromium, which Playwright drives.
- name: Get Playwright version
if: matrix.runtime == 'node'
if: matrix.runtime == 'browser'
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 == 'browser'
uses: actions/cache@v4
with:
path: ${{ matrix.os == 'windows-latest' && '~/AppData/Local/ms-playwright' || '~/.cache/ms-playwright' }}
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}

# The unit bundle test and the Cloudflare deploy config fail in CI when
Expand Down Expand Up @@ -139,3 +141,12 @@ jobs:
env:
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }}

# Full unit + connectionConfig suite inside Chromium (vitest browser mode);
# `pretest:browser` installs the browser and its system dependencies.
- name: Run test suite in the browser
if: matrix.runtime == 'browser'
run: pnpm test:browser
env:
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }}
10 changes: 2 additions & 8 deletions packages/js-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@
"generate:volume-api": "openapi-typescript ../../spec/openapi-volumecontent.yml -x api_key --array-length --alphabetize --output src/volume/schema.gen.ts",
"generate:mcp": "json2ts -i ./../../spec/mcp-server.json -o src/sandbox/mcp.d.ts --unreachableDefinitions --style.singleQuote --no-style.semi",
"check-deps": "knip",
"pretest": "npx playwright install --with-deps chromium",
"pretest:browser": "npx playwright install --with-deps chromium",
"test:browser": "vitest run --config tests/runtimes/browser/vitest.config.mts",
"test:bun": "bunx --bun vitest run --project unit --project connectionConfig --project template",
"test:cf": "vitest run --config tests/runtimes/cloudflare/vitest.config.mts",
"test:cf:deploy": "vitest run --config tests/runtimes/cloudflare-deploy/vitest.config.mts",
Expand All @@ -47,13 +48,9 @@
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.18.7",
"@redocly/cli": "2.39.0",
"@testing-library/react": "^16.2.0",
"@types/node": "^20.19.19",
"@types/platform": "^1.3.6",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@typescript/native": "npm:typescript@^7.0.2",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/browser": "^4.1.10",
"@vitest/browser-playwright": "^4.1.10",
"dotenv": "^16.4.5",
Expand All @@ -64,12 +61,9 @@
"npm-run-all": "^4.1.5",
"openapi-typescript": "^7.9.1",
"playwright": "^1.55.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tsdown": "^0.22.3",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"vitest": "^4.1.10",
"vitest-browser-react": "^2.2.0",
"wrangler": "^4.113.0"
},
"files": [
Expand Down
2 changes: 0 additions & 2 deletions packages/js-sdk/tests/api/http2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import { afterEach, expect, test, vi } from 'vitest'
afterEach(() => {
vi.restoreAllMocks()
vi.resetModules()
vi.doUnmock('undici')
vi.doUnmock('../../src/utils')
delete process.env.E2B_API_CONNECTIONS
delete process.env.E2B_API_INFLIGHT_REQUESTS
})
Expand Down
5 changes: 2 additions & 3 deletions packages/js-sdk/tests/api/list.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { assert } from 'vitest'
import { randomUUID } from 'crypto'

import { Sandbox, SandboxInfo } from '../../src'
import { sandboxTest, isDebug } from '../setup.js'
Expand All @@ -20,7 +19,7 @@ sandboxTest.skipIf(isDebug)(
)

sandboxTest.skipIf(isDebug)('list sandboxes with filter', async () => {
const uniqueId = randomUUID()
const uniqueId = crypto.randomUUID()
const extraSbx = await Sandbox.create({ metadata: { uniqueId } })

try {
Expand Down Expand Up @@ -237,7 +236,7 @@ sandboxTest.skipIf(isDebug)(
)

sandboxTest.skipIf(isDebug)('list sandboxes with filter', async () => {
const uniqueId = randomUUID()
const uniqueId = crypto.randomUUID()
const extraSbx = await Sandbox.create({ metadata: { uniqueId } })

try {
Expand Down
36 changes: 21 additions & 15 deletions packages/js-sdk/tests/connectionConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
setupRequestController,
wrapStreamWithConnectionCleanup,
} from '../src/connectionConfig'
import { runtime } from '../src/utils'

// Store original env vars to restore after tests
let originalEnv: { [key: string]: string | undefined }
Expand Down Expand Up @@ -61,21 +62,26 @@ test('api_url has correct priority', () => {
assert.equal(config.apiUrl, 'http://localhost:8080')
})

test('sandbox_url defaults to stable sandbox host in production', () => {
delete process.env.E2B_SANDBOX_URL
delete process.env.E2B_DOMAIN
delete process.env.E2B_DEBUG

const config = new ConnectionConfig()

assert.equal(
config.getSandboxUrl('sbx-test', {
sandboxDomain: 'e2b.app',
envdPort: 49983,
}),
'https://sandbox.e2b.app'
)
})
// The stable host is deliberately not used in a browser (CORS); the browser
// side of this branch is asserted in connectionConfig.browser.test.ts.
test.skipIf(runtime === 'browser')(
'sandbox_url defaults to stable sandbox host in production',
() => {
delete process.env.E2B_SANDBOX_URL
delete process.env.E2B_DOMAIN
delete process.env.E2B_DEBUG

const config = new ConnectionConfig()

assert.equal(
config.getSandboxUrl('sbx-test', {
sandboxDomain: 'e2b.app',
envdPort: 49983,
}),
'https://sandbox.e2b.app'
)
}
)

test('sandbox_direct_url keeps per-sandbox host in production', () => {
delete process.env.E2B_SANDBOX_URL
Expand Down
2 changes: 0 additions & 2 deletions packages/js-sdk/tests/envd/http2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import { afterEach, expect, test, vi } from 'vitest'
afterEach(() => {
vi.restoreAllMocks()
vi.resetModules()
vi.doUnmock('undici')
vi.doUnmock('../../src/utils')
delete process.env.E2B_ENVD_RPC_CONNECTIONS
delete process.env.E2B_ENVD_INFLIGHT_REQUESTS
delete process.env.E2B_ENVD_RPC_INFLIGHT_REQUESTS
Expand Down
9 changes: 5 additions & 4 deletions packages/js-sdk/tests/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { afterEach, expect, test, vi } from 'vitest'

// The unit project also runs under Bun, Deno, and Cloudflare's workerd, where
// the host runtime's own unstubbable marker (globalThis.Bun / globalThis.Deno /
// the Cloudflare-Workers user agent) correctly wins detection — these
// scenarios only exist on a Node host.
// The unit project also runs under Bun, Deno, Cloudflare's workerd, and a real
// browser, where the host runtime's own unstubbable marker (globalThis.Bun /
// globalThis.Deno / the Cloudflare-Workers user agent / `window`) correctly
// wins detection — these scenarios only exist on a Node host.
const isNodeHost =
typeof (globalThis as any).Bun === 'undefined' &&
typeof (globalThis as any).Deno === 'undefined' &&
typeof (globalThis as any).window === 'undefined' &&
(globalThis as any).navigator?.userAgent !== 'Cloudflare-Workers'

afterEach(() => {
Expand Down
7 changes: 7 additions & 0 deletions packages/js-sdk/tests/runtimes/browser/importMeta.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Vite exposes the runner's `env` config on `import.meta.env`, which the
// browser suite reads instead of `process.env`. The canonical types live in
// `vite/client`, but vite is only a transitive dependency here (via vitest) and
// isn't resolvable from this package, so declare the shape the suite uses.
interface ImportMeta {
readonly env: Record<string, string | undefined>
}
50 changes: 50 additions & 0 deletions packages/js-sdk/tests/runtimes/browser/noProcessGlobal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { assert, test } from 'vitest'

import { template } from '../../template'

// Bundlers don't give browser apps a `process` unless asked to, so the SDK has
// to be fully configurable through explicit options — `getEnvVar`
// (src/api/metadata.ts) returns '' when `process` is missing rather than
// throwing. The rest of this suite runs against the `process.env` shim from
// processEnv.setup.ts, which would mask a regression that reintroduces a bare
// `process` read on the create path, so this test drops the shim entirely and
// drives a real sandbox the way a browser app has to.
//
// The SDK is imported dynamically inside the test, after the shim is gone: a
// static import is evaluated at collection time, while the shim is still
// installed, which would let a top-level `process` read anywhere in the module
// graph pass here and still crash a browser app on import.
//
// Config comes from `import.meta.env` (where vitest puts the config's `env`),
// which is also how a Vite app would hand its own build-time values over.
const apiKey = import.meta.env.E2B_API_KEY
const domain = import.meta.env.E2B_DOMAIN || undefined
const isDebug = import.meta.env.E2B_DEBUG !== undefined

test.skipIf(isDebug)(
'drives a sandbox with no process global',
async () => {
const shim = Reflect.getOwnPropertyDescriptor(globalThis, 'process')
Reflect.deleteProperty(globalThis, 'process')
assert.equal(typeof process, 'undefined')

try {
const { Sandbox } = await import('../../../src')
const sandbox = await Sandbox.create(template, { apiKey, domain })

try {
await sandbox.files.write('hello.txt', 'Hello World')
assert.equal(await sandbox.files.read('hello.txt'), 'Hello World')

const result = await sandbox.commands.run('echo "from the browser"')
assert.equal(result.exitCode, 0)
assert.equal(result.stdout.trim(), 'from the browser')
} finally {
await sandbox.kill()
}
} finally {
if (shim) Reflect.defineProperty(globalThis, 'process', shim)
}
},
60_000
)
15 changes: 15 additions & 0 deletions packages/js-sdk/tests/runtimes/browser/processEnv.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Browsers have no `process`: `getEnvVar` (src/api/metadata.ts) resolves to ''
// there, so E2B_* can only reach a browser app through whatever shim its
// bundler injects. Provide that shim — the same thing workerd's nodejs_compat
// gives the Cloudflare suite for free — so the shared suites can configure the
// SDK from the environment like they do on every other runtime.
//
// `process.env` aliases Vite's `import.meta.env`, which is where vitest puts
// the config's `env` and what `vi.stubEnv` writes to in browser mode, so env
// reads and stubbing both behave as they do on Node.
//
// Only `env` is defined: `process.release` must stay absent or the SDK would
// detect the runtime as 'node' instead of 'browser'. Tests that need a
// browser without any `process` at all delete it themselves — see
// noProcessGlobal.test.ts.
Object.assign(globalThis, { process: { env: import.meta.env } })
38 changes: 0 additions & 38 deletions packages/js-sdk/tests/runtimes/browser/run.test.tsx

This file was deleted.

71 changes: 71 additions & 0 deletions packages/js-sdk/tests/runtimes/browser/vitest.config.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { playwright } from '@vitest/browser-playwright'
import { config } from 'dotenv'
import { defineConfig } from 'vitest/config'

// Real env vars win over `.env`, matching dotenv's own precedence.
const env = { ...config().parsed, ...process.env }

// Config the shared suites read from the environment. Everything the SDK takes
// is `E2B_`-prefixed; ENABLE_VOLUME_TESTS gates the volume suite. Forwarded by
// name rather than as the whole environment, which would inline every host
// variable into JS served to the browser.
const testEnv = Object.fromEntries(
Object.entries(env).filter(
([name]) => name.startsWith('E2B_') || name === 'ENABLE_VOLUME_TESTS'
)
) as Record<string, string>

// Runs the unit + connectionConfig projects (same coverage as test:bun /
// test:deno / test:cf) inside a real Chromium via Playwright, against src.
// Nothing is skipped for being a browser: the suites that can't run here are
// Node-only rather than browser-hostile, and they're excluded below. Tests that
// read a response from a server inside the sandbox start a CORS-enabled one
// (`corsHttpServerCmd` in tests/setup.ts), the way a browser app's own server
// would be configured.
export default defineConfig({
test: {
name: 'browser',
include: [
'tests/**/*.test.ts',
// Browser-only suite; tests/runtimes/** is otherwise excluded below.
'tests/runtimes/browser/**/*.test.ts',
],
exclude: [
// Other runtimes' suites, which run under their own configs. This
// suite's own browser-only tests are re-included above; a new runtime
// directory needs adding here.
'tests/runtimes/cloudflare*/**',
'tests/template/**',
// Inspects the host-built dist/index.mjs via node:fs, which the browser
// can never see; the Node unit project keeps running it.
'tests/bundle/**',
// Resolves the `undici`/`undici8` packages off `process.versions.node`.
// The browser never takes that path — `createRuntimeFetch` late-binds
// the global fetch outside Node — so there is nothing to cover here.
'tests/undici.test.ts',
// These mock the API with msw's `setupServer`, whose `msw/node` entry
// pulls in node:http and can't be served to the browser. Porting them
// means `setupWorker` plus a service worker served from a public dir.
'tests/sandbox/abortSignal.test.ts',
'tests/volume/volume.test.ts',
],
globals: false,
testTimeout: 30_000,
// A real browser has no `process`; the setup file shims `process.env` onto
// `import.meta.env`, where vitest puts `env` below, so the shared suites
// can read their config the way they do on every other runtime.
setupFiles: ['tests/runtimes/browser/processEnv.setup.ts'],
env: testEnv,
browser: {
enabled: true,
// Defaults to `isCI`, so set it explicitly for local runs too.
headless: true,
// These tests drive the SDK and never render anything, so a failure
// screenshot is a picture of a blank page. Off by default it would write
// one PNG per failed test into .vitest-attachments/.
screenshotFailures: false,
provider: playwright(),
instances: [{ browser: 'chromium' }],
},
},
})
Loading
Loading