From 193dee35f436844902ceaa357fea5ff3124f06f5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:50:37 +0000 Subject: [PATCH 1/2] test: mock all template build tests Co-Authored-By: mish@e2b.dev --- packages/js-sdk/tests/setup.ts | 3 + .../tests/template/backgroundBuild.test.ts | 17 +- packages/js-sdk/tests/template/build.test.ts | 7 + packages/js-sdk/tests/template/exists.test.ts | 18 +- .../template/methods/makeSymlink.test.ts | 8 + .../tests/template/methods/runCmd.test.ts | 9 +- .../js-sdk/tests/template/mockBuildApi.ts | 311 ++++++++++++++++++ packages/js-sdk/tests/template/tags.test.ts | 196 ++++++----- .../tests/async/template_async/conftest.py | 11 + .../methods/test_from_dockerfile.py | 8 - .../methods/test_make_symlink.py | 4 - .../template_async/methods/test_run_cmd.py | 3 - .../methods/test_to_dockerfile.py | 5 - .../template_async/test_background_build.py | 1 - .../tests/async/template_async/test_build.py | 5 - .../tests/async/template_async/test_exists.py | 3 - .../async/template_async/test_stacktrace.py | 31 -- .../tests/async/template_async/test_tags.py | 4 - packages/python-sdk/tests/mock_build_api.py | 248 ++++++++++++++ .../tests/sync/template_sync/conftest.py | 11 + .../methods/test_from_dockerfile.py | 8 - .../methods/test_make_symlink.py | 4 - .../template_sync/methods/test_run_cmd.py | 3 - .../methods/test_to_dockerfile.py | 5 - .../template_sync/test_background_build.py | 1 - .../tests/sync/template_sync/test_build.py | 4 - .../tests/sync/template_sync/test_exists.py | 3 - .../sync/template_sync/test_stacktrace.py | 31 -- .../tests/sync/template_sync/test_tags.py | 4 - 29 files changed, 745 insertions(+), 221 deletions(-) create mode 100644 packages/js-sdk/tests/template/mockBuildApi.ts create mode 100644 packages/python-sdk/tests/mock_build_api.py diff --git a/packages/js-sdk/tests/setup.ts b/packages/js-sdk/tests/setup.ts index 91d427c3cc..1f3fc20755 100644 --- a/packages/js-sdk/tests/setup.ts +++ b/packages/js-sdk/tests/setup.ts @@ -56,6 +56,9 @@ async function buildTemplate( memoryMB: 1024, skipCache: options?.skipCache, onBuildLogs: captureLogs, + // The placeholder key keeps the mocked template tests independent of + // E2B_API_KEY being set in the environment. + apiKey: process.env.E2B_API_KEY ?? TEST_API_KEY, }) } catch (e) { console.error( diff --git a/packages/js-sdk/tests/template/backgroundBuild.test.ts b/packages/js-sdk/tests/template/backgroundBuild.test.ts index 0e798b47cd..a8f780e553 100644 --- a/packages/js-sdk/tests/template/backgroundBuild.test.ts +++ b/packages/js-sdk/tests/template/backgroundBuild.test.ts @@ -1,6 +1,18 @@ import { randomUUID } from 'node:crypto' -import { expect, test } from 'vitest' +import { afterAll, beforeAll, expect, test } from 'vitest' +import { setupServer } from 'msw/node' import { Template, waitForTimeout } from '../../src' +import { TEST_API_KEY } from '../setup' +import { createMockBuildApi } from './mockBuildApi' + +const server = setupServer(...createMockBuildApi().handlers) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterAll(() => server.close()) + +// The placeholder key keeps the mocked template tests independent of +// E2B_API_KEY being set in the environment. +const apiKey = process.env.E2B_API_KEY ?? TEST_API_KEY test('build template in background', async () => { const template = Template() @@ -14,12 +26,13 @@ test('build template in background', async () => { const buildInfo = await Template.buildInBackground(template, name, { cpuCount: 1, memoryMB: 1024, + apiKey, }) // 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) + const status = await Template.getBuildStatus(buildInfo, { apiKey }) 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..87963f4e7d 100644 --- a/packages/js-sdk/tests/template/build.test.ts +++ b/packages/js-sdk/tests/template/build.test.ts @@ -2,8 +2,15 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { afterAll, beforeAll } from 'vitest' +import { setupServer } from 'msw/node' import { defaultBuildLogger, Template, waitForTimeout } from '../../src' import { buildTemplateTest } from '../setup' +import { createMockBuildApi } from './mockBuildApi' + +const server = setupServer(...createMockBuildApi().handlers) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterAll(() => server.close()) // 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 diff --git a/packages/js-sdk/tests/template/exists.test.ts b/packages/js-sdk/tests/template/exists.test.ts index aa62857c72..cdd4866b13 100644 --- a/packages/js-sdk/tests/template/exists.test.ts +++ b/packages/js-sdk/tests/template/exists.test.ts @@ -1,14 +1,26 @@ import { randomUUID } from 'node:crypto' -import { expect, test } from 'vitest' +import { afterAll, beforeAll, expect, test } from 'vitest' +import { setupServer } from 'msw/node' import { Template } from '../../src' +import { TEST_API_KEY } from '../setup' +import { createMockBuildApi } from './mockBuildApi' + +const server = setupServer(...createMockBuildApi().handlers) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterAll(() => server.close()) + +// The placeholder key keeps the mocked template tests independent of +// E2B_API_KEY being set in the environment. +const apiKey = process.env.E2B_API_KEY ?? TEST_API_KEY test('check if base template name exists', async () => { - const exists = await Template.exists('base') + const exists = await Template.exists('base', { apiKey }) expect(exists).toBe(true) }) test('check non existing name', async () => { const nonExistingName = `nonexistent-${randomUUID()}` - const exists = await Template.exists(nonExistingName) + const exists = await Template.exists(nonExistingName, { apiKey }) 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..78f0f9ec49 100644 --- a/packages/js-sdk/tests/template/methods/makeSymlink.test.ts +++ b/packages/js-sdk/tests/template/methods/makeSymlink.test.ts @@ -1,5 +1,13 @@ +import { afterAll, beforeAll } from 'vitest' +import { setupServer } from 'msw/node' import { Template } from '../../../src' import { buildTemplateTest } from '../../setup' +import { createMockBuildApi } from '../mockBuildApi' + +const server = setupServer(...createMockBuildApi().handlers) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterAll(() => server.close()) buildTemplateTest('make symlink', async ({ buildTemplate }) => { const template = Template() diff --git a/packages/js-sdk/tests/template/methods/runCmd.test.ts b/packages/js-sdk/tests/template/methods/runCmd.test.ts index 340a226a74..fed46ed38f 100644 --- a/packages/js-sdk/tests/template/methods/runCmd.test.ts +++ b/packages/js-sdk/tests/template/methods/runCmd.test.ts @@ -1,6 +1,13 @@ -import { expect } from 'vitest' +import { afterAll, beforeAll, expect } from 'vitest' +import { setupServer } from 'msw/node' import { Template } from '../../../src' import { buildTemplateTest } from '../../setup' +import { createMockBuildApi } from '../mockBuildApi' + +const server = setupServer(...createMockBuildApi().handlers) + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterAll(() => server.close()) buildTemplateTest('run command', async ({ buildTemplate }) => { const template = Template() diff --git a/packages/js-sdk/tests/template/mockBuildApi.ts b/packages/js-sdk/tests/template/mockBuildApi.ts new file mode 100644 index 0000000000..bd50f63267 --- /dev/null +++ b/packages/js-sdk/tests/template/mockBuildApi.ts @@ -0,0 +1,311 @@ +import { randomUUID } from 'node:crypto' +import { http, HttpResponse, type HttpHandler } from 'msw' + +import { apiUrl } from '../setup' + +interface MockLogEntry { + timestamp: string + level: 'debug' | 'info' | 'warn' | 'error' + message: string +} + +interface MockBuild { + templateID: string + buildID: string + alias: string + tags: string[] + triggered: boolean + logEntries: MockLogEntry[] + finalStatus: 'ready' | 'error' + reason?: { message: string; step?: string } + createdAt: string +} + +interface MockBuildApiState { + /** Builds keyed by buildID. */ + builds: Map + /** Template IDs keyed by alias. */ + templates: Map +} + +const VALID_USERS = new Set(['root', 'user']) + +function logEntry(message: string): MockLogEntry { + return { timestamp: new Date().toISOString(), level: 'info', message } +} + +function splitName(name: string): { alias: string; tag?: string } | undefined { + const colonIndex = name.indexOf(':') + if (colonIndex === -1) { + return name.length > 0 ? { alias: name } : undefined + } + const alias = name.slice(0, colonIndex) + const tag = name.slice(colonIndex + 1) + if (alias.length === 0 || tag.length === 0) { + return undefined + } + return { alias, tag } +} + +function badRequest(message: string) { + return HttpResponse.json({ code: 400, message }, { status: 400 }) +} + +function notFound(message: string) { + return HttpResponse.json({ code: 404, message }, { status: 404 }) +} + +/** + * Stateful in-memory mock of the template build API (request build, file + * upload links, trigger, status polling, aliases, and tags). The `base` + * alias is pre-seeded so `fromTemplate('base')` and `Template.exists('base')` + * work out of the box. + */ +export function createMockBuildApi(): { + handlers: HttpHandler[] + state: MockBuildApiState +} { + const builds = new Map() + const templates = new Map() + + function seedTemplate(alias: string) { + const build: MockBuild = { + templateID: randomUUID(), + buildID: randomUUID(), + alias, + tags: ['latest'], + triggered: true, + logEntries: [logEntry('Build finished')], + finalStatus: 'ready', + createdAt: new Date().toISOString(), + } + builds.set(build.buildID, build) + templates.set(alias, build.templateID) + } + + seedTemplate('base') + + function latestBuildForAlias(alias: string): MockBuild | undefined { + let latest: MockBuild | undefined + for (const build of builds.values()) { + if (build.alias === alias) { + latest = build + } + } + return latest + } + + const handlers = [ + // Request a template build + http.post(apiUrl('/v3/templates'), async ({ request }) => { + const body = (await request.clone().json()) as { + name: string + tags?: string[] + } + + const parsedName = splitName(body.name) + if (!parsedName) { + return badRequest(`Invalid template name: '${body.name}'`) + } + + const { alias, tag } = parsedName + const templateID = templates.get(alias) ?? randomUUID() + templates.set(alias, templateID) + + const tags = [...(tag ? [tag] : []), ...(body.tags ?? [])] + const build: MockBuild = { + templateID, + buildID: randomUUID(), + alias, + tags, + triggered: false, + logEntries: [], + finalStatus: 'ready', + createdAt: new Date().toISOString(), + } + builds.set(build.buildID, build) + + return HttpResponse.json({ + templateID, + buildID: build.buildID, + public: false, + names: [alias], + tags, + aliases: [alias], + }) + }), + + // Check whether the files for a hash are already uploaded. Always + // reporting them as cached (with no upload URL) skips the upload step. + http.get(apiUrl('/templates/:templateID/files/:hash'), () => { + return HttpResponse.json({ present: true }) + }), + + // Trigger a build: simulate it synchronously by recording one log entry + // per step, then mark the final status. + http.post<{ templateID: string; buildID: string }>( + apiUrl('/v2/templates/:templateID/builds/:buildID'), + async ({ params, request }) => { + const build = builds.get(params.buildID) + if (!build || build.templateID !== params.templateID) { + return notFound('Build not found') + } + + const body = (await request.clone().json()) as { + fromImage?: string + fromTemplate?: string + steps?: { type: string; args?: string[] }[] + } + + const from = body.fromImage ?? body.fromTemplate ?? 'base' + build.logEntries.push(logEntry(`FROM ${from}`)) + for (const [index, step] of (body.steps ?? []).entries()) { + // RUN steps carry the user in args[1]; only users that exist in the + // base image are accepted, like the real build backend. + const user = step.type === 'RUN' ? step.args?.[1] : undefined + if (user && !VALID_USERS.has(user)) { + build.finalStatus = 'error' + build.reason = { + message: `failed to run command '${step.args?.[0]}': command failed: unauthenticated: invalid username: '${user}'`, + step: String(index + 1), + } + break + } + build.logEntries.push( + logEntry( + `Step ${index + 1}: ${step.type} ${(step.args ?? []).join(' ')}` + ) + ) + } + if (build.finalStatus !== 'error') { + build.logEntries.push(logEntry('Build finished')) + } + build.triggered = true + + return HttpResponse.json({}, { status: 202 }) + } + ), + + // Poll build status: deliver the pending log entries on the first call + // (status `building`), then report the final status once drained. + http.get<{ templateID: string; buildID: string }>( + apiUrl('/templates/:templateID/builds/:buildID/status'), + ({ params, request }) => { + const build = builds.get(params.buildID) + if (!build || build.templateID !== params.templateID) { + return notFound('Build not found') + } + + const logsOffset = Number( + new URL(request.url).searchParams.get('logsOffset') ?? 0 + ) + const logEntries = build.logEntries.slice(logsOffset) + + let status: string + if (!build.triggered) { + status = 'waiting' + } else if (logEntries.length > 0) { + status = 'building' + } else { + status = build.finalStatus + } + + return HttpResponse.json({ + templateID: build.templateID, + buildID: build.buildID, + status, + logEntries, + logs: logEntries.map((entry) => entry.message), + ...(status === 'error' ? { reason: build.reason } : {}), + }) + } + ), + + // Check whether an alias exists + http.get<{ alias: string }>( + apiUrl('/templates/aliases/:alias'), + ({ params }) => { + const templateID = templates.get(params.alias) + if (!templateID) { + return notFound('Template not found') + } + return HttpResponse.json({ templateID, public: false }) + } + ), + + // Assign tags to an existing build + http.post(apiUrl('/templates/tags'), async ({ request }) => { + const body = (await request.clone().json()) as { + target: string + tags: string[] + } + + const parsedTarget = splitName(body.target) + if (!parsedTarget) { + return badRequest(`Invalid target: '${body.target}'`) + } + + const build = latestBuildForAlias(parsedTarget.alias) + if (!build) { + return notFound('Template not found') + } + + // Tags may be bare ('production') or namespaced ('alias:production'); + // the API returns and stores just the tag portion. + const tags: string[] = [] + for (const tag of body.tags) { + const parsedTag = splitName(tag) + if (!parsedTag) { + return badRequest(`Invalid tag: '${tag}'`) + } + tags.push(parsedTag.tag ?? parsedTag.alias) + } + + build.tags.push(...tags) + + return HttpResponse.json({ buildID: build.buildID, tags }) + }), + + // Remove tags from a template + http.delete(apiUrl('/templates/tags'), async ({ request }) => { + const body = (await request.clone().json()) as { + name: string + tags: string[] + } + + const build = latestBuildForAlias(body.name) + if (!build) { + return notFound('Template not found') + } + + build.tags = build.tags.filter((tag) => !body.tags.includes(tag)) + + return new HttpResponse(null, { status: 204 }) + }), + + // List tags for a template + http.get<{ templateID: string }>( + apiUrl('/templates/:templateID/tags'), + ({ params }) => { + const templateBuilds = Array.from(builds.values()).filter( + (build) => build.templateID === params.templateID + ) + if (templateBuilds.length === 0) { + return notFound('Template not found') + } + + return HttpResponse.json( + templateBuilds.flatMap((build) => + build.tags.map((tag) => ({ + tag, + buildID: build.buildID, + createdAt: build.createdAt, + })) + ) + ) + } + ), + ] + + return { handlers, state: { builds, templates } } +} diff --git a/packages/js-sdk/tests/template/tags.test.ts b/packages/js-sdk/tests/template/tags.test.ts index 21e2166709..d040c1619d 100644 --- a/packages/js-sdk/tests/template/tags.test.ts +++ b/packages/js-sdk/tests/template/tags.test.ts @@ -5,7 +5,8 @@ import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { Template } from '../../src' -import { apiUrl, buildTemplateTest, isDebug } from '../setup' +import { apiUrl, buildTemplateTest, TEST_API_KEY } from '../setup' +import { createMockBuildApi } from './mockBuildApi' // Mock handlers for tag API endpoints const mockHandlers = [ @@ -58,6 +59,10 @@ const mockHandlers = [ const server = setupServer(...mockHandlers) +// The placeholder key keeps the mocked template tests independent of +// E2B_API_KEY being set in the environment. +const apiKey = process.env.E2B_API_KEY ?? TEST_API_KEY + // Unit tests with mock server describe('Template tags unit tests', () => { beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) @@ -66,16 +71,21 @@ describe('Template tags unit tests', () => { describe('Template.assignTags', () => { test('assigns a single tag', async () => { - const result = await Template.assignTags('my-template:v1.0', 'production') + const result = await Template.assignTags( + 'my-template:v1.0', + 'production', + { apiKey } + ) expect(result.buildId).toBe('00000000-0000-0000-0000-000000000000') expect(result.tags).toContain('production') }) test('assigns multiple tags', async () => { - const result = await Template.assignTags('my-template:v1.0', [ - 'production', - 'stable', - ]) + const result = await Template.assignTags( + 'my-template:v1.0', + ['production', 'stable'], + { apiKey } + ) expect(result.buildId).toBe('00000000-0000-0000-0000-000000000000') expect(result.tags).toContain('production') expect(result.tags).toContain('stable') @@ -86,27 +96,29 @@ describe('Template tags unit tests', () => { test('deletes a single tag', async () => { // Should not throw await expect( - Template.removeTags('my-template', 'production') + Template.removeTags('my-template', 'production', { apiKey }) ).resolves.toBeUndefined() }) test('deletes multiple tags', async () => { // Should not throw await expect( - Template.removeTags('my-template', ['production', 'staging']) + Template.removeTags('my-template', ['production', 'staging'], { + apiKey, + }) ).resolves.toBeUndefined() }) test('handles 404 error for nonexistent template', async () => { await expect( - Template.removeTags('nonexistent', ['tag']) + Template.removeTags('nonexistent', ['tag'], { apiKey }) ).rejects.toThrow() }) }) describe('Template.getTags', () => { test('returns tags for a template', async () => { - const tags = await Template.getTags('my-template-id') + const tags = await Template.getTags('my-template-id', { apiKey }) expect(tags).toHaveLength(2) expect(tags[0].tag).toBe('v1.0') expect(tags[0].buildId).toBe('00000000-0000-0000-0000-000000000000') @@ -117,86 +129,94 @@ describe('Template tags unit tests', () => { }) test('handles 404 for nonexistent template', async () => { - await expect(Template.getTags('nonexistent')).rejects.toThrow() + await expect( + Template.getTags('nonexistent', { apiKey }) + ).rejects.toThrow() }) }) }) -// 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', - ]) +// Integration tests against the stateful mock build API +describe('Template tags integration tests', () => { + const integrationServer = setupServer(...createMockBuildApi().handlers) + + beforeAll(() => integrationServer.listen({ onUnhandledRequest: 'error' })) + afterAll(() => integrationServer.close()) + + buildTemplateTest( + 'build template with tags, assign and delete', + 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'], + { apiKey } + ) + + expect(tagInfo.buildId).toBeTruthy() + expect(tagInfo.tags).toContain('production') + expect(tagInfo.tags).toContain('latest') + } + ) + + buildTemplateTest( + 'assign single tag to existing template', + async ({ buildTemplate }) => { + const templateName = 'e2b-tags-test' + const initialTag = `${templateName}:v1-${randomUUID()}` - 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() - } -) + 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', { + apiKey, + }) + + expect(tagInfo.buildId).toBeTruthy() + expect(tagInfo.tags).toContain('stable') + } + ) + + buildTemplateTest( + 'rejects invalid tag format - missing alias', + 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', { apiKey }) + ).rejects.toThrow() + } + ) + + buildTemplateTest( + 'rejects invalid tag format - missing tag', + 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}:`, { apiKey }) + ).rejects.toThrow() + } + ) +}) diff --git a/packages/python-sdk/tests/async/template_async/conftest.py b/packages/python-sdk/tests/async/template_async/conftest.py index 520f64f208..1ffb5b93a7 100644 --- a/packages/python-sdk/tests/async/template_async/conftest.py +++ b/packages/python-sdk/tests/async/template_async/conftest.py @@ -2,9 +2,20 @@ import pytest +from mock_build_api import MockBuildAPI + _DIR = os.path.dirname(os.path.abspath(__file__)) +@pytest.fixture(autouse=True) +def mock_build_api(monkeypatch, test_api_key) -> MockBuildAPI: + """Route all template build API calls through the in-memory mock.""" + monkeypatch.setenv("E2B_API_KEY", test_api_key) + mock = MockBuildAPI() + mock.install_async(monkeypatch) + return mock + + def pytest_collection_modifyitems(items): for item in items: if str(item.fspath).startswith(_DIR): diff --git a/packages/python-sdk/tests/async/template_async/methods/test_from_dockerfile.py b/packages/python-sdk/tests/async/template_async/methods/test_from_dockerfile.py index 1c9f2fda08..4c9fb729a3 100644 --- a/packages/python-sdk/tests/async/template_async/methods/test_from_dockerfile.py +++ b/packages/python-sdk/tests/async/template_async/methods/test_from_dockerfile.py @@ -1,10 +1,7 @@ -import pytest - from e2b import AsyncTemplate from e2b.template.types import InstructionType -@pytest.mark.skip_debug() async def test_from_dockerfile(): dockerfile = """FROM node:24 WORKDIR /app @@ -42,7 +39,6 @@ async def test_from_dockerfile(): assert template._template._start_cmd == "sleep 20" -@pytest.mark.skip_debug() async def test_from_dockerfile_with_default_user_and_workdir(): dockerfile = "FROM node:24" @@ -54,7 +50,6 @@ async def test_from_dockerfile_with_default_user_and_workdir(): assert template._template._instructions[-1]["args"][0] == "/home/user" -@pytest.mark.skip_debug() async def test_from_dockerfile_with_custom_user_and_workdir(): dockerfile = "FROM node:24\nUSER mish\nWORKDIR /home/mish" @@ -66,7 +61,6 @@ async def test_from_dockerfile_with_custom_user_and_workdir(): assert template._template._instructions[-1]["args"][0] == "/home/mish" -@pytest.mark.skip_debug() async def test_from_dockerfile_with_multi_source_copy(): dockerfile = """FROM node:24 COPY file1.txt file2.txt file3.txt /dest/""" @@ -86,7 +80,6 @@ async def test_from_dockerfile_with_multi_source_copy(): assert copy_instructions[2]["args"][1] == "/dest/" -@pytest.mark.skip_debug() async def test_from_dockerfile_with_multi_source_copy_chown(): dockerfile = """FROM node:24 COPY --chown=myuser:mygroup pkg.json pkg-lock.json /app/""" @@ -106,7 +99,6 @@ async def test_from_dockerfile_with_multi_source_copy_chown(): assert copy_instructions[1]["args"][2] == "myuser:mygroup" -@pytest.mark.skip_debug() async def test_from_dockerfile_with_copy_chown(): dockerfile = """FROM node:24 COPY --chown=myuser:mygroup app.js /app/ diff --git a/packages/python-sdk/tests/async/template_async/methods/test_make_symlink.py b/packages/python-sdk/tests/async/template_async/methods/test_make_symlink.py index f6c1c61139..747fc7aadf 100644 --- a/packages/python-sdk/tests/async/template_async/methods/test_make_symlink.py +++ b/packages/python-sdk/tests/async/template_async/methods/test_make_symlink.py @@ -1,9 +1,6 @@ -import pytest - from e2b import AsyncTemplate -@pytest.mark.skip_debug() async def test_make_symlink(async_build): template = ( AsyncTemplate() @@ -16,7 +13,6 @@ async def test_make_symlink(async_build): await async_build(template) -@pytest.mark.skip_debug() async def test_make_symlink_force(async_build): template = ( AsyncTemplate() diff --git a/packages/python-sdk/tests/async/template_async/methods/test_run_cmd.py b/packages/python-sdk/tests/async/template_async/methods/test_run_cmd.py index 6f29096ab3..8d06b036c6 100644 --- a/packages/python-sdk/tests/async/template_async/methods/test_run_cmd.py +++ b/packages/python-sdk/tests/async/template_async/methods/test_run_cmd.py @@ -3,14 +3,12 @@ from e2b import AsyncTemplate -@pytest.mark.skip_debug() async def test_run_command(async_build): template = AsyncTemplate().from_image("ubuntu:22.04").skip_cache().run_cmd("ls -l") await async_build(template) -@pytest.mark.skip_debug() async def test_run_command_as_different_user(async_build): template = ( AsyncTemplate() @@ -22,7 +20,6 @@ async def test_run_command_as_different_user(async_build): await async_build(template) -@pytest.mark.skip_debug() async def test_run_command_as_user_that_does_not_exist(async_build): template = ( AsyncTemplate() diff --git a/packages/python-sdk/tests/async/template_async/methods/test_to_dockerfile.py b/packages/python-sdk/tests/async/template_async/methods/test_to_dockerfile.py index f955d1fbfa..4ad13e75da 100644 --- a/packages/python-sdk/tests/async/template_async/methods/test_to_dockerfile.py +++ b/packages/python-sdk/tests/async/template_async/methods/test_to_dockerfile.py @@ -1,9 +1,6 @@ -import pytest - from e2b import AsyncTemplate -@pytest.mark.skip_debug() async def test_to_dockerfile(): template = ( AsyncTemplate() @@ -21,7 +18,6 @@ async def test_to_dockerfile(): assert dockerfile == expected_dockerfile -@pytest.mark.skip_debug() async def test_to_dockerfile_with_options(): template = ( AsyncTemplate() @@ -39,7 +35,6 @@ async def test_to_dockerfile_with_options(): assert dockerfile == expected_dockerfile -@pytest.mark.skip_debug() async def test_to_dockerfile_with_env_instructions(): template = ( AsyncTemplate() 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..ca674434ed 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,7 +5,6 @@ from e2b import AsyncTemplate, wait_for_timeout -@pytest.mark.skip_debug() @pytest.mark.timeout(10) async def test_build_in_background_should_start_build_and_return_info(): """Test that build_in_background returns immediately without waiting for build to complete.""" diff --git a/packages/python-sdk/tests/async/template_async/test_build.py b/packages/python-sdk/tests/async/template_async/test_build.py index f16da0bd4b..21f0836618 100644 --- a/packages/python-sdk/tests/async/template_async/test_build.py +++ b/packages/python-sdk/tests/async/template_async/test_build.py @@ -40,7 +40,6 @@ def setup_test_folder(): shutil.rmtree(test_dir, ignore_errors=True) -@pytest.mark.skip_debug() async def test_build_template(async_build, setup_test_folder): template = ( AsyncTemplate(file_context_path=setup_test_folder) @@ -54,13 +53,11 @@ async def test_build_template(async_build, setup_test_folder): await async_build(template, skip_cache=True, on_build_logs=default_build_logger()) -@pytest.mark.skip_debug() async def test_build_template_from_base_template(async_build): template = AsyncTemplate().from_template("base") await async_build(template, skip_cache=True, on_build_logs=default_build_logger()) -@pytest.mark.skip_debug() async def test_build_template_with_symlinks(async_build, setup_test_folder): template = ( AsyncTemplate(file_context_path=setup_test_folder) @@ -73,7 +70,6 @@ async def test_build_template_with_symlinks(async_build, setup_test_folder): await async_build(template) -@pytest.mark.skip_debug() async def test_build_template_with_resolve_symlinks(async_build, setup_test_folder): template = ( AsyncTemplate(file_context_path=setup_test_folder) @@ -91,7 +87,6 @@ async def test_build_template_with_resolve_symlinks(async_build, setup_test_fold await async_build(template) -@pytest.mark.skip_debug() async def test_build_template_with_skip_cache(async_build, setup_test_folder): template = ( AsyncTemplate(file_context_path=setup_test_folder) 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..55d6b455e3 100644 --- a/packages/python-sdk/tests/async/template_async/test_exists.py +++ b/packages/python-sdk/tests/async/template_async/test_exists.py @@ -1,18 +1,15 @@ import uuid -import pytest from e2b import AsyncTemplate -@pytest.mark.skip_debug() async def test_check_base_template_name_exists(): """Test that the base template name exists.""" exists = await AsyncTemplate.exists("base") assert exists is True -@pytest.mark.skip_debug() async def test_check_non_existing_name(): """Test that a non-existing name returns False.""" non_existing_name = f"nonexistent-{uuid.uuid4()}" 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..84198e7332 100644 --- a/packages/python-sdk/tests/async/template_async/test_stacktrace.py +++ b/packages/python-sdk/tests/async/template_async/test_stacktrace.py @@ -107,7 +107,6 @@ async def _expect_to_throw_and_check_trace(func, expected_method: str): assert saw_expected_method, traceback.format_exc() -@pytest.mark.skip_debug() async def test_traces_on_from_image(async_build): template = AsyncTemplate().from_image("e2b.dev/this-image-does-not-exist") await _expect_to_throw_and_check_trace( @@ -115,7 +114,6 @@ async def test_traces_on_from_image(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_from_template(async_build): template = AsyncTemplate().from_template("this-template-does-not-exist") await _expect_to_throw_and_check_trace( @@ -124,7 +122,6 @@ async def test_traces_on_from_template(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_from_dockerfile(async_build): template = AsyncTemplate().from_dockerfile("FROM ubuntu:22.04\nRUN nonexistent") await _expect_to_throw_and_check_trace( @@ -133,7 +130,6 @@ async def test_traces_on_from_dockerfile(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_from_image_registry(async_build): template = AsyncTemplate().from_image( "registry.example.com/nonexistent:latest", @@ -146,7 +142,6 @@ async def test_traces_on_from_image_registry(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_from_image_credentials(): await _expect_to_throw_and_check_trace( lambda: AsyncTemplate().from_image("ubuntu:22.04", username="user"), @@ -154,7 +149,6 @@ async def test_traces_on_from_image_credentials(): ) -@pytest.mark.skip_debug() async def test_traces_on_from_aws_registry(async_build): template = AsyncTemplate().from_aws_registry( "123456789.dkr.ecr.us-east-1.amazonaws.com/nonexistent:latest", @@ -167,7 +161,6 @@ async def test_traces_on_from_aws_registry(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_from_gcp_registry(async_build): template = AsyncTemplate().from_gcp_registry( "gcr.io/nonexistent-project/nonexistent:latest", @@ -180,7 +173,6 @@ async def test_traces_on_from_gcp_registry(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_copy(async_build): template = AsyncTemplate() template = template.from_base_image() @@ -190,7 +182,6 @@ async def test_traces_on_copy(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_copyItems(async_build): template = AsyncTemplate() template = template.from_base_image() @@ -202,7 +193,6 @@ async def test_traces_on_copyItems(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_second_source_of_multi_source_copy(async_build): template = AsyncTemplate() template = template.from_base_image() @@ -212,7 +202,6 @@ async def test_traces_on_second_source_of_multi_source_copy(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_step_after_multi_source_copy(async_build): template = AsyncTemplate() template = template.from_base_image() @@ -223,7 +212,6 @@ async def test_traces_on_step_after_multi_source_copy(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_second_item_of_copy_items(async_build): template = AsyncTemplate() template = template.from_base_image() @@ -238,7 +226,6 @@ async def test_traces_on_second_item_of_copy_items(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_step_after_copy_items(async_build): template = AsyncTemplate() template = template.from_base_image() @@ -254,7 +241,6 @@ async def test_traces_on_step_after_copy_items(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_copy_absolute_path(): await _expect_to_throw_and_check_trace( lambda: AsyncTemplate() @@ -264,7 +250,6 @@ async def test_traces_on_copy_absolute_path(): ) -@pytest.mark.skip_debug() async def test_traces_on_copyItems_absolute_path(): await _expect_to_throw_and_check_trace( lambda: AsyncTemplate() @@ -274,7 +259,6 @@ async def test_traces_on_copyItems_absolute_path(): ) -@pytest.mark.skip_debug() async def test_traces_on_remove(async_build): template = AsyncTemplate() template = template.from_base_image() @@ -284,7 +268,6 @@ async def test_traces_on_remove(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_rename(async_build): template = AsyncTemplate() template = template.from_base_image() @@ -294,7 +277,6 @@ async def test_traces_on_rename(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_make_dir(async_build): template = AsyncTemplate() template = template.from_base_image() @@ -304,7 +286,6 @@ async def test_traces_on_make_dir(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_make_symlink(async_build): template = AsyncTemplate() template = template.from_base_image() @@ -314,7 +295,6 @@ async def test_traces_on_make_symlink(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_run_cmd(async_build): template = AsyncTemplate() template = template.from_base_image() @@ -324,7 +304,6 @@ async def test_traces_on_run_cmd(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_set_workdir(async_build): template = AsyncTemplate() template = template.from_base_image() @@ -334,7 +313,6 @@ async def test_traces_on_set_workdir(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_set_user(async_build): template = AsyncTemplate() template = template.from_base_image() @@ -344,7 +322,6 @@ async def test_traces_on_set_user(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_pip_install(async_build): template = AsyncTemplate() template = template.from_base_image() @@ -354,7 +331,6 @@ async def test_traces_on_pip_install(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_npm_install(async_build): template = AsyncTemplate() template = template.from_base_image() @@ -364,7 +340,6 @@ async def test_traces_on_npm_install(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_bun_install(async_build): template = AsyncTemplate() template = template.from_base_image() @@ -374,7 +349,6 @@ async def test_traces_on_bun_install(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_apt_install(async_build): template = AsyncTemplate() template = template.from_base_image() @@ -384,7 +358,6 @@ async def test_traces_on_apt_install(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_git_clone(async_build): template = AsyncTemplate() template = template.from_base_image() @@ -394,7 +367,6 @@ async def test_traces_on_git_clone(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_start_cmd(async_build): template = AsyncTemplate() template = template.from_base_image() @@ -406,7 +378,6 @@ async def test_traces_on_start_cmd(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_add_mcp_server(): # needs mcp-gateway as base template, without it no mcp servers can be added await _expect_to_throw_and_check_trace( @@ -415,7 +386,6 @@ async def test_traces_on_add_mcp_server(): ) -@pytest.mark.skip_debug() async def test_traces_on_dev_container_prebuild(async_build): template = AsyncTemplate() template = template.from_template("devcontainer") @@ -426,7 +396,6 @@ async def test_traces_on_dev_container_prebuild(async_build): ) -@pytest.mark.skip_debug() async def test_traces_on_set_dev_container_start(async_build): template = AsyncTemplate() template = template.from_template("devcontainer") diff --git a/packages/python-sdk/tests/async/template_async/test_tags.py b/packages/python-sdk/tests/async/template_async/test_tags.py index 73eba0aaa5..e4e2224220 100644 --- a/packages/python-sdk/tests/async/template_async/test_tags.py +++ b/packages/python-sdk/tests/async/template_async/test_tags.py @@ -175,7 +175,6 @@ async def test_get_tags_error(self, monkeypatch): class TestTagsIntegration: """Integration tests for AsyncTemplate tags functionality.""" - @pytest.mark.skip_debug() async def test_build_template_with_tags_assign_and_delete(self, async_build): """Test building a template with tags, assigning new tags, and deleting.""" template_name = "e2b-tags-test" @@ -198,7 +197,6 @@ async def test_build_template_with_tags_assign_and_delete(self, async_build): assert "production" in tag_info.tags assert "latest" in tag_info.tags - @pytest.mark.skip_debug() async def test_assign_single_tag_to_existing_template(self, async_build): """Test assigning a single tag (not array) to an existing template.""" template_name = "e2b-tags-test" @@ -214,7 +212,6 @@ async def test_assign_single_tag_to_existing_template(self, async_build): # API returns just the tag portion, not the full alias:tag assert "stable" in tag_info.tags - @pytest.mark.skip_debug() async def test_rejects_invalid_tag_format_missing_alias(self, async_build): """Test that tag without alias (starts with colon) is rejected.""" template_name = "e2b-tags-test" @@ -227,7 +224,6 @@ async def test_rejects_invalid_tag_format_missing_alias(self, async_build): with pytest.raises(Exception): await AsyncTemplate.assign_tags(initial_tag, ":invalid-tag") - @pytest.mark.skip_debug() async def test_rejects_invalid_tag_format_missing_tag(self, async_build): """Test that tag without tag portion (ends with colon) is rejected.""" template_name = "e2b-tags-test" diff --git a/packages/python-sdk/tests/mock_build_api.py b/packages/python-sdk/tests/mock_build_api.py new file mode 100644 index 0000000000..e2cb841d95 --- /dev/null +++ b/packages/python-sdk/tests/mock_build_api.py @@ -0,0 +1,248 @@ +"""In-memory mock of the template build control-plane API. + +Mirrors packages/js-sdk/tests/template/mockBuildApi.ts: builds are simulated +synchronously at trigger time (one log entry per step) and drained through +status polling, the `base` alias is pre-seeded, and aliases/tags are tracked +in memory. The mock is installed by monkeypatching the build API functions +in the sync/async `main` modules (the same seam the stacktrace tests use), +so specialized fixtures can still override individual functions. +""" + +from dataclasses import dataclass, field +from datetime import datetime +from types import SimpleNamespace +from typing import Dict, List, Optional, Tuple +from uuid import uuid4 + +from e2b.exceptions import BuildException, TemplateException +from e2b.template.logger import LogEntry +from e2b.template.types import ( + BuildStatusReason, + InstructionType, + TemplateBuildStatus, + TemplateBuildStatusResponse, + TemplateTag, + TemplateTagInfo, +) + +# Users that exist in the base image, like on the real build backend. +_VALID_USERS = {"root", "user"} + + +@dataclass +class MockBuild: + template_id: str + build_id: str + alias: str + tags: List[str] + triggered: bool + log_entries: List[LogEntry] = field(default_factory=list) + final_status: TemplateBuildStatus = TemplateBuildStatus.READY + reason: Optional[BuildStatusReason] = None + created_at: datetime = field(default_factory=datetime.now) + + +def _log_entry(message: str) -> LogEntry: + return LogEntry(timestamp=datetime.now(), level="info", message=message) + + +def _split_name(name: str) -> Optional[Tuple[str, Optional[str]]]: + if ":" not in name: + return (name, None) if name else None + alias, _, tag = name.partition(":") + if not alias or not tag: + return None + return (alias, tag) + + +class MockBuildAPI: + def __init__(self): + # Builds keyed by build ID; template IDs keyed by alias. + self.builds: Dict[str, MockBuild] = {} + self.templates: Dict[str, str] = {} + self._seed_template("base") + + def _seed_template(self, alias: str) -> None: + build = MockBuild( + template_id=str(uuid4()), + build_id=str(uuid4()), + alias=alias, + tags=["latest"], + triggered=True, + log_entries=[_log_entry("Build finished")], + ) + self.builds[build.build_id] = build + self.templates[alias] = build.template_id + + def _latest_build_for_alias(self, alias: str) -> Optional[MockBuild]: + latest = None + for build in self.builds.values(): + if build.alias == alias: + latest = build + return latest + + def request_build(self, client, name, tags, cpu_count, memory_mb): + parsed = _split_name(name) + if parsed is None: + raise BuildException(f"Invalid template name: '{name}'") + + alias, tag = parsed + template_id = self.templates.setdefault(alias, str(uuid4())) + + all_tags = ([tag] if tag else []) + (tags or []) + build = MockBuild( + template_id=template_id, + build_id=str(uuid4()), + alias=alias, + tags=all_tags, + triggered=False, + ) + self.builds[build.build_id] = build + + return SimpleNamespace( + template_id=template_id, build_id=build.build_id, tags=all_tags + ) + + def get_file_upload_link(self, client, template_id, files_hash, stack_trace=None): + # Reporting every hash as cached (with no upload URL) skips uploads. + return SimpleNamespace(present=True, url=None) + + def trigger_build(self, client, template_id, build_id, template) -> None: + build = self.builds.get(build_id) + if build is None or build.template_id != template_id: + raise BuildException("Build not found") + + # Simulate the build synchronously: one log entry per step. + from_value = template.get("fromImage") or template.get("fromTemplate") or "base" + build.log_entries.append(_log_entry(f"FROM {from_value}")) + for index, step in enumerate(template.get("steps") or []): + step_type = InstructionType(step.get("type")).value + args = step.get("args") or [] + # RUN steps carry the user in args[1]; only users that exist in + # the base image are accepted, like the real build backend. + user = args[1] if step_type == "RUN" and len(args) > 1 else None + if user is not None and user not in _VALID_USERS: + build.final_status = TemplateBuildStatus.ERROR + build.reason = BuildStatusReason( + message=f"failed to run command '{args[0]}': command failed: " + f"unauthenticated: invalid username: '{user}'", + step=str(index + 1), + ) + break + build.log_entries.append( + _log_entry(f"Step {index + 1}: {step_type} {' '.join(args)}") + ) + if build.final_status != TemplateBuildStatus.ERROR: + build.log_entries.append(_log_entry("Build finished")) + build.triggered = True + + def get_build_status( + self, client, template_id, build_id, logs_offset + ) -> TemplateBuildStatusResponse: + build = self.builds.get(build_id) + if build is None or build.template_id != template_id: + raise BuildException("Build not found") + + log_entries = build.log_entries[logs_offset:] + + # Deliver the pending log entries first (status `building`), then + # report the final status once they are drained. + if not build.triggered: + status = TemplateBuildStatus.WAITING + elif log_entries: + status = TemplateBuildStatus.BUILDING + else: + status = build.final_status + + return TemplateBuildStatusResponse( + build_id=build.build_id, + template_id=build.template_id, + status=status, + log_entries=log_entries, + logs=[entry.message for entry in log_entries], + reason=build.reason if status == TemplateBuildStatus.ERROR else None, + ) + + def check_alias_exists(self, client, alias) -> bool: + return alias in self.templates + + def assign_tags(self, client, target_name, tags) -> TemplateTagInfo: + parsed = _split_name(target_name) + if parsed is None: + raise TemplateException(f"Invalid target: '{target_name}'") + + build = self._latest_build_for_alias(parsed[0]) + if build is None: + raise TemplateException("Template not found") + + # Tags may be bare ('production') or namespaced ('alias:production'); + # the API returns and stores just the tag portion. + assigned = [] + for tag in tags: + parsed_tag = _split_name(tag) + if parsed_tag is None: + raise TemplateException(f"Invalid tag: '{tag}'") + assigned.append(parsed_tag[1] or parsed_tag[0]) + + build.tags.extend(assigned) + + return TemplateTagInfo(build_id=build.build_id, tags=assigned) + + def remove_tags(self, client, name, tags) -> None: + build = self._latest_build_for_alias(name) + if build is None: + raise TemplateException("Template not found") + + build.tags = [tag for tag in build.tags if tag not in tags] + + def get_template_tags(self, client, template_id_or_name) -> List[TemplateTag]: + template_builds = [ + build + for build in self.builds.values() + if build.template_id == template_id_or_name + ] + if not template_builds: + raise TemplateException("Template not found") + + return [ + TemplateTag(tag=tag, build_id=build.build_id, created_at=build.created_at) + for build in template_builds + for tag in build.tags + ] + + _SYNC_FUNCTIONS = ( + "request_build", + "get_file_upload_link", + "trigger_build", + "get_build_status", + "check_alias_exists", + "assign_tags", + "remove_tags", + "get_template_tags", + ) + + def install_sync(self, monkeypatch) -> None: + import e2b.template_sync.build_api as build_api_mod + import e2b.template_sync.main as main_mod + + for name in self._SYNC_FUNCTIONS: + monkeypatch.setattr(main_mod, name, getattr(self, name)) + # wait_for_build_finish polls get_build_status through its own module. + monkeypatch.setattr(build_api_mod, "get_build_status", self.get_build_status) + + def install_async(self, monkeypatch) -> None: + import e2b.template_async.build_api as build_api_mod + import e2b.template_async.main as main_mod + + def as_async(func): + async def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + for name in self._SYNC_FUNCTIONS: + monkeypatch.setattr(main_mod, name, as_async(getattr(self, name))) + # wait_for_build_finish polls get_build_status through its own module. + monkeypatch.setattr( + build_api_mod, "get_build_status", as_async(self.get_build_status) + ) diff --git a/packages/python-sdk/tests/sync/template_sync/conftest.py b/packages/python-sdk/tests/sync/template_sync/conftest.py index 520f64f208..c61e525390 100644 --- a/packages/python-sdk/tests/sync/template_sync/conftest.py +++ b/packages/python-sdk/tests/sync/template_sync/conftest.py @@ -2,9 +2,20 @@ import pytest +from mock_build_api import MockBuildAPI + _DIR = os.path.dirname(os.path.abspath(__file__)) +@pytest.fixture(autouse=True) +def mock_build_api(monkeypatch, test_api_key) -> MockBuildAPI: + """Route all template build API calls through the in-memory mock.""" + monkeypatch.setenv("E2B_API_KEY", test_api_key) + mock = MockBuildAPI() + mock.install_sync(monkeypatch) + return mock + + def pytest_collection_modifyitems(items): for item in items: if str(item.fspath).startswith(_DIR): diff --git a/packages/python-sdk/tests/sync/template_sync/methods/test_from_dockerfile.py b/packages/python-sdk/tests/sync/template_sync/methods/test_from_dockerfile.py index a1db51acfb..61e19ea06e 100644 --- a/packages/python-sdk/tests/sync/template_sync/methods/test_from_dockerfile.py +++ b/packages/python-sdk/tests/sync/template_sync/methods/test_from_dockerfile.py @@ -1,10 +1,7 @@ -import pytest - from e2b import Template from e2b.template.types import InstructionType -@pytest.mark.skip_debug() def test_from_dockerfile(): dockerfile = """FROM node:24 WORKDIR /app @@ -42,7 +39,6 @@ def test_from_dockerfile(): assert template._template._start_cmd == "sleep 20" -@pytest.mark.skip_debug() def test_from_dockerfile_with_default_user_and_workdir(): dockerfile = "FROM node:24" @@ -54,7 +50,6 @@ def test_from_dockerfile_with_default_user_and_workdir(): assert template._template._instructions[-1]["args"][0] == "/home/user" -@pytest.mark.skip_debug() def test_from_dockerfile_with_custom_user_and_workdir(): dockerfile = "FROM node:24\nUSER mish\nWORKDIR /home/mish" @@ -66,7 +61,6 @@ def test_from_dockerfile_with_custom_user_and_workdir(): assert template._template._instructions[-1]["args"][0] == "/home/mish" -@pytest.mark.skip_debug() def test_from_dockerfile_with_multi_source_copy(): dockerfile = """FROM node:24 COPY file1.txt file2.txt file3.txt /dest/""" @@ -86,7 +80,6 @@ def test_from_dockerfile_with_multi_source_copy(): assert copy_instructions[2]["args"][1] == "/dest/" -@pytest.mark.skip_debug() def test_from_dockerfile_with_multi_source_copy_chown(): dockerfile = """FROM node:24 COPY --chown=myuser:mygroup pkg.json pkg-lock.json /app/""" @@ -106,7 +99,6 @@ def test_from_dockerfile_with_multi_source_copy_chown(): assert copy_instructions[1]["args"][2] == "myuser:mygroup" -@pytest.mark.skip_debug() def test_from_dockerfile_with_copy_chown(): dockerfile = """FROM node:24 COPY --chown=myuser:mygroup app.js /app/ diff --git a/packages/python-sdk/tests/sync/template_sync/methods/test_make_symlink.py b/packages/python-sdk/tests/sync/template_sync/methods/test_make_symlink.py index 037109974b..29a90853ef 100644 --- a/packages/python-sdk/tests/sync/template_sync/methods/test_make_symlink.py +++ b/packages/python-sdk/tests/sync/template_sync/methods/test_make_symlink.py @@ -1,9 +1,6 @@ -import pytest - from e2b import Template -@pytest.mark.skip_debug() def test_make_symlink(build): template = ( Template() @@ -16,7 +13,6 @@ def test_make_symlink(build): build(template) -@pytest.mark.skip_debug() def test_make_symlink_force(build): template = ( Template() diff --git a/packages/python-sdk/tests/sync/template_sync/methods/test_run_cmd.py b/packages/python-sdk/tests/sync/template_sync/methods/test_run_cmd.py index 2114026891..39589f8ab6 100644 --- a/packages/python-sdk/tests/sync/template_sync/methods/test_run_cmd.py +++ b/packages/python-sdk/tests/sync/template_sync/methods/test_run_cmd.py @@ -3,14 +3,12 @@ from e2b import Template -@pytest.mark.skip_debug() def test_run_command(build): template = Template().from_image("ubuntu:22.04").skip_cache().run_cmd("ls -l") build(template) -@pytest.mark.skip_debug() def test_run_command_as_different_user(build): template = ( Template() @@ -22,7 +20,6 @@ def test_run_command_as_different_user(build): build(template) -@pytest.mark.skip_debug() def test_run_command_as_user_that_does_not_exist(build): template = ( Template() diff --git a/packages/python-sdk/tests/sync/template_sync/methods/test_to_dockerfile.py b/packages/python-sdk/tests/sync/template_sync/methods/test_to_dockerfile.py index ed1b1499ec..98a17e03f0 100644 --- a/packages/python-sdk/tests/sync/template_sync/methods/test_to_dockerfile.py +++ b/packages/python-sdk/tests/sync/template_sync/methods/test_to_dockerfile.py @@ -1,9 +1,6 @@ -import pytest - from e2b import Template -@pytest.mark.skip_debug() def test_to_dockerfile(): template = ( Template() @@ -21,7 +18,6 @@ def test_to_dockerfile(): assert dockerfile == expected_dockerfile -@pytest.mark.skip_debug() def test_to_dockerfile_with_options(): template = ( Template() @@ -39,7 +35,6 @@ def test_to_dockerfile_with_options(): assert dockerfile == expected_dockerfile -@pytest.mark.skip_debug() def test_to_dockerfile_with_env_instructions(): template = ( Template() 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..e8f6a98790 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,7 +5,6 @@ from e2b import Template, wait_for_timeout -@pytest.mark.skip_debug() @pytest.mark.timeout(10) def test_build_in_background_should_start_build_and_return_info(): """Test that build_in_background returns immediately without waiting for build to complete.""" diff --git a/packages/python-sdk/tests/sync/template_sync/test_build.py b/packages/python-sdk/tests/sync/template_sync/test_build.py index 423e809c4c..6a4b57a4c0 100644 --- a/packages/python-sdk/tests/sync/template_sync/test_build.py +++ b/packages/python-sdk/tests/sync/template_sync/test_build.py @@ -40,7 +40,6 @@ def setup_test_folder(): shutil.rmtree(test_dir, ignore_errors=True) -@pytest.mark.skip_debug() def test_build_template(build, setup_test_folder): template = ( Template(file_context_path=setup_test_folder) @@ -55,13 +54,11 @@ def test_build_template(build, setup_test_folder): build(template, skip_cache=True, on_build_logs=default_build_logger()) -@pytest.mark.skip_debug() def test_build_template_from_base_template(build): template = Template().from_template("base") build(template, skip_cache=True, on_build_logs=default_build_logger()) -@pytest.mark.skip_debug() def test_build_template_with_symlinks(build, setup_test_folder): template = ( Template(file_context_path=setup_test_folder) @@ -74,7 +71,6 @@ def test_build_template_with_symlinks(build, setup_test_folder): build(template) -@pytest.mark.skip_debug() def test_build_template_with_resolve_symlinks(build, setup_test_folder): template = ( Template(file_context_path=setup_test_folder) 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..656656d80a 100644 --- a/packages/python-sdk/tests/sync/template_sync/test_exists.py +++ b/packages/python-sdk/tests/sync/template_sync/test_exists.py @@ -1,18 +1,15 @@ import uuid -import pytest from e2b import Template -@pytest.mark.skip_debug() def test_check_base_template_name_exists(): """Test that the base template name exists.""" exists = Template.exists("base") assert exists is True -@pytest.mark.skip_debug() def test_check_non_existing_name(): """Test that a non-existing name returns False.""" non_existing_name = f"nonexistent-{uuid.uuid4()}" 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..88b32da521 100644 --- a/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py +++ b/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py @@ -107,7 +107,6 @@ def _expect_to_throw_and_check_trace(func, expected_method: str): assert saw_expected_method, traceback.format_exc() -@pytest.mark.skip_debug() def test_traces_on_from_image(build): template = Template() template = template.from_image("e2b.dev/this-image-does-not-exist") @@ -116,7 +115,6 @@ def test_traces_on_from_image(build): ) -@pytest.mark.skip_debug() def test_traces_on_from_template(build): template = Template().from_template("this-template-does-not-exist") _expect_to_throw_and_check_trace( @@ -124,7 +122,6 @@ def test_traces_on_from_template(build): ) -@pytest.mark.skip_debug() def test_traces_on_from_dockerfile(build): template = Template() template = template.from_dockerfile("FROM ubuntu:22.04\nRUN nonexistent") @@ -134,7 +131,6 @@ def test_traces_on_from_dockerfile(build): ) -@pytest.mark.skip_debug() def test_traces_on_from_image_registry(build): template = Template() template = template.from_image( @@ -148,7 +144,6 @@ def test_traces_on_from_image_registry(build): ) -@pytest.mark.skip_debug() def test_traces_on_from_image_credentials(): _expect_to_throw_and_check_trace( lambda: Template().from_image("ubuntu:22.04", username="user"), @@ -156,7 +151,6 @@ def test_traces_on_from_image_credentials(): ) -@pytest.mark.skip_debug() def test_traces_on_from_aws_registry(build): template = Template() template = template.from_aws_registry( @@ -170,7 +164,6 @@ def test_traces_on_from_aws_registry(build): ) -@pytest.mark.skip_debug() def test_traces_on_from_gcp_registry(build): template = Template() template = template.from_gcp_registry( @@ -184,7 +177,6 @@ def test_traces_on_from_gcp_registry(build): ) -@pytest.mark.skip_debug() def test_traces_on_copy(build): template = Template() template = template.from_base_image() @@ -192,7 +184,6 @@ def test_traces_on_copy(build): _expect_to_throw_and_check_trace(lambda: build(template, name="copy"), "copy") -@pytest.mark.skip_debug() def test_traces_on_copyItems(build): template = Template() template = template.from_base_image() @@ -204,7 +195,6 @@ def test_traces_on_copyItems(build): ) -@pytest.mark.skip_debug() def test_traces_on_second_source_of_multi_source_copy(build): template = Template() template = template.from_base_image() @@ -214,7 +204,6 @@ def test_traces_on_second_source_of_multi_source_copy(build): ) -@pytest.mark.skip_debug() def test_traces_on_step_after_multi_source_copy(build): template = Template() template = template.from_base_image() @@ -225,7 +214,6 @@ def test_traces_on_step_after_multi_source_copy(build): ) -@pytest.mark.skip_debug() def test_traces_on_second_item_of_copy_items(build): template = Template() template = template.from_base_image() @@ -240,7 +228,6 @@ def test_traces_on_second_item_of_copy_items(build): ) -@pytest.mark.skip_debug() def test_traces_on_step_after_copy_items(build): template = Template() template = template.from_base_image() @@ -256,7 +243,6 @@ def test_traces_on_step_after_copy_items(build): ) -@pytest.mark.skip_debug() def test_traces_on_copy_absolute_path(): _expect_to_throw_and_check_trace( lambda: Template().from_base_image().copy("/absolute/path", "/absolute/path"), @@ -264,7 +250,6 @@ def test_traces_on_copy_absolute_path(): ) -@pytest.mark.skip_debug() def test_traces_on_copyItems_absolute_path(): _expect_to_throw_and_check_trace( lambda: ( @@ -276,7 +261,6 @@ def test_traces_on_copyItems_absolute_path(): ) -@pytest.mark.skip_debug() def test_traces_on_remove(build): template = Template() template = template.from_base_image() @@ -284,7 +268,6 @@ def test_traces_on_remove(build): _expect_to_throw_and_check_trace(lambda: build(template, name="remove"), "remove") -@pytest.mark.skip_debug() def test_traces_on_rename(build): template = Template() template = template.from_base_image() @@ -292,7 +275,6 @@ def test_traces_on_rename(build): _expect_to_throw_and_check_trace(lambda: build(template, name="rename"), "rename") -@pytest.mark.skip_debug() def test_traces_on_make_dir(build): template = Template() template = template.from_base_image() @@ -302,7 +284,6 @@ def test_traces_on_make_dir(build): ) -@pytest.mark.skip_debug() def test_traces_on_make_symlink(build): template = Template() template = template.from_base_image() @@ -312,7 +293,6 @@ def test_traces_on_make_symlink(build): ) -@pytest.mark.skip_debug() def test_traces_on_run_cmd(build): template = Template() template = template.from_base_image() @@ -320,7 +300,6 @@ def test_traces_on_run_cmd(build): _expect_to_throw_and_check_trace(lambda: build(template, name="run_cmd"), "run_cmd") -@pytest.mark.skip_debug() def test_traces_on_set_workdir(build): template = Template() template = template.from_base_image() @@ -330,7 +309,6 @@ def test_traces_on_set_workdir(build): ) -@pytest.mark.skip_debug() def test_traces_on_set_user(build): template = Template() template = template.from_base_image() @@ -340,7 +318,6 @@ def test_traces_on_set_user(build): ) -@pytest.mark.skip_debug() def test_traces_on_pip_install(build): template = Template() template = template.from_base_image() @@ -350,7 +327,6 @@ def test_traces_on_pip_install(build): ) -@pytest.mark.skip_debug() def test_traces_on_npm_install(build): template = Template() template = template.from_base_image() @@ -360,7 +336,6 @@ def test_traces_on_npm_install(build): ) -@pytest.mark.skip_debug() def test_traces_on_bun_install(build): template = Template() template = template.from_base_image() @@ -370,7 +345,6 @@ def test_traces_on_bun_install(build): ) -@pytest.mark.skip_debug() def test_traces_on_apt_install(build): template = Template() template = template.from_base_image() @@ -380,7 +354,6 @@ def test_traces_on_apt_install(build): ) -@pytest.mark.skip_debug() def test_traces_on_git_clone(build): template = Template() template = template.from_base_image() @@ -390,7 +363,6 @@ def test_traces_on_git_clone(build): ) -@pytest.mark.skip_debug() def test_traces_on_set_start_cmd(build): template = Template() template = template.from_base_image() @@ -402,7 +374,6 @@ def test_traces_on_set_start_cmd(build): ) -@pytest.mark.skip_debug() def test_traces_on_add_mcp_server(): # needs mcp-gateway as base template, without it no mcp servers can be added _expect_to_throw_and_check_trace( @@ -411,7 +382,6 @@ def test_traces_on_add_mcp_server(): ) -@pytest.mark.skip_debug() def test_traces_on_dev_container_prebuild(build): template = Template() template = template.from_template("devcontainer") @@ -422,7 +392,6 @@ def test_traces_on_dev_container_prebuild(build): ) -@pytest.mark.skip_debug() def test_traces_on_set_dev_container_start(build): template = Template() template = template.from_template("devcontainer") diff --git a/packages/python-sdk/tests/sync/template_sync/test_tags.py b/packages/python-sdk/tests/sync/template_sync/test_tags.py index 9caaca3970..3db66ea183 100644 --- a/packages/python-sdk/tests/sync/template_sync/test_tags.py +++ b/packages/python-sdk/tests/sync/template_sync/test_tags.py @@ -164,7 +164,6 @@ def test_get_tags_error(self, monkeypatch): class TestTagsIntegration: """Integration tests for Template tags functionality.""" - @pytest.mark.skip_debug() def test_build_template_with_tags_assign_and_delete(self, build): """Test building a template with tags, assigning new tags, and deleting.""" template_name = "e2b-tags-test" @@ -185,7 +184,6 @@ def test_build_template_with_tags_assign_and_delete(self, build): assert "production" in tag_info.tags assert "latest" in tag_info.tags - @pytest.mark.skip_debug() def test_assign_single_tag_to_existing_template(self, build): """Test assigning a single tag (not array) to an existing template.""" template_name = "e2b-tags-test" @@ -201,7 +199,6 @@ def test_assign_single_tag_to_existing_template(self, build): # API returns just the tag portion, not the full alias:tag assert "stable" in tag_info.tags - @pytest.mark.skip_debug() def test_rejects_invalid_tag_format_missing_alias(self, build): """Test that tag without alias (starts with colon) is rejected.""" template_name = "e2b-tags-test" @@ -214,7 +211,6 @@ def test_rejects_invalid_tag_format_missing_alias(self, build): with pytest.raises(Exception): Template.assign_tags(initial_tag, ":invalid-tag") - @pytest.mark.skip_debug() def test_rejects_invalid_tag_format_missing_tag(self, build): """Test that tag without tag portion (ends with colon) is rejected.""" template_name = "e2b-tags-test" From 3c6233944499b8b2b2d7db2ac28bc85b50a29e07 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:54:52 +0000 Subject: [PATCH 2/2] chore: retrigger CI Co-Authored-By: mish@e2b.dev