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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 23 additions & 18 deletions packages/js-sdk/tests/template/backgroundBuild.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,30 @@
import { randomUUID } from 'node:crypto'
import { expect, test } from 'vitest'
import { expect } from 'vitest'
import { Template, waitForTimeout } from '../../src'
import { e2eTest } from '../setup'

test('build template in background', async () => {
const template = Template()
.fromImage('ubuntu:22.04')
.skipCache()
.runCmd('sleep 5') // Add a delay to ensure build takes time
.setStartCmd('echo "Hello"', waitForTimeout(10_000))
e2eTest(
'build template in background',
async () => {
const template = Template()
.fromImage('ubuntu:22.04')
.skipCache()
.runCmd('sleep 5') // Add a delay to ensure build takes time
.setStartCmd('echo "Hello"', waitForTimeout(10_000))

const name = `e2b-test:v1-${randomUUID()}`
const name = `e2b-test:v1-${randomUUID()}`

const buildInfo = await Template.buildInBackground(template, name, {
cpuCount: 1,
memoryMB: 1024,
})
const buildInfo = await Template.buildInBackground(template, name, {
cpuCount: 1,
memoryMB: 1024,
})

// Should return quickly (within a few seconds), not wait for the full build
expect(buildInfo).toBeDefined()
// Should return quickly (within a few seconds), not wait for the full build
expect(buildInfo).toBeDefined()

// Verify the build is actually running
const status = await Template.getBuildStatus(buildInfo)
expect(status.status).toEqual('building')
}, 10_000)
// Verify the build is actually running
const status = await Template.getBuildStatus(buildInfo)
expect(status.status).toEqual('building')
},
10_000
)
27 changes: 15 additions & 12 deletions packages/js-sdk/tests/template/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import os from 'node:os'
import path from 'node:path'
import { afterAll, beforeAll } from 'vitest'
import { defaultBuildLogger, Template, waitForTimeout } from '../../src'
import { buildTemplateTest } from '../setup'
import { e2eBuildTemplateTest } from '../setup'

// The file context lives in a temp directory so a test run never writes into
// the repository tree. It is created in beforeAll rather than at module load so
Expand Down Expand Up @@ -35,7 +35,7 @@ afterAll(() => {
}
})

buildTemplateTest('build template', async ({ buildTemplate }) => {
e2eBuildTemplateTest('build template', async ({ buildTemplate }) => {
const template = Template({ fileContextPath: contextPath })
// using base image to avoid re-building ubuntu:22.04 image
.fromBaseImage()
Expand All @@ -47,25 +47,28 @@ buildTemplateTest('build template', async ({ buildTemplate }) => {
await buildTemplate(template, { skipCache: true }, defaultBuildLogger())
})

buildTemplateTest(
e2eBuildTemplateTest(
'build template from base template',
async ({ buildTemplate }) => {
const template = Template().fromTemplate('base')
await buildTemplate(template, { skipCache: true })
}
)

buildTemplateTest('build template with symlinks', async ({ buildTemplate }) => {
const template = Template({ fileContextPath: contextPath })
.fromImage('ubuntu:22.04')
.skipCache()
.copy('folder/*', 'folder', { forceUpload: true })
.runCmd('cat folder/symlink.txt')
e2eBuildTemplateTest(
'build template with symlinks',
async ({ buildTemplate }) => {
const template = Template({ fileContextPath: contextPath })
.fromImage('ubuntu:22.04')
.skipCache()
.copy('folder/*', 'folder', { forceUpload: true })
.runCmd('cat folder/symlink.txt')

await buildTemplate(template)
})
await buildTemplate(template)
}
)

buildTemplateTest(
e2eBuildTemplateTest(
'build template with resolveSymlinks',
async ({ buildTemplate }) => {
const template = Template({ fileContextPath: contextPath })
Expand Down
7 changes: 4 additions & 3 deletions packages/js-sdk/tests/template/exists.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { randomUUID } from 'node:crypto'
import { expect, test } from 'vitest'
import { expect } from 'vitest'
import { Template } from '../../src'
import { e2eTest } from '../setup'

test('check if base template name exists', async () => {
e2eTest('check if base template name exists', async () => {
const exists = await Template.exists('base')
expect(exists).toBe(true)
})

test('check non existing name', async () => {
e2eTest('check non existing name', async () => {
const nonExistingName = `nonexistent-${randomUUID()}`
const exists = await Template.exists(nonExistingName)
expect(exists).toBe(false)

Check warning on line 14 in packages/js-sdk/tests/template/exists.test.ts

View check run for this annotation

Claude / Claude Code Review

Missing E2B_DEBUG skip on control-plane/build e2e tests

Use `hostedTest` instead of `e2eTest` for tests that hit control-plane/build-only APIs, so `E2B_DEBUG` still skips them -- sweep:`e2eTest\(` (seen in backgroundBuild.test.ts and exists.test.ts). `hostedTest = e2eTest.skipIf(isDebug)` is the variant meant for exactly this case; a local envd cannot service `Template.buildInBackground` or `Template.exists`, so under `E2B_E2E=1` + `E2B_DEBUG` these tests would hit the wrong server and fail/hang instead of skipping, unlike their Python mirrors which
Comment on lines 1 to 14

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Use hostedTest instead of e2eTest for tests that hit control-plane/build-only APIs, so E2B_DEBUG still skips them -- sweep:e2eTest\( (seen in backgroundBuild.test.ts and exists.test.ts). hostedTest = e2eTest.skipIf(isDebug) is the variant meant for exactly this case; a local envd cannot service Template.buildInBackground or Template.exists, so under E2B_E2E=1 + E2B_DEBUG these tests would hit the wrong server and fail/hang instead of skipping, unlike their Python mirrors which carry @pytest.mark.skip_debug().

Extended reasoning...

tests/setup.ts defines three tiers relevant here: e2eTest = base.skipIf(!isE2E) (no debug guard), hostedTest = e2eTest.skipIf(isDebug), and e2eBuildTemplateTest = buildTemplateTest.skipIf(!isE2E || isDebug). The hostedTest/e2eBuildTemplateTest variants exist specifically for operations that a local envd (used under E2B_DEBUG) cannot stand in for: control-plane routes, real builds, snapshots, the traffic proxy. tests/README.md documents this convention explicitly, and sandbox/create.test.ts / connect.test.ts already follow it.

This PR wraps backgroundBuild.test.ts (Template.buildInBackground, a real server-side build) and exists.test.ts (Template.exists -> aliasExists -> checkAliasExists, a control-plane GET /templates/aliases/{alias} route) in bare e2eTest rather than hostedTest. Neither operation has a local-envd fallback: under E2B_DEBUG, ConnectionConfig.apiUrl resolves to http://localhost:3000, which does not serve the builds API or the templates-alias control-plane route.

Concrete walkthrough for exists.test.ts: run with E2B_E2E=1 E2B_DEBUG=1. isE2E is true so e2eTest does not skip. Template.exists(\"base\") calls checkAliasExists, which issues client.api.GET(\"/templates/aliases/{alias}\") against http://localhost:3000 (the local envd) instead of the real control plane. That route does not exist on envd, so the request fails or 404s, and expect(exists).toBe(true) fails instead of the test being skipped -- exactly the outcome hostedTest exists to prevent. The same reasoning applies to backgroundBuild.test.ts, where Template.buildInBackground would attempt a real server-side build against an envd that has no build endpoint.

This is also a JS/Python parity gap (a rule from CLAUDE.md): the Python mirrors test_exists.py and test_background_build.py (both sync and async) correctly carry both @pytest.mark.e2e and the pre-existing @pytest.mark.skip_debug(), so only the JS side regresses under the combined E2B_E2E + E2B_DEBUG configuration.

Fix is mechanical: replace e2eTest with hostedTest (or equivalently e2eTest.skipIf(isDebug)) as the import and wrapper in both files. Severity is nit -- this only misfires in the niche opt-in combination of E2B_E2E=1 and E2B_DEBUG together; the default unit tier and the plain e2e-without-debug tier are unaffected, and it is test-only code with no production impact.

})
6 changes: 3 additions & 3 deletions packages/js-sdk/tests/template/methods/makeSymlink.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Template } from '../../../src'
import { buildTemplateTest } from '../../setup'
import { e2eBuildTemplateTest } from '../../setup'

buildTemplateTest('make symlink', async ({ buildTemplate }) => {
e2eBuildTemplateTest('make symlink', async ({ buildTemplate }) => {
const template = Template()
.fromImage('ubuntu:22.04')
.skipCache()
Expand All @@ -11,7 +11,7 @@ buildTemplateTest('make symlink', async ({ buildTemplate }) => {
await buildTemplate(template)
})

buildTemplateTest('make symlink (force)', async ({ buildTemplate }) => {
e2eBuildTemplateTest('make symlink (force)', async ({ buildTemplate }) => {
const template = Template()
.fromImage('ubuntu:22.04')
.makeSymlink('.bashrc', '.bashrc.local')
Expand Down
8 changes: 4 additions & 4 deletions packages/js-sdk/tests/template/methods/runCmd.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { expect } from 'vitest'
import { Template } from '../../../src'
import { buildTemplateTest } from '../../setup'
import { e2eBuildTemplateTest } from '../../setup'

buildTemplateTest('run command', async ({ buildTemplate }) => {
e2eBuildTemplateTest('run command', async ({ buildTemplate }) => {
const template = Template()
.fromImage('ubuntu:22.04')
.skipCache()
Expand All @@ -11,7 +11,7 @@ buildTemplateTest('run command', async ({ buildTemplate }) => {
await buildTemplate(template)
})

buildTemplateTest(
e2eBuildTemplateTest(
'run command as a different user',
async ({ buildTemplate }) => {
const template = Template()
Expand All @@ -23,7 +23,7 @@ buildTemplateTest(
}
)

buildTemplateTest(
e2eBuildTemplateTest(
'run command as user that does not exist',
async ({ buildTemplate }) => {
const template = Template()
Expand Down
112 changes: 112 additions & 0 deletions packages/js-sdk/tests/template/serialization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, assert, beforeAll, expect, test } from 'vitest'

import { Template } from '../../src'
import { InstructionType } from '../../src/template/types'
import { calculateFilesHash } from '../../src/template/utils'
Comment on lines +7 to +8

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-54 (one flat entry point per package — everything public is re-exported from index.ts, no subpath exports): InstructionType is the SDK's own vocabulary for step types (T-15) and the only way to assert on a serialized payload, yet it is not in the export type { ... } from './types' list in src/template/index.ts, so this suite has to reach into src/template/types. Add InstructionType to the package's public exports and import both it and Template from '../../src'. (calculateFilesHash is genuinely internal — the deep import for it is fine.)


let contextPath: string

beforeAll(async () => {
contextPath = await mkdtemp(join(tmpdir(), 'template-serialization-'))
await writeFile(join(contextPath, 'app.txt'), 'hello')
await writeFile(join(contextPath, 'other.txt'), 'hello')
})

afterAll(async () => {
await rm(contextPath, { recursive: true, force: true })
})

const filesHash = (src: string, dest: string) =>
calculateFilesHash(src, dest, contextPath, [], false, undefined)

test('hash is stable and content-dependent', async () => {
const before = await filesHash('app.txt', '/app/')
assert.equal(await filesHash('app.txt', '/app/'), before)

await writeFile(join(contextPath, 'app.txt'), 'hello again')
const after = await filesHash('app.txt', '/app/')

assert.notEqual(after, before)
assert.match(after, /^[0-9a-f]{64}$/)
})

test('hash covers the source and destination paths', async () => {
// Identical content, different instruction — the hash seeds on `COPY src dest`.
assert.notEqual(
await filesHash('app.txt', '/app/'),
await filesHash('other.txt', '/app/')
)
assert.notEqual(
await filesHash('app.txt', '/app/'),
await filesHash('app.txt', '/srv/')
)
})

test('hashing a source that matches no file fails', async () => {
// TODO: should reject with TemplateError once calculateFilesHash stops
// throwing a bare Error.
Comment on lines +49 to +50

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 and configuration failures raise BuildError / BuildException, never a bare Error): the TODO correctly flags the bare Error, but names the wrong replacement. calculateFilesHash failing because the copy source matches no file is a builder-time precondition failure on the local file context, so the target is BuildErrorTemplateError extends SandboxError and is for sandbox-side template/envd failures. Pinning the wrong class in the comment is how the eventual fix lands on the wrong error.

Suggested change
// TODO: should reject with TemplateError once calculateFilesHash stops
// throwing a bare Error.
// TODO: should reject with BuildError once calculateFilesHash stops
// throwing a bare Error.

await expect(filesHash('nope.txt', '/app/')).rejects.toThrow()
})

test('serializes a build payload from the builder', async () => {
const template = Template({ fileContextPath: contextPath })
.fromImage('ubuntu:22.04')
.runCmd('echo hello')
.setWorkdir('/app')
.setStartCmd('python main.py', 'curl -f http://localhost:8000')

const payload = JSON.parse(await Template.toJSON(template, false))

assert.equal(payload.fromImage, 'ubuntu:22.04')
assert.equal(payload.startCmd, 'python main.py')
assert.equal(payload.readyCmd, 'curl -f http://localhost:8000')
assert.isUndefined(payload.fromTemplate)
assert.deepEqual(
payload.steps.map((step: { type: string }) => step.type),
[InstructionType.RUN, InstructionType.WORKDIR]
)
})

test('serializes fromTemplate instead of fromImage', async () => {
const payload = JSON.parse(
await Template.toJSON(Template().fromTemplate('base'))
)

assert.equal(payload.fromTemplate, 'base')
assert.isUndefined(payload.fromImage)
})

test('serializes a registry config next to the image', async () => {
const template = Template().fromImage('registry.example.com/app:latest', {
username: 'user',
password: 'pass',
})

const payload = JSON.parse(await Template.toJSON(template))

assert.equal(payload.fromImage, 'registry.example.com/app:latest')
assert.equal(payload.fromImageRegistry.type, 'registry')
assert.equal(payload.fromImageRegistry.username, 'user')
})

test('computeHashes adds the copy hash to the payload', async () => {
const template = Template({ fileContextPath: contextPath })
.fromImage('ubuntu:22.04')
.copy('app.txt', '/app/')

const withoutHashes = JSON.parse(await Template.toJSON(template, false))
const withHashes = JSON.parse(await Template.toJSON(template, true))

const copyStep = (payload: {
steps: { type: string; filesHash?: string }[]
}) => payload.steps.find((step) => step.type === InstructionType.COPY)

assert.isUndefined(copyStep(withoutHashes)?.filesHash)
assert.equal(
copyStep(withHashes)?.filesHash,
await filesHash('app.txt', '/app/')
)
})
82 changes: 1 addition & 81 deletions packages/js-sdk/tests/template/tags.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { randomUUID } from 'node:crypto'
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'

import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'

import { Template } from '../../src'
import { apiUrl, buildTemplateTest, isDebug } from '../setup'
import { apiUrl } from '../setup'

// Mock handlers for tag API endpoints
const mockHandlers = [
Expand Down Expand Up @@ -121,82 +120,3 @@ describe('Template tags unit tests', () => {
})
})
})

// Integration tests
buildTemplateTest.skipIf(isDebug)(
'build template with tags, assign and delete',
{ timeout: 300_000 },
async ({ buildTemplate }) => {
const templateName = 'e2b-tags-test'
const initialTag = `${templateName}:v1-${randomUUID()}`

// Build a template with initial tag
const template = Template().fromBaseImage()
const buildInfo = await buildTemplate(template, { name: initialTag })

expect(buildInfo.buildId).toBeTruthy()
expect(buildInfo.templateId).toBeTruthy()

// Assign additional tags (just tag names, not full alias:tag format)
const tagInfo = await Template.assignTags(initialTag, [
'production',
'latest',
])

expect(tagInfo.buildId).toBeTruthy()
expect(tagInfo.tags).toContain('production')
expect(tagInfo.tags).toContain('latest')
}
)

buildTemplateTest.skipIf(isDebug)(
'assign single tag to existing template',
{ timeout: 300_000 },
async ({ buildTemplate }) => {
const templateName = 'e2b-tags-test'
const initialTag = `${templateName}:v1-${randomUUID()}`

const template = Template().fromBaseImage()
await buildTemplate(template, { name: initialTag })

// Assign single tag (just tag name, not full alias:tag format)
const tagInfo = await Template.assignTags(initialTag, 'stable')

expect(tagInfo.buildId).toBeTruthy()
expect(tagInfo.tags).toContain('stable')
}
)

buildTemplateTest.skipIf(isDebug)(
'rejects invalid tag format - missing alias',
{ timeout: 300_000 },
async ({ buildTemplate }) => {
const templateName = 'e2b-tags-test'
const initialTag = `${templateName}:v1-${randomUUID()}`

const template = Template().fromBaseImage()
await buildTemplate(template, { name: initialTag })

// Tag without alias (starts with colon) should be rejected
await expect(
Template.assignTags(initialTag, ':invalid-tag')
).rejects.toThrow()
}
)

buildTemplateTest.skipIf(isDebug)(
'rejects invalid tag format - missing tag',
{ timeout: 300_000 },
async ({ buildTemplate }) => {
const templateName = 'e2b-tags-test'
const initialTag = `${templateName}:v1-${randomUUID()}`

const template = Template().fromBaseImage()
await buildTemplate(template, { name: initialTag })

// Tag without tag portion (ends with colon) should be rejected
await expect(
Template.assignTags(initialTag, `${templateName}:`)
).rejects.toThrow()
}
)
Loading
Loading