Skip to content

test: split SDK suites into a mocked unit tier and an opt-in e2e tier - #1739

Closed
devin-ai-integration[bot] wants to merge 3 commits into
mainfrom
devin/1787256556-test-tiers
Closed

test: split SDK suites into a mocked unit tier and an opt-in e2e tier#1739
devin-ai-integration[bot] wants to merge 3 commits into
mainfrom
devin/1787256556-test-tiers

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Every SDK package now has two test tiers: a fully mocked default tier and an opt-in e2e tier. Before this, pnpm test / pytest provisioned sandboxes and built templates, so the "unit" run needed credentials, live infra and minutes; the only gate was E2B_DEBUG, which is a different axis (local envd vs. provisioned sandbox).

The split follows what an assertion actually verifies: client-side logic (payload shaping, config propagation, version gating, format switching, error mapping, pagination, URL construction, pure utilities) stays in the default tier; anything crossing the RPC boundary into envd or the control plane (process execution, filesystem round-trips, PTY, git in the VM, lifecycle against live infra, server-side builds) is e2e.

Result — no VM provisioning, no credentials, deterministic:

js-sdk    52 files, 535 tests    2.3s
cli       15 files, 102 tests   13.2s
python   691 tests               5.7s   (ran with E2B_API_KEY unset)

JS SDK

The e2e file list lives in one place, tests/e2eFiles.mts, and is consumed by vitest.config.mts (as the e2e project's include and the unit/template projects' exclude) and by the Cloudflare workerd config's exclude. Adding a behavioral test means adding one glob.

// tests/setup.ts
export const isE2E = process.env.E2B_E2E !== undefined
export const e2eTest = base.skipIf(!isE2E)
export const e2eBuildTemplateTest = buildTemplateTest.skipIf(!isE2E)
// sandboxTest provisions a sandbox, so its auto fixture skips the whole test:
sandboxTestId: [async ({ skip }, use) => { skip(!isE2E, ...) }, { auto: true }]
"test": "vitest run --project unit --project template --project connectionConfig",
"test:e2e": "vitest run --project e2e",
"test:browser": "vitest run --project browser"

The default script names projects explicitly rather than using --project !e2e, which pulls in browser and fails without a Playwright binary. browser is e2e (it provisions a sandbox from a browser bundle) and moved out of the default run.

Mixed files were split so client-side assertions stay in the default tier: pure helper suites moved out of the behavioral directories (commands/commandHandlesandbox/commandHandle, files/entryInfo, files/watchHandle, git/validationsandbox/gitValidation), and mocked assertions living inside behavioral files were extracted into sandbox/connectDebug, sandbox/forkPayload, sandbox/lifecycleBehavior (the e2e remainder) and template/tagsBuild.

New mocked coverage for logic that was previously only exercised e2e — all msw/canned-response based, asserting on the request the SDK sends:

  • sandbox/versionGatescommands.run({ stdin: false }) below ENVD_COMMANDS_STDIN; filesystem.watchDir recursive / includeEntry / allowNetworkMounts gates.
  • sandbox/readFormattext/bytes/blob/stream switch, default user, gzip header, empty body, 404 → NotFoundError.
  • sandbox/uploadMode — multipart vs. octet-stream decision, old-envd fallback, streams, gzip, metadata, multi-file request shape.
  • template/serialization — file-hash stability and content/path sensitivity, missing source, Dockerfile/template payload serialization, registry config, COPY hash inclusion.

Python SDK

addopts = --import-mode=importlib -m "not e2e"

Marking is automatic rather than per-file, so a new test can't silently land in the default tier:

E2E_FIXTURES = frozenset({"sandbox", "sandbox_factory", "async_sandbox",
                          "async_sandbox_factory", "build", "async_build"})

def pytest_collection_modifyitems(items):
    for item in items:
        if isinstance(item, pytest.Function) and not E2E_FIXTURES.isdisjoint(item.fixturenames):
            item.add_marker(pytest.mark.e2e)

The handful of tests that hit live APIs without one of those fixtures (API kill of a non-existing sandbox, MCP gateway creation, template exists, background build) got an explicit @pytest.mark.e2e. Sync and async mocked mirrors of the JS extractions were added (test_version_gates, test_read_format, test_upload_mode, plus template_sync/test_serialization — the hashing/serialization utilities are shared, so it isn't duplicated for async).

CLI

The credential/skip logic duplicated in two files is now shared in tests/setup.ts and keyed on the opt-in:

export const skipE2E = !isE2E || !e2eApiKey || isDebug
export const e2eTest = test.skipIf(skipE2E)

exec_pipe.test.ts and backend_integration.test.ts reuse it. create_lifecycle.test.ts deliberately does not: it is a vi.mock-based suite asserting on the options the CLI passes to Sandbox.create, so gating it behind credentials would remove default coverage rather than add it.

CI

SDK Tests (required, every PR) runs the unit tier only. The reusable workflows gained an e2e: boolean input that switches the run to pnpm test:e2e / pytest -m e2e / CLI test:e2e; a new opt-in SDK E2E Tests workflow calls them with e2e: true on manual dispatch (optionally against staging) or when a PR carries the e2e label. Playwright install steps only run in the e2e leg.

Note: the staging legs of SDK Tests now re-run the mocked tier against the staging domain, so per-PR backend-compatibility signal moves to the e2e workflow. Happy to make the staging legs e2e-on-PR instead if that signal should stay automatic.

Docs: tests/README.md in each package (which tier a test belongs in, how to run each) and a table in CONTRIBUTING.md.

Usage

# default: mocked, no credentials, no sandboxes
cd packages/js-sdk    && pnpm test
cd packages/python-sdk && uv run pytest
cd packages/cli       && pnpm test

# opt-in e2e
export E2B_API_KEY=e2b_...
cd packages/js-sdk    && pnpm test:e2e && pnpm test:browser
cd packages/python-sdk && uv run pytest -m e2e
cd packages/cli       && pnpm test:e2e

Review follow-ups

Second commit, in response to review:

  • E2B_E2E=1 moved out of the package scripts (POSIX-only, breaks the Windows CI legs) into the e2e Vitest project's env and a new packages/cli/vitest.e2e.config.mts.
  • hostedTest / hostedSandboxTest restore the skipIf(isDebug) skip for control-plane tests, so E2B_E2E=1 pnpm test:e2e still works against a local envd.
  • Version-gate tests derive versions from the ENVD_* constants plus a belowEnvdVersion / below_envd_version helper (a prerelease of the threshold) instead of hardcoded literals, and the accepted-path test runs against msw rather than a real sandbox URL.
  • Serialization tests compare against InstructionType members; the 404 case asserts FileNotFoundError; the two wrong-error-class assertions carry TODOs instead of freezing the wrong contract.
  • Python @pytest.mark.mocked opts a test out of fixture-based e2e marking — the template stacktrace suites mock every build call, so their 54 tests stay in the default tier.
  • Pure getSignature tests extracted to sandbox/secureSignature.test.ts; CLI e2e domain falls back to a named DEFAULT_E2E_DOMAIN = 'e2b.dev'.
  • Volumes: new volume/mountPayload.test.ts + tests/shared/volume/test_mount_payload.py (unit) cover volumeMounts request shaping, and volume/mount.test.ts + sync/async test_mount.py (e2e) assert a mounted volume is the same storage in two sandboxes — the one volume behavior no mock can cover.

Fixes SDK-136.

Link to Devin session: https://app.devin.ai/sessions/e237ae2e3d8043fbabca7017400e6e57
Requested by: @mishushakov

Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@changeset-bot

changeset-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8e72370

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@e2b/python-sdk Patch
@e2b/cli Patch
e2b Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Package Artifacts

Built from 11f56bd. Download artifacts from this workflow run.

JS SDK (e2b@2.44.2-devin-1787256556-test-tiers.0):

npm install ./e2b-2.44.2-devin-1787256556-test-tiers.0.tgz

CLI (@e2b/cli@2.16.4-devin-1787256556-test-tiers.0):

npm install ./e2b-cli-2.16.4-devin-1787256556-test-tiers.0.tgz

Python SDK (e2b==2.44.0+devin.1787256556.test.tiers):

pip install ./e2b-2.44.0+devin.1787256556.test.tiers-py3-none-any.whl

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 98bb6bd5c9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/js-sdk/package.json Outdated
Comment on lines +31 to +32
"test:e2e": "E2B_E2E=1 vitest run --project e2e",
"test:browser": "E2B_E2E=1 vitest run --project browser",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use a cross-platform environment assignment

In the inspected JS SDK workflow, the e2e matrix includes windows-latest, but this package script uses POSIX assignment syntax; the CLI adds the same script while its workflow also targets Windows. Package scripts use cmd.exe by default on Windows, as described in the npm scripts documentation, so these jobs fail before Vitest starts with E2B_E2E treated as a command. Set the variable in the workflow env block or use a cross-platform helper.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The flag is no longer set in the package script: JS sets it in the e2e project's env in vitest.config.mts, and the CLI got a vitest.e2e.config.mts that merges the base config with env: { E2B_E2E: '1' }. test:e2e/test:browser are now plain vitest run invocations, so they work under cmd.exe.

Comment thread packages/js-sdk/tests/api/list.test.ts Outdated
query: { metadata: { sandboxTestId } },
})
const sandboxes = await paginator.nextItems()
sandboxTest('list sandboxes', async ({ sandbox, sandboxTestId }) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve debug-mode skips for control-plane e2e tests

When E2B_DEBUG=true, Sandbox.create returns the fixed local debug_sandbox_id without provisioning through the control plane, but this test now proceeds to Sandbox.list(), which still queries the hosted control-plane API and cannot find that local sandbox. The previous skipIf(isDebug) prevented this failure; restore it for control-plane tests (as both sync and async Python tests still do) so E2B_E2E=1 pnpm test:e2e remains usable against local envd.

AGENTS.md reference: AGENTS.md:L3-L3

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. tests/setup.ts now exports hostedTest/hostedSandboxTest (e2eTest/sandboxTest plus skipIf(isDebug)), and every control-plane test uses them — api/{list,info,kill,snapshot}, plus the lifecycle/connectivity/snapshot/metrics/network files. e2eBuildTemplateTest skips under debug too, since builds are always server-side. E2B_E2E=1 pnpm test:e2e against local envd is usable again.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TASTE.md review of the unit/e2e test split. Checked the rules the diff can actually touch: enum vocabulary (T-15, T-41), envd version gating (T-55), error domains and specificity (T-42, T-57, T-58, T-59, T-60), named defaults (T-47), and JS/sync-Python/async-Python parity of the new mocked mirrors (T-1, T-2).

The tiering itself reads clean: the opt-in flag follows the E2B_ env-var convention (T-49), the e2e glob list has a single home, and the new mocked suites are mirrored across JS, sync Python and async Python.

6 violations, all in newly added test code:

  • 2 x bare 'RUN'/'WORKDIR'/'COPY' instruction literals instead of InstructionType (T-15, T-41) — JS and the Python mirror.
  • inline envd version strings at the call site instead of the ENVD_* threshold constants (T-55), which also drifted the JS/Python pair apart (0.2.4 vs 0.2.9 for the same gate, T-1).
  • a 404 assertion on the NotFoundError base rather than FileNotFoundError (T-58, T-60), diverging from the Python mirror which does assert FileNotFoundException.
  • two new assertions that pin down error classes TASTE forbids: TemplateError/TemplateException for a sandbox.files argument gate (T-57, T-59) and a bare Error/ValueError for a builder precondition (T-42). Both are pre-existing SDK behavior, so the fix belongs in the SDK, not here — but freezing them in a new test makes them contractual, so they should at least be marked.
  • a magic 'e2b.app' default domain in the shared CLI test setup (T-47), which also disagrees with the e2b.dev default the CLI and SDK use.

Not tied to a line: template_sync/test_serialization.py has no template_async mirror. T-2 wants sync and async as separate mirrors, but the hashing/serialization utilities under test are genuinely shared and non-async, so the single copy is fine — worth a one-line note in tests/README.md so the next person doesn't read it as an oversight. Same for the deliberate non-gating of create_lifecycle.test.ts, which the PR description explains but the test file doesn't.

assert.isUndefined(payload.fromTemplate)
assert.deepEqual(
payload.steps.map((step: { type: string }) => step.type),
['RUN', 'WORKDIR']

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

T-41 / T-15 — every site that filters or dispatches on instruction.type goes through InstructionType; a bare 'RUN' / 'WORKDIR' / 'COPY' literal is never a legitimate read of the enum. That is exactly what makes a typo a compile error instead of a silently-passing (or silently-vacuous) assertion — steps.find((step) => step.type === 'COPY') on line 102 returns undefined on a typo and assert.isUndefined(copyStep(...)) still passes.

Add import { InstructionType } from '../../src/template/types' and use the members here and on line 102.

Suggested change
['RUN', 'WORKDIR']
[InstructionType.RUN, InstructionType.WORKDIR]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the suite imports InstructionType from src/template/types and both the step-order assertion and the COPY lookup compare against enum members, so a typo is a compile error.

assert payload["startCmd"] == "python main.py"
assert payload["readyCmd"] == "curl -f http://localhost:8000"
assert payload.get("fromTemplate") is None
assert [step["type"] for step in payload["steps"]] == ["RUN", "WORKDIR"]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

T-41 / T-15 — same as the JS mirror: reads of instruction.type go through InstructionType, never a bare "RUN" / "WORKDIR" / "COPY" literal (line 102 too). InstructionType is a str Enum, so comparing it against the serialized payload works unchanged.

Add from e2b.template.types import InstructionType.

Suggested change
assert [step["type"] for step in payload["steps"]] == ["RUN", "WORKDIR"]
assert [step["type"] for step in payload["steps"]] == [
InstructionType.RUN,
InstructionType.WORKDIR,
]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — same change in the Python mirror: from e2b.template.types import InstructionType, used for the step-order assertion and the COPY lookup (it's a str Enum, so the payload comparison is unchanged).


describe('commands', () => {
test('rejects stdin:false below ENVD_COMMANDS_STDIN', async () => {
const sandbox = sandboxWithEnvd('0.2.4')

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

T-55 — envd-version-dependent behavior is expressed through the named ENVD_* threshold constants in envd/versions, never an inline version string at the call site. These new suites hardcode a version per gate ('0.2.4', '0.1.3', '0.6.2', '0.6.3', '0.6.1', '0.5.6') plus a '0.6.4' default in readFormat/uploadMode; the test name references ENVD_COMMANDS_STDIN but nothing ties the literal to it. Bump a constant and the tests keep passing while no longer exercising the gate — and the '0.6.4' default silently sits below the next threshold added.

Derive the versions from the constants instead, e.g. import ENVD_COMMANDS_STDIN and pin the below-threshold and at-threshold cases relative to it (a small belowVersion(ENVD_COMMANDS_STDIN) helper in tests/setup.ts, and ENVD_VERSION_WATCH_NETWORK_MOUNTS as the "supported envd" default).

Related T-1: the JS mirror uses '0.2.4' and the Python one "0.2.9" for the same gate — a symptom of the literals not having a single source.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Both suites now import the ENVD_* thresholds (ENVD_COMMANDS_STDIN, ENVD_FILE_METADATA, ENVD_OCTET_STREAM_UPLOAD, ENVD_DEFAULT_USER, ENVD_VERSION_RECURSIVE_WATCH, ENVD_VERSION_FS_EVENT_ENTRY_INFO, ENVD_VERSION_WATCH_NETWORK_MOUNTS) and derive the reject-branch version from the threshold instead of hardcoding one: belowEnvdVersion(v) in tests/setup.ts (a -0 prerelease) and below_envd_version(v) in tests/envd_versions.py (an rc1 prerelease). Supported-path defaults use ENVD_DEBUG_FALLBACK, so a new threshold can't leave them silently below the gate, and the JS/Python divergence is gone.

Comment on lines +139 to +144
await expect(files.read('/home/user/missing.txt')).rejects.toThrowError(
NotFoundError
)
await expect(
files.read('/home/user/missing.txt', { format: 'stream' })
).rejects.toThrowError(NotFoundError)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

T-58 / T-60 — prefer specific over generic: assert FileNotFoundError, not the shared NotFoundError base. 404 on a file op is defined to land on FileNotFoundError (envd/api.ts maps 404 and Code.NotFound to it), so the base-class assertion passes even if the mapping regresses to a SandboxNotFoundError — and it disagrees with the Python mirror, which does assert FileNotFoundException (T-1).

Update the import on line 6 to FileNotFoundError and the test name on line 128 accordingly.

Suggested change
await expect(files.read('/home/user/missing.txt')).rejects.toThrowError(
NotFoundError
)
await expect(
files.read('/home/user/missing.txt', { format: 'stream' })
).rejects.toThrowError(NotFoundError)
await expect(files.read('/home/user/missing.txt')).rejects.toThrowError(
FileNotFoundError
)
await expect(
files.read('/home/user/missing.txt', { format: 'stream' })
).rejects.toThrowError(FileNotFoundError)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the 404 case asserts FileNotFoundError, not the base NotFoundError.

sandbox('0.6.1').files.write('/home/user/hello.txt', 'hello world', {
metadata: { origin: 'unit-test' },
})
).rejects.toThrowError(TemplateError)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

T-59 / T-57 — this is argument validation that runs before any operation starts (no request leaves the SDK), so the contract is InvalidArgumentError; TemplateError names a template build that never existed, and a sandbox.files failure should extend SandboxError in any case. Same for the watchDir gates in versionGates.test.ts (lines 49/57/65) and the Python TemplateException mirrors.

The throw itself is pre-existing SDK behavior, so don't change it in a test-only PR — but a new assertion turns it into a documented contract. Either assert on the message and leave the class unpinned, or keep the class with a // TODO(T-59): should be InvalidArgumentError marker so the eventual fix is findable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assertion now names the class the gate actually throws and carries a TODO that this contract is wrong: version-gating an argument on sandbox.files should reject with InvalidArgumentError, not TemplateError. Left as a follow-up rather than changing SDK behavior in a test-only PR.

})

test('hashing a source that matches no file fails', async () => {
await expect(filesHash('nope.txt', '/app/')).rejects.toThrow()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

T-42 — builder precondition failures raise BuildError / BuildException, never a bare Error or ValueError. calculateFilesHash currently throws a bare Error (and calculate_files_hash a bare ValueError, which the Python mirror asserts on line 51), and an untyped .rejects.toThrow() locks that in: it would keep passing after the SDK is fixed, and it documents no contract today.

Same call as above — the SDK fix is out of scope for a test-only PR, so mark it rather than pinning the wrong class:

Suggested change
await expect(filesHash('nope.txt', '/app/')).rejects.toThrow()
// TODO(T-42): should reject with BuildError once calculateFilesHash stops throwing a bare Error.
await expect(filesHash('nope.txt', '/app/')).rejects.toThrow()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same treatment: the missing-source case asserts only that it rejects, with a TODO that it should be a TemplateError once calculateFilesHash stops throwing a bare Error (ValueError in Python). Freezing the wrong class in a test would make the fix look like a regression.

Comment thread packages/cli/tests/setup.ts Outdated
process.env.E2B_DOMAIN ||
userConfig?.E2B_DOMAIN ||
userConfig?.domain ||
'e2b.app'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

T-47 — defaults live in named constants, not magic values at the call site. This one also disagrees with the default everywhere else: packages/cli/src/user.ts and the SDK's ConnectionConfig use e2b.dev, so an e2e run with no E2B_DOMAIN and no ~/.e2b/config.json points the CLI at a different domain than the SDK it drives.

The literal moved here from the two test files rather than being introduced, but promoting it to shared setup is the moment to name it — import the SDK's default domain, or at minimum:

Suggested change
'e2b.app'
DEFAULT_E2E_DOMAIN

(with const DEFAULT_E2E_DOMAIN = 'e2b.dev' above, matching T-49's explicit-option → env-var → default chain.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — tests/setup.ts has a named DEFAULT_E2E_DOMAIN = 'e2b.dev' used as the last fallback after E2B_DOMAIN and the user config.

Comment on lines +69 to +84
// The gates pass, so the call proceeds to the RPC and fails on the network
// instead — the point is that it is not a TemplateError.
const sandbox = sandboxWithEnvd('0.6.4')

await sandbox.files
.watchDir('/home/user', noop, {
recursive: true,
includeEntry: true,
allowNetworkMounts: true,
requestTimeoutMs: 1_000,
})
.then(
() => assert.fail('expected the request to fail without a sandbox'),
(err: Error) => assert.notInstanceOf(err, TemplateError)
)
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 In versionGates.test.ts (lines 68-84), the "accepts the gated options on a supported envd" test uses envd 0.6.4, which passes all three client-side version gates and lets files.watchDir() proceed into a real RPC call to https://49983-sbx-version-gate.sandbox.e2b.dev — actual production e2b.dev infrastructure — instead of stopping at the mocked boundary like its sibling files (readFormat.test.ts, uploadMode.test.ts).

Extended reasoning...

This file is not in tests/e2eFiles.mts, so it runs in both the default unit vitest project (pnpm test) and the Cloudflare workerd project (pnpm test:cf) — the two tiers this PR's own description advertises as fully mocked, deterministic, and requiring "no VM provisioning, no credentials" (even calling out running with E2B_API_KEY unset). Every other test in this same file passes version gates that fail client-side, so the request never leaves the process; this one test is the sole exception where the gates pass and execution falls through to this.rpc.watchDir(...) and await handleWatchDirStartEvent(events) in src/sandbox/filesystem/index.ts, which actually opens a connect-rpc stream.

Unlike readFormat.test.ts and uploadMode.test.ts, which mock the same kind of sandbox-pointed request with an msw setupServer, versionGates.test.ts has no msw server and no handler for this domain at all. tests/globalFetchFallback.setup.ts (the only setup file for this project) just reroutes the SDK onto globalThis.fetch so msw could intercept it if a server were running — it does not mock any response itself. With no server listening, the request escapes straight to the real network and hits sandbox.e2b.dev with a nonexistent sandbox ID on every default pnpm test and pnpm test:cf run.

Concretely: envd 0.6.4 clears ENVD_VERSION_RECURSIVE_WATCH, ENVD_VERSION_FS_EVENT_ENTRY_INFO, and ENVD_VERSION_WATCH_NETWORK_MOUNTS, so recursive: true, includeEntry: true, and allowNetworkMounts: true all pass validation → watchDir calls this.rpc.watchDir(...) → the connect-rpc transport (built from envdRpcFetch) issues an HTTPS request to 49983-sbx-version-gate.sandbox.e2b.dev → the request either times out after requestTimeoutMs: 1_000, DNS-fails, or gets a real 4xx/5xx from e2b.dev's edge → the test only asserts err not instanceof TemplateError, which is true for every one of those outcomes, so the test passes regardless.

That last point is also why this doesn't break CI today: no matter what the network does, the assertion holds. But it means the test's "pass" is not actually verifying client-side behavior at all — it's a no-op assertion riding on an unintended live network call, contradicting the fully-mocked/deterministic tier boundary this PR is establishing everywhere else in the same file and its sibling files.

Fix: give this test the same treatment as readFormat.test.ts/uploadMode.test.ts — add an msw handler for https://49983-sbx-version-gate.sandbox.e2b.dev/... that returns a canned response (or an error), so the assertion actually exercises the SDK's error-mapping instead of live network variance. Alternatively, since the real intent here appears to be "prove the gates don't reject," the test could stop right after confirming the gate check passes rather than letting the call proceed to the RPC layer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the accepted-path test now runs against an msw setupServer that answers the envd RPC, so it asserts the SDK sent the request instead of reaching a real sandbox URL and passing on the resulting failure.

Comment on lines +53 to +65
"async_sandbox_factory",
"build",
"async_build",
}
)


def pytest_collection_modifyitems(items):
for item in items:
if isinstance(item, pytest.Function) and not E2E_FIXTURES.isdisjoint(
item.fixturenames
):
item.add_marker(pytest.mark.e2e)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The fixture-name heuristic in pytest_collection_modifyitems (conftest.py:60-65) auto-marks any test using the build/async_build fixture as e2e, but it doesn't account for tests that fully mock that fixture. tests/sync/template_sync/test_stacktrace.py and tests/async/template_async/test_stacktrace.py each declare 27 tests (54 total) on build/async_build, yet both files have an autouse mock_template_build fixture that monkeypatches the request/upload/status calls so Template.build() never hits the network. As a result, all 54 fully-mocked stacktrace-mapping tests get silently excluded from the default uv run pytest run (via addopts = -m "not e2e") and only execute under the opt-in pytest -m e2e tier, losing default per-PR coverage of that logic with no warning.

Extended reasoning...

The bug: pytest_collection_modifyitems in packages/python-sdk/tests/conftest.py (lines 53-65) marks a test as e2e purely based on whether one of its requested fixture names is in E2E_FIXTURES = {"sandbox", "sandbox_factory", "async_sandbox", "async_sandbox_factory", "build", "async_build"}. This is a reasonable heuristic for most of the suite, since build/async_build normally drive a real server-side template build. But pytest.ini's new addopts = --import-mode=importlib -m "not e2e" means anything tagged e2e is silently dropped from the default uv run pytest run that gates every PR.

Where it breaks: tests/sync/template_sync/test_stacktrace.py and its async mirror tests/async/template_async/test_stacktrace.py each declare the build/async_build fixture as a parameter on 27 test functions (54 total). Both files also define an autouse fixture, mock_template_build, that monkeypatches request_build, trigger_build, get_file_upload_link, and get_build_status on template_(sync|async)_main/build_api_mod. Because that fixture is autouse, it patches these calls for every test in the file before the build/async_build fixture actually invokes Template.build(...), so the "build" never leaves the process — no network call, no credentials needed, no live infra touched. These are pure client-side tests of the stacktrace step-index mapping logic.

Why the existing code doesn't catch this: the hook only inspects item.fixturenames, which lists every fixture a test transitively depends on by name, with no way to know whether the fixture it's keying on was overridden or wrapped by another (autouse) fixture that neutralizes its side effects. E2E_FIXTURES treats build/async_build as an unconditional live-call signal, which is true everywhere else in the suite but false here.

Impact: all 54 stacktrace-mapping tests move from the required default tier into the opt-in pytest -m e2e tier, which needs E2B_API_KEY and typically only runs on the e2e label or manual dispatch. This directly undercuts the PR's own stated goal — "client-side logic … stays in the default tier" — for a meaningful chunk of tests, and it happens silently: nothing fails, SDK Tests stays green, and the gap is easy to miss since the tests still pass when someone does run the e2e tier.

Step-by-step proof:

  1. A test like test_stacktrace.py::test_maps_step_index_for_run_cmd(build) requests the build fixture.
  2. pytest_collection_modifyitems sees "build" in item.fixturenames and calls item.add_marker(pytest.mark.e2e).
  3. pytest.ini's addopts = -m "not e2e" deselects it from a plain uv run pytest invocation.
  4. But mock_template_build (autouse in the same file) has already patched request_build/trigger_build/get_file_upload_link/get_build_status, so when the build fixture calls Template.build(...), it resolves entirely against the mocks — no network I/O, no E2B_API_KEY requirement.
  5. Net effect: a fully mocked, credential-free test is excluded from the tier explicitly designed to hold "fully mocked, credential-free" tests, and only runs when someone opts into -m e2e.

Suggested fix: either mark these two files with an explicit override (e.g. pytestmark = [pytest.mark.skip_debug()] plus something like a local fixture-name exemption), or have the heuristic check whether mock_template_build (or an equivalent) is also present in item.fixturenames and skip the auto-e2e marking in that case. A sweep: pattern could generalize this: any file with an autouse mock_*_build fixture alongside a build/async_build parameter should be exempted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — pytest.ini declares a mocked marker and pytest_collection_modifyitems skips auto-marking anything carrying it. Both template_{sync,async}/test_stacktrace.py set pytestmark = pytest.mark.mocked next to the autouse fixture that mocks every build call, so those 54 tests stay in the default tier despite requesting the build fixture.

Comment on lines 57 to 63
assert.deepEqual(readSignatureExpected, readSignatureReceived)
})

test.skipIf(isDebug)('signing generation with expiration', async () => {
e2eTest('signing generation with expiration', async () => {
const operation = 'read'
const path = '/home/user/hello.txt'
const user = 'root'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 packages/js-sdk/tests/sandbox/secure.test.ts is matched by the e2eFiles glob ('secure' in the sandbox list), so the whole file — including three tests that only call the pure client-side getSignature() helper ('signing generation', 'signing generation with expiration', 'static signing key comparison') — now runs only under pnpm test:e2e with credentials instead of the default pnpm test tier they ran in before this PR. Per the PR's own split criterion (client-side logic stays default) and the pattern it already applies for fork.test.ts/connect.test.ts, these three should be extracted into a mocked companion file (e.g. secureSignature.test.ts) alongside forkPayload.test.ts and connectDebug.test.ts.

Extended reasoning...

What the bug is

getSignature() in src/sandbox/signature.ts is a pure, client-side function: it builds a string from path/operation/user/envdAccessToken/expiration and returns a local sha256-derived signature. It never opens a socket, calls envd, or touches a sandbox.

tests/sandbox/secure.test.ts has three top-level tests that exercise only this function:

  • signing generation (originally line 33)
  • signing generation with expiration (originally line 60)
  • static signing key comparison (originally line 92)

Each one calls getSignature() directly and asserts the result against an independently-computed (or hardcoded) hash string — no sandboxTest fixture, no network call, no envd round-trip.

The code path that triggers it

Before this PR, these three tests used test.skipIf(isDebug), so they ran unconditionally in the default pnpm test run (skipped only under E2B_DEBUG). This PR converts them to e2eTest (base.skipIf(!isE2E)) and adds secure.test.ts to the e2eFiles glob in tests/e2eFiles.mts (tests/sandbox/{...,secure,...}.test.ts). That glob is consumed by vitest.config.mts as the exclude list for the unit and template projects and as the include list for the new e2e project. Because file-level exclude applies to the whole file, not per-test, all three of these pure-logic tests are removed from the projects that back pnpm test and only run under pnpm test:e2e (needs E2B_E2E=1 plus credentials).

Why existing code does not prevent it

The rest of secure.test.ts (the describe('secure sandbox', ...) block) genuinely needs a live sandbox — it writes files and fetches signed download URLs — so moving the file to e2e reads as correct at a glance. Nothing in the PR's tooling flags that a subset of tests in an e2e-tagged file do not actually use the e2e fixtures; the mixed-file problem is exactly what the PR solves elsewhere (see below), just missed here.

Impact

Three assertions that used to run on every pnpm test invocation (including required CI) now silently stop being part of that signal. They still exist and still pass under pnpm test:e2e, so nothing is deleted or broken at runtime — this is a coverage/organization regression, not a functional bug. A change to getSignature()'s hashing scheme would no longer be caught by the default/required test tier.

How to fix it

Follow the same extraction pattern the PR already applies to other mixed files: pull the three pure getSignature() tests out of secure.test.ts into a new file (e.g. tests/sandbox/secureSignature.test.ts) that is not matched by the e2eFiles glob, using a plain test(...) (or test.skipIf(isDebug)) instead of e2eTest. This mirrors fork.test.ts -> forkPayload.test.ts and connect.test.ts -> connectDebug.test.ts in this same diff.

Step-by-step proof

  1. Check the glob: tests/e2eFiles.mts line ~18 is 'tests/sandbox/{...,secure,...}.test.ts', which matches tests/sandbox/secure.test.ts.
  2. Check vitest.config.mts: the unit project's exclude spreads ...e2eFiles, and the template/cloudflare configs do too — so secure.test.ts is excluded from every project pnpm test runs (--project unit --project template --project connectionConfig).
  3. Check the e2e project: its include is e2eFiles, so secure.test.ts (the whole file) is only picked up by pnpm test:e2e.
  4. Inside that file, signing generation, signing generation with expiration, and static signing key comparison each call only getSignature(...) from ../../src and assert.deepEqual/assert.equal against a value computed with node:crypto — no sandbox fixture parameter, no fetch, no sandboxTest.
  5. Therefore: running pnpm test today executes zero of these three assertions, whereas before this PR (with test.skipIf(isDebug)) they ran every time E2B_DEBUG was unset — i.e., in virtually all CI runs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the pure getSignature tests moved to tests/sandbox/secureSignature.test.ts, which stays in the unit tier. secure.test.ts keeps only the live download/reconnect assertions.

@mishushakov

Copy link
Copy Markdown
Member

check all comments

@mishushakov

Copy link
Copy Markdown
Member

any volume tests we should make e2e? right now it's all mocked

@mishushakov

Copy link
Copy Markdown
Member

attach the PR to the linear issue
SDK-136: Switch SDK tests from integration to mocks by default

Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

any volume tests we should make e2e? right now it's all mocked

One: real mount content. Everything the existing volume suites assert — CRUD request shaping, error mapping, pagination, the content transport — is client-side, so mocking it is right and they stay in the default tier. What no mock covers is that a mounted volume is actually the same storage in two sandboxes, so I added that:

  • js-sdk/tests/volume/mount.test.ts (e2e) — create a volume, mount it in sandbox A, write a file, kill A, mount it in sandbox B, read the file back, destroy the volume. Plus sync/async Python mirrors (volume_{sync,async}/test_mount.py).
  • js-sdk/tests/volume/mountPayload.test.ts + python-sdk/tests/shared/volume/test_mount_payload.py (unit) — the other half that was untested: Sandbox.create omits volumeMounts when none are requested and maps both { '/mnt/data': 'name' } and a Volume instance to [{ name, path }].

Note the e2e mount test provisions two sandboxes and hasn't been run against live infra yet — I don't have approval to spend on the e2e tier. Say the word and I'll run pnpm test:e2e / pytest -m e2e and report.

@linear-code

linear-code Bot commented Aug 21, 2026

Copy link
Copy Markdown

SDK-136

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

attach the PR to the linear issue

Done — the description now ends with Fixes SDK-136 so the GitHub integration links it, and I left a comment with the PR link on SDK-136. (The Linear MCP surface here has no URL-attachment tool, so I couldn't add it as an attachment row directly.)

Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Superseded by the review stack #1742#1743#1744#1745 (stack #1746), which has the same final tree split per module: base scaffolding, Sandbox, Volume, Template. Closing this one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant