chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:47:58 +08:00
commit b16403ea71
789 changed files with 115226 additions and 0 deletions
@@ -0,0 +1,133 @@
import { assert, test, describe } from 'vitest'
import { handleApiError } from '../../src/api'
import {
AuthenticationError,
RateLimitError,
SandboxError,
} from '../../src/errors'
function createMockResponse(
status: number,
error: unknown,
data?: unknown
): {
response: { status: number; ok: boolean }
error: unknown
data: unknown
} {
return {
response: { status, ok: status >= 200 && status < 300 },
error,
data,
}
}
describe('handleApiError', () => {
describe('without content', () => {
// openapi-fetch leaves `error` undefined for non-2xx responses with
// Content-Length: 0
test('catches 404 with undefined error', () => {
const res = createMockResponse(404, undefined)
const err = handleApiError(res as any)
assert.instanceOf(err, SandboxError)
assert.include(err?.message, '404')
})
test('catches 500 with undefined error', () => {
const res = createMockResponse(500, undefined)
const err = handleApiError(res as any)
assert.instanceOf(err, SandboxError)
assert.include(err?.message, '500')
})
test('returns AuthenticationError for 401 with undefined error', () => {
const res = createMockResponse(401, undefined)
const err = handleApiError(res as any)
assert.instanceOf(err, AuthenticationError)
assert.include(err?.message, 'Unauthorized')
})
})
describe('with empty error body', () => {
test('catches 404 with empty string error', () => {
const res = createMockResponse(404, '')
const err = handleApiError(res as any)
assert.instanceOf(err, SandboxError)
assert.include(err?.message, '404')
})
test('catches 400 with empty string error', () => {
const res = createMockResponse(400, '')
const err = handleApiError(res as any)
assert.instanceOf(err, SandboxError)
assert.include(err?.message, '400')
})
test('catches 500 with empty string error', () => {
const res = createMockResponse(500, '')
const err = handleApiError(res as any)
assert.instanceOf(err, SandboxError)
assert.include(err?.message, '500')
})
})
describe('with JSON error body', () => {
test('catches 404 with message', () => {
const res = createMockResponse(404, { code: 404, message: 'Not found' })
const err = handleApiError(res as any)
assert.instanceOf(err, SandboxError)
assert.include(err?.message, 'Not found')
})
test('catches 400 with message', () => {
const res = createMockResponse(400, { code: 400, message: 'Bad request' })
const err = handleApiError(res as any)
assert.instanceOf(err, SandboxError)
assert.include(err?.message, 'Bad request')
})
})
describe('special status codes', () => {
test('returns AuthenticationError for 401', () => {
const res = createMockResponse(401, { message: 'Invalid token' })
const err = handleApiError(res as any)
assert.instanceOf(err, AuthenticationError)
assert.include(err?.message, 'Unauthorized')
})
test('returns AuthenticationError for 401 with empty body', () => {
const res = createMockResponse(401, '')
const err = handleApiError(res as any)
assert.instanceOf(err, AuthenticationError)
assert.include(err?.message, 'Unauthorized')
})
test('returns RateLimitError for 429', () => {
const res = createMockResponse(429, { message: 'Too many requests' })
const err = handleApiError(res as any)
assert.instanceOf(err, RateLimitError)
assert.include(err?.message, 'Rate limit')
})
test('returns RateLimitError for 429 with empty body', () => {
const res = createMockResponse(429, '')
const err = handleApiError(res as any)
assert.instanceOf(err, RateLimitError)
assert.include(err?.message, 'Rate limit')
})
})
describe('success responses', () => {
test('returns undefined for 200 success', () => {
const res = createMockResponse(200, undefined, { id: '123' })
const err = handleApiError(res as any)
assert.isUndefined(err)
})
test('returns undefined for 201 success', () => {
const res = createMockResponse(201, undefined, { id: '123' })
const err = handleApiError(res as any)
assert.isUndefined(err)
})
})
})
+137
View File
@@ -0,0 +1,137 @@
import { afterEach, expect, test, vi } from 'vitest'
afterEach(() => {
vi.restoreAllMocks()
vi.resetModules()
vi.doUnmock('undici')
vi.doUnmock('../../src/utils')
delete process.env.E2B_API_CONNECTIONS
delete process.env.E2B_API_INFLIGHT_REQUESTS
})
test('uses undici with a bounded HTTP/2 dispatcher for API requests', async () => {
const agents: Array<{ allowH2?: boolean; connections?: number }> = []
const requests: Array<{ init?: RequestInit & { dispatcher?: unknown } }> = []
class Agent {
constructor(options: { allowH2?: boolean; connections?: number }) {
agents.push(options)
}
}
const undiciFetch = vi.fn((input, init) => {
requests.push({ init })
return Promise.resolve(new Response('ok'))
})
const loadUndici = vi.fn(() => Promise.resolve({ Agent, fetch: undiciFetch }))
const { createApiFetchForRuntime } = await import('../../src/api/http2')
const fetcher = createApiFetchForRuntime('node', {
connectionLimit: 100,
loadUndici,
})
await fetcher('https://example.com/sandboxes')
expect(loadUndici).toHaveBeenCalledOnce()
expect(agents).toEqual([{ allowH2: true, connections: 100 }])
expect(requests[0].init?.dispatcher).toBeInstanceOf(Agent)
})
test('uses a ProxyAgent dispatcher when a proxy is configured', async () => {
const proxyAgents: Array<{
uri?: string
allowH2?: boolean
connections?: number
}> = []
const agents: Array<unknown> = []
const requests: Array<{ init?: RequestInit & { dispatcher?: unknown } }> = []
class Agent {
constructor() {
agents.push(this)
}
}
class ProxyAgent {
constructor(options: {
uri?: string
allowH2?: boolean
connections?: number
}) {
proxyAgents.push(options)
}
}
const undiciFetch = vi.fn((input, init) => {
requests.push({ init })
return Promise.resolve(new Response('ok'))
})
const loadUndici = vi.fn(() =>
Promise.resolve({ Agent, ProxyAgent, fetch: undiciFetch })
)
const { createApiFetchForRuntime } = await import('../../src/api/http2')
const fetcher = createApiFetchForRuntime('node', {
connectionLimit: 100,
proxy: 'http://user:pass@127.0.0.1:8080',
loadUndici,
})
await fetcher('https://example.com/sandboxes')
expect(agents).toHaveLength(0)
expect(proxyAgents).toEqual([
{
uri: 'http://user:pass@127.0.0.1:8080',
allowH2: true,
connections: 100,
},
])
expect(requests[0].init?.dispatcher).toBeInstanceOf(ProxyAgent)
})
test('caches API fetchers per proxy', async () => {
const { createApiFetch } = await import('../../src/api/http2')
const noProxy = createApiFetch()
const proxyA = createApiFetch('http://127.0.0.1:8080')
const proxyB = createApiFetch('http://127.0.0.1:9090')
expect(createApiFetch()).toBe(noProxy)
expect(createApiFetch('http://127.0.0.1:8080')).toBe(proxyA)
expect(proxyA).not.toBe(noProxy)
expect(proxyA).not.toBe(proxyB)
})
test('getApiConnectionLimit throws on a malformed env value', async () => {
process.env.E2B_API_CONNECTIONS = 'not-a-number'
const { getApiConnectionLimit } = await import('../../src/api/http2')
expect(() => getApiConnectionLimit()).toThrow(/E2B_API_CONNECTIONS/)
})
test('getApiInflightLimit throws on a malformed env value', async () => {
process.env.E2B_API_INFLIGHT_REQUESTS = 'not-a-number'
const { getApiInflightLimit } = await import('../../src/api/http2')
expect(() => getApiInflightLimit()).toThrow(/E2B_API_INFLIGHT_REQUESTS/)
})
test('getApiInflightLimit returns 0 when explicitly disabled', async () => {
process.env.E2B_API_INFLIGHT_REQUESTS = '0'
const { getApiInflightLimit } = await import('../../src/api/http2')
expect(getApiInflightLimit()).toBe(0)
})
test('getApiInflightLimit throws on negative env value', async () => {
process.env.E2B_API_INFLIGHT_REQUESTS = '-5'
const { getApiInflightLimit } = await import('../../src/api/http2')
expect(() => getApiInflightLimit()).toThrow(/E2B_API_INFLIGHT_REQUESTS=-5/)
})
@@ -0,0 +1,75 @@
import { expect, test, vi } from 'vitest'
import { limitConcurrency } from '../../src/api/inflight'
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
test('limitConcurrency queues requests over the cap and releases on response', async () => {
const gate = deferred<Response>()
let secondStarted = false
const inner = vi.fn(async (input: RequestInfo | URL) => {
if (String(input).endsWith('/first')) return gate.promise
secondStarted = true
return new Response('second')
}) as unknown as typeof fetch
const limited = limitConcurrency(inner, 1)
const first = limited('https://example.com/first')
const second = limited('https://example.com/second')
await Promise.resolve()
await Promise.resolve()
expect(secondStarted).toBe(false)
gate.resolve(new Response('first'))
expect(await (await first).text()).toBe('first')
expect(await (await second).text()).toBe('second')
expect(secondStarted).toBe(true)
})
test('limitConcurrency releases when the underlying fetch rejects', async () => {
let calls = 0
const inner = vi.fn(async () => {
calls++
if (calls === 1) throw new Error('boom')
return new Response('ok')
}) as unknown as typeof fetch
const limited = limitConcurrency(inner, 1)
await expect(limited('https://example.com/a')).rejects.toThrow('boom')
// Slot should be free for the next request.
const res = await limited('https://example.com/b')
expect(await res.text()).toBe('ok')
})
test('limitConcurrency aborts queued requests when their signal fires', async () => {
const gate = deferred<Response>()
const inner = vi.fn(async () => gate.promise) as unknown as typeof fetch
const limited = limitConcurrency(inner, 1)
// Occupy the only slot.
const first = limited('https://example.com/first')
const controller = new AbortController()
const queued = limited('https://example.com/queued', {
signal: controller.signal,
})
// Abort the queued request before the slot frees.
controller.abort()
await expect(queued).rejects.toMatchObject({ name: 'AbortError' })
// Release the first request to make sure cleanup did not break the slot.
gate.resolve(new Response('done'))
const resp = await first
expect(await resp.text()).toBe('done')
})
+10
View File
@@ -0,0 +1,10 @@
import { expect } from 'vitest'
import { sandboxTest, isDebug } from '../setup.js'
import { Sandbox } from '../../src'
sandboxTest.skipIf(isDebug)('get sandbox info', async ({ sandbox }) => {
const info = await Sandbox.getInfo(sandbox.sandboxId)
expect(info).toBeDefined()
expect(info.sandboxId).toBe(sandbox.sandboxId)
})
+21
View File
@@ -0,0 +1,21 @@
import { expect } from 'vitest'
import { sandboxTest, isDebug } from '../setup.js'
import { Sandbox } from '../../src'
sandboxTest.skipIf(isDebug)(
'kill existing sandbox',
async ({ sandbox, sandboxTestId }) => {
await Sandbox.kill(sandbox.sandboxId)
const paginator = Sandbox.list({
query: { state: ['running'], metadata: { sandboxTestId } },
})
const sandboxes = await paginator.nextItems()
expect(sandboxes.map((s) => s.sandboxId)).not.toContain(sandbox.sandboxId)
}
)
sandboxTest.skipIf(isDebug)('kill non-existing sandbox', async () => {
await expect(Sandbox.kill('nonexistingsandbox')).resolves.toBe(false)
})
+440
View File
@@ -0,0 +1,440 @@
import { assert } from 'vitest'
import { randomUUID } from 'crypto'
import { Sandbox, SandboxInfo } from '../../src'
import { sandboxTest, isDebug } from '../setup.js'
sandboxTest.skipIf(isDebug)(
'list sandboxes',
async ({ sandbox, sandboxTestId }) => {
const paginator = Sandbox.list({
query: { metadata: { sandboxTestId } },
})
const sandboxes = await paginator.nextItems()
assert.isAtLeast(sandboxes.length, 1)
const found = sandboxes.some((s) => s.sandboxId === sandbox.sandboxId)
assert.isTrue(found)
}
)
sandboxTest.skipIf(isDebug)('list sandboxes with filter', async () => {
const uniqueId = randomUUID()
const extraSbx = await Sandbox.create({ metadata: { uniqueId } })
try {
const paginator = Sandbox.list({
query: { metadata: { uniqueId } },
})
const sandboxes = await paginator.nextItems()
assert.equal(sandboxes.length, 1)
assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId)
} finally {
await extraSbx.kill()
}
})
sandboxTest.skipIf(isDebug)(
'list running sandboxes',
async ({ sandboxTestId }) => {
const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } })
try {
const paginator = Sandbox.list({
query: { metadata: { sandboxTestId }, state: ['running'] },
})
const sandboxes = await paginator.nextItems()
assert.isAtLeast(sandboxes.length, 1)
// Verify our running sandbox is in the list
const found = sandboxes.some(
(s) => s.sandboxId === extraSbx.sandboxId && s.state === 'running'
)
assert.isTrue(found)
} finally {
await extraSbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'list paused sandboxes',
async ({ sandboxTestId }) => {
// Create and pause a sandbox
const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } })
await extraSbx.betaPause()
try {
const paginator = Sandbox.list({
query: { metadata: { sandboxTestId }, state: ['paused'] },
})
const sandboxes = await paginator.nextItems()
assert.isAtLeast(sandboxes.length, 1)
// Verify our paused sandbox is in the list
const found = sandboxes.some(
(s) => s.sandboxId === extraSbx.sandboxId && s.state === 'paused'
)
assert.isTrue(found)
} finally {
await extraSbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'paginate running sandboxes',
async ({ sandbox, sandboxTestId }) => {
// Create extra sandboxes
const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } })
try {
// Test pagination with limit
const paginator = Sandbox.list({
limit: 1,
query: { metadata: { sandboxTestId }, state: ['running'] },
})
const sandboxes = await paginator.nextItems()
// Check first page
assert.equal(sandboxes.length, 1)
assert.equal(sandboxes[0].state, 'running')
assert.isTrue(paginator.hasNext)
assert.notEqual(paginator.nextToken, undefined)
assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId)
// Get second page
const sandboxes2 = await paginator.nextItems()
// Check second page
assert.equal(sandboxes2.length, 1)
assert.equal(sandboxes2[0].state, 'running')
assert.isFalse(paginator.hasNext)
assert.equal(paginator.nextToken, undefined)
assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId)
} finally {
await extraSbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'paginate paused sandboxes',
async ({ sandbox, sandboxTestId }) => {
await sandbox.betaPause()
// Create extra paused sandbox
const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } })
await extraSbx.betaPause()
try {
// Test pagination with limit
const paginator = Sandbox.list({
limit: 1,
query: { metadata: { sandboxTestId }, state: ['paused'] },
})
const sandboxes = await paginator.nextItems()
// Check first page
assert.equal(sandboxes.length, 1)
assert.equal(sandboxes[0].state, 'paused')
assert.isTrue(paginator.hasNext)
assert.notEqual(paginator.nextToken, undefined)
assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId)
// Get second page
const sandboxes2 = await paginator.nextItems()
// Check second page
assert.equal(sandboxes2.length, 1)
assert.equal(sandboxes2[0].state, 'paused')
assert.isFalse(paginator.hasNext)
assert.equal(paginator.nextToken, undefined)
assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId)
} finally {
await extraSbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'paginate running and paused sandboxes',
async ({ sandbox, sandboxTestId }) => {
// Create extra sandbox
const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } })
// Pause the extra sandbox
await extraSbx.betaPause()
try {
// Test pagination with limit
const paginator = Sandbox.list({
limit: 1,
query: {
metadata: { sandboxTestId },
state: ['running', 'paused'],
},
})
const sandboxes = await paginator.nextItems()
// Check first page
assert.equal(sandboxes.length, 1)
assert.equal(sandboxes[0].state, 'paused')
assert.isTrue(paginator.hasNext)
assert.notEqual(paginator.nextToken, undefined)
assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId)
// Get second page
const sandboxes2 = await paginator.nextItems()
// Check second page
assert.equal(sandboxes2.length, 1)
assert.equal(sandboxes2[0].state, 'running')
assert.isFalse(paginator.hasNext)
assert.equal(paginator.nextToken, undefined)
assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId)
} finally {
await extraSbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'paginate iterator',
async ({ sandbox, sandboxTestId }) => {
const paginator = Sandbox.list({
query: { metadata: { sandboxTestId } },
})
const sandboxes: SandboxInfo[] = []
while (paginator.hasNext) {
const sbxs = await paginator.nextItems()
sandboxes.push(...sbxs)
}
assert.isAtLeast(sandboxes.length, 1)
assert.isTrue(sandboxes.some((s) => s.sandboxId === sandbox.sandboxId))
}
)
sandboxTest.skipIf(isDebug)(
'list sandboxes',
async ({ sandbox, sandboxTestId }) => {
const paginator = Sandbox.list({
query: { metadata: { sandboxTestId } },
})
const sandboxes = await paginator.nextItems()
assert.isAtLeast(sandboxes.length, 1)
const found = sandboxes.some((s) => s.sandboxId === sandbox.sandboxId)
assert.isTrue(found)
}
)
sandboxTest.skipIf(isDebug)('list sandboxes with filter', async () => {
const uniqueId = randomUUID()
const extraSbx = await Sandbox.create({ metadata: { uniqueId } })
try {
const paginator = Sandbox.list({
query: { metadata: { uniqueId } },
})
const sandboxes = await paginator.nextItems()
assert.equal(sandboxes.length, 1)
assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId)
} finally {
await extraSbx.kill()
}
})
sandboxTest.skipIf(isDebug)(
'list running sandboxes',
async ({ sandboxTestId }) => {
const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } })
try {
const paginator = Sandbox.list({
query: { metadata: { sandboxTestId }, state: ['running'] },
})
const sandboxes = await paginator.nextItems()
assert.isAtLeast(sandboxes.length, 1)
// Verify our running sandbox is in the list
const found = sandboxes.some(
(s) => s.sandboxId === extraSbx.sandboxId && s.state === 'running'
)
assert.isTrue(found)
} finally {
await extraSbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'list paused sandboxes',
async ({ sandboxTestId }) => {
// Create and pause a sandbox
const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } })
await Sandbox.betaPause(extraSbx.sandboxId)
try {
const paginator = Sandbox.list({
query: { metadata: { sandboxTestId }, state: ['paused'] },
})
const sandboxes = await paginator.nextItems()
assert.isAtLeast(sandboxes.length, 1)
// Verify our paused sandbox is in the list
const found = sandboxes.some(
(s) => s.sandboxId === extraSbx.sandboxId && s.state === 'paused'
)
assert.isTrue(found)
} finally {
await extraSbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'paginate running sandboxes',
async ({ sandbox, sandboxTestId }) => {
// Create extra sandboxes
const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } })
try {
// Test pagination with limit
const paginator = Sandbox.list({
limit: 1,
query: { metadata: { sandboxTestId }, state: ['running'] },
})
const sandboxes = await paginator.nextItems()
// Check first page
assert.equal(sandboxes.length, 1)
assert.equal(sandboxes[0].state, 'running')
assert.isTrue(paginator.hasNext)
assert.notEqual(paginator.nextToken, undefined)
assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId)
// Get second page
const sandboxes2 = await paginator.nextItems()
// Check second page
assert.equal(sandboxes2.length, 1)
assert.equal(sandboxes2[0].state, 'running')
assert.isFalse(paginator.hasNext)
assert.equal(paginator.nextToken, undefined)
assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId)
} finally {
await extraSbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'paginate paused sandboxes',
async ({ sandbox, sandboxTestId }) => {
await Sandbox.betaPause(sandbox.sandboxId)
// Create extra paused sandbox
const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } })
await Sandbox.betaPause(extraSbx.sandboxId)
try {
// Test pagination with limit
const paginator = Sandbox.list({
limit: 1,
query: { metadata: { sandboxTestId }, state: ['paused'] },
})
const sandboxes = await paginator.nextItems()
// Check first page
assert.equal(sandboxes.length, 1)
assert.equal(sandboxes[0].state, 'paused')
assert.isTrue(paginator.hasNext)
assert.notEqual(paginator.nextToken, undefined)
assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId)
// Get second page
const sandboxes2 = await paginator.nextItems()
// Check second page
assert.equal(sandboxes2.length, 1)
assert.equal(sandboxes2[0].state, 'paused')
assert.isFalse(paginator.hasNext)
assert.equal(paginator.nextToken, undefined)
assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId)
} finally {
await extraSbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'paginate running and paused sandboxes',
async ({ sandbox, sandboxTestId }) => {
// Create extra sandbox
const extraSbx = await Sandbox.create({ metadata: { sandboxTestId } })
// Pause the extra sandbox
await Sandbox.betaPause(sandbox.sandboxId)
try {
// Test pagination with limit
const paginator = Sandbox.list({
limit: 1,
query: {
metadata: { sandboxTestId },
state: ['running', 'paused'],
},
})
const sandboxes = await paginator.nextItems()
// Check first page
assert.equal(sandboxes.length, 1)
assert.equal(sandboxes[0].state, 'running')
assert.isTrue(paginator.hasNext)
assert.notEqual(paginator.nextToken, undefined)
assert.equal(sandboxes[0].sandboxId, extraSbx.sandboxId)
// Get second page
const sandboxes2 = await paginator.nextItems()
// Check second page
assert.equal(sandboxes2.length, 1)
assert.equal(sandboxes2[0].state, 'paused')
assert.isFalse(paginator.hasNext)
assert.equal(paginator.nextToken, undefined)
assert.equal(sandboxes2[0].sandboxId, sandbox.sandboxId)
} finally {
await extraSbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'paginate iterator',
async ({ sandbox, sandboxTestId }) => {
const paginator = Sandbox.list({
query: { metadata: { sandboxTestId } },
})
const sandboxes: SandboxInfo[] = []
while (paginator.hasNext) {
const sbxs = await paginator.nextItems()
sandboxes.push(...sbxs)
}
assert.isAtLeast(sandboxes.length, 1)
assert.isTrue(sandboxes.some((s) => s.sandboxId === sandbox.sandboxId))
}
)
@@ -0,0 +1,26 @@
import { assert } from 'vitest'
import { sandboxTest, isDebug } from '../setup.js'
import { Sandbox } from '../../src'
sandboxTest.skipIf(isDebug)('pause sandbox', async ({ sandbox }) => {
await Sandbox.pause(sandbox.sandboxId)
assert.isFalse(
await sandbox.isRunning(),
'Sandbox should not be running after pause'
)
})
sandboxTest.skipIf(isDebug)('resume sandbox', async ({ sandbox }) => {
await Sandbox.pause(sandbox.sandboxId)
assert.isFalse(
await sandbox.isRunning(),
'Sandbox should not be running after pause'
)
await Sandbox.connect(sandbox.sandboxId)
assert.isTrue(
await sandbox.isRunning(),
'Sandbox should be running after resume'
)
})
@@ -0,0 +1,91 @@
import { assert, test, describe, vi, afterEach } from 'vitest'
import { ApiClient, validateApiKey } from '../../src/api'
import { ConnectionConfig } from '../../src/connectionConfig'
import { AuthenticationError } from '../../src/errors'
describe('validateApiKey', () => {
const validKey = 'e2b_' + '0123456789abcdef'.repeat(2) + '01234567'
test('accepts a well-formed key', () => {
assert.doesNotThrow(() => validateApiKey(validKey))
})
test('rejects a key without the e2b_ prefix', () => {
assert.throws(
() => validateApiKey('sk_' + '0'.repeat(40)),
AuthenticationError,
/Invalid API key format/
)
})
test('accepts a key with a non-default body length', () => {
assert.doesNotThrow(() => validateApiKey('e2b_' + '0'.repeat(20)))
})
test('rejects an empty body after the prefix', () => {
assert.throws(
() => validateApiKey('e2b_'),
AuthenticationError,
/Invalid API key format/
)
})
test('rejects a key with non-hex characters in the body', () => {
assert.throws(
() => validateApiKey('e2b_' + 'z'.repeat(40)),
AuthenticationError,
/Invalid API key format/
)
})
test('error message includes an example token', () => {
try {
validateApiKey('nope')
assert.fail('expected validateApiKey to throw')
} catch (err) {
assert.instanceOf(err, AuthenticationError)
assert.match(
(err as Error).message,
/e2b_0{40}/,
'expected example token in error message'
)
}
})
})
describe('ApiClient API key validation', () => {
test('throws on a malformed key by default', () => {
const config = new ConnectionConfig({ apiKey: 'not-a-valid-key' })
assert.throws(() => new ApiClient(config), AuthenticationError)
})
test('skips validation when validateApiKey is false', () => {
const config = new ConnectionConfig({
apiKey: 'not-a-valid-key',
validateApiKey: false,
})
assert.doesNotThrow(() => new ApiClient(config))
})
})
describe('ApiClient API key requirement', () => {
afterEach(() => {
vi.unstubAllEnvs()
})
test('throws when no API key is supplied', () => {
vi.stubEnv('E2B_API_KEY', '')
const config = new ConnectionConfig({})
assert.throws(
() => new ApiClient(config),
AuthenticationError,
/API key is required/
)
})
test('does not require an API key when requireApiKey is false', () => {
vi.stubEnv('E2B_API_KEY', '')
const config = new ConnectionConfig({})
assert.doesNotThrow(() => new ApiClient(config, { requireApiKey: false }))
})
})
+20
View File
@@ -0,0 +1,20 @@
import { CommandHandle, CommandExitError } from '../src/index.js'
import { assert } from 'vitest'
export function catchCmdExitErrorInBackground(cmd: CommandHandle) {
let disabled = false
cmd.wait().catch((res: CommandExitError) => {
if (!disabled) {
assert.equal(
res.exitCode,
0,
`command failed with exit code ${res.exitCode}: ${res.stderr}`
)
}
})
return () => {
disabled = true
}
}
@@ -0,0 +1,24 @@
import { afterEach, assert, test, vi } from 'vitest'
afterEach(() => {
vi.resetModules()
vi.doUnmock('../src/utils')
})
test('sandbox_url keeps per-sandbox host in browser runtime', async () => {
vi.doMock('../src/utils', () => ({
runtime: 'browser',
runtimeVersion: 'test',
}))
const { ConnectionConfig } = await import('../src/connectionConfig')
const config = new ConnectionConfig()
assert.equal(
config.getSandboxUrl('sbx-test', {
sandboxDomain: 'e2b.app',
envdPort: 49983,
}),
'https://49983-sbx-test.e2b.app'
)
})
@@ -0,0 +1,568 @@
import { assert, test, beforeEach, afterEach } from 'vitest'
import {
ConnectionConfig,
setupRequestController,
wrapStreamWithConnectionCleanup,
} from '../src/connectionConfig'
// Store original env vars to restore after tests
let originalEnv: { [key: string]: string | undefined }
beforeEach(() => {
originalEnv = {
E2B_API_URL: process.env.E2B_API_URL,
E2B_DOMAIN: process.env.E2B_DOMAIN,
E2B_SANDBOX_URL: process.env.E2B_SANDBOX_URL,
E2B_DEBUG: process.env.E2B_DEBUG,
E2B_VALIDATE_API_KEY: process.env.E2B_VALIDATE_API_KEY,
}
})
afterEach(() => {
// Restore original env vars
Object.keys(originalEnv).forEach((key) => {
if (originalEnv[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = originalEnv[key]
}
})
// Clear the process-wide integration attribution
ConnectionConfig.setIntegration(undefined)
})
test('api_url defaults correctly', () => {
// Ensure no env vars interfere
delete process.env.E2B_API_URL
delete process.env.E2B_DOMAIN
delete process.env.E2B_DEBUG
const config = new ConnectionConfig()
assert.equal(config.apiUrl, 'https://api.e2b.app')
})
test('api_url in args', () => {
const config = new ConnectionConfig({ apiUrl: 'http://localhost:8080' })
assert.equal(config.apiUrl, 'http://localhost:8080')
})
test('api_url in env var', () => {
process.env.E2B_API_URL = 'http://localhost:8080'
const config = new ConnectionConfig()
assert.equal(config.apiUrl, 'http://localhost:8080')
})
test('api_url has correct priority', () => {
process.env.E2B_API_URL = 'http://localhost:1111'
const config = new ConnectionConfig({ apiUrl: 'http://localhost:8080' })
assert.equal(config.apiUrl, 'http://localhost:8080')
})
test('sandbox_url defaults to stable sandbox host in production', () => {
delete process.env.E2B_SANDBOX_URL
delete process.env.E2B_DOMAIN
delete process.env.E2B_DEBUG
const config = new ConnectionConfig()
assert.equal(
config.getSandboxUrl('sbx-test', {
sandboxDomain: 'e2b.app',
envdPort: 49983,
}),
'https://sandbox.e2b.app'
)
})
test('sandbox_direct_url keeps per-sandbox host in production', () => {
delete process.env.E2B_SANDBOX_URL
delete process.env.E2B_DOMAIN
delete process.env.E2B_DEBUG
const config = new ConnectionConfig()
assert.equal(
config.getSandboxDirectUrl('sbx-test', {
sandboxDomain: 'e2b.app',
envdPort: 49983,
}),
'https://49983-sbx-test.e2b.app'
)
})
test('sandbox_url keeps per-sandbox host outside production', () => {
delete process.env.E2B_SANDBOX_URL
delete process.env.E2B_DEBUG
const config = new ConnectionConfig({ domain: 'e2b.dev' })
assert.equal(
config.getSandboxUrl('sbx-test', {
sandboxDomain: 'sandbox.e2b.dev',
envdPort: 49983,
}),
'https://49983-sbx-test.sandbox.e2b.dev'
)
})
test('sandbox_url in args has priority', () => {
process.env.E2B_SANDBOX_URL = 'https://sandbox.from-env'
const config = new ConnectionConfig({ sandboxUrl: 'https://sandbox.custom' })
assert.equal(
config.getSandboxUrl('sbx-test', {
sandboxDomain: 'e2b.app',
envdPort: 49983,
}),
'https://sandbox.custom'
)
})
test('sandbox_url in env var overrides default', () => {
process.env.E2B_SANDBOX_URL = 'https://sandbox.from-env'
const config = new ConnectionConfig()
assert.equal(
config.getSandboxUrl('sbx-test', {
sandboxDomain: 'e2b.app',
envdPort: 49983,
}),
'https://sandbox.from-env'
)
})
test('sandbox_url stays localhost in debug mode', () => {
delete process.env.E2B_SANDBOX_URL
process.env.E2B_DEBUG = 'true'
const config = new ConnectionConfig()
assert.equal(
config.getSandboxUrl('sbx-test', {
sandboxDomain: 'e2b.app',
envdPort: 49983,
}),
'http://localhost:49983'
)
})
test('validateApiKey defaults to true', () => {
delete process.env.E2B_VALIDATE_API_KEY
const config = new ConnectionConfig()
assert.equal(config.validateApiKey, true)
})
test('validateApiKey disabled via env var', () => {
process.env.E2B_VALIDATE_API_KEY = 'false'
const config = new ConnectionConfig()
assert.equal(config.validateApiKey, false)
})
test('validateApiKey in args has priority over env var', () => {
process.env.E2B_VALIDATE_API_KEY = 'true'
const config = new ConnectionConfig({ validateApiKey: false })
assert.equal(config.validateApiKey, false)
})
test('debug false in args overrides E2B_DEBUG env var', () => {
process.env.E2B_DEBUG = 'true'
const config = new ConnectionConfig({ debug: false })
assert.equal(config.debug, false)
})
test('debug defaults to E2B_DEBUG env var', () => {
process.env.E2B_DEBUG = 'true'
const config = new ConnectionConfig()
assert.equal(config.debug, true)
})
test('setIntegration appends the integration to the user agent', () => {
ConnectionConfig.setIntegration('testing/version')
const config = new ConnectionConfig()
assert.equal(config.headers?.['User-Agent']?.startsWith('e2b-js-sdk/'), true)
assert.equal(
config.headers?.['User-Agent']?.endsWith(' testing/version'),
true
)
})
test('integration survives config rebuilds', () => {
ConnectionConfig.setIntegration('testing/version')
const config = new ConnectionConfig()
const rebuiltConfig = new ConnectionConfig({ ...config })
assert.equal(
rebuiltConfig.headers?.['User-Agent']?.endsWith(' testing/version'),
true
)
})
test('setIntegration does not retro-tag configs built earlier', () => {
const before = new ConnectionConfig()
ConnectionConfig.setIntegration('testing/version')
const after = new ConnectionConfig()
assert.equal(before.headers?.['User-Agent']?.includes('testing'), false)
assert.equal(
after.headers?.['User-Agent']?.endsWith(' testing/version'),
true
)
})
test('clearing the integration restores the plain user agent', () => {
ConnectionConfig.setIntegration('testing/version')
ConnectionConfig.setIntegration(undefined)
const config = new ConnectionConfig()
assert.equal(config.headers?.['User-Agent']?.startsWith('e2b-js-sdk/'), true)
assert.equal(config.headers?.['User-Agent']?.includes('testing'), false)
})
test('custom user agent is preserved without integration', () => {
const config = new ConnectionConfig({
apiHeaders: { 'User-Agent': 'my-app/1.0' },
})
assert.equal(config.headers?.['User-Agent'], 'my-app/1.0')
})
test('custom user agent wins over integration', () => {
ConnectionConfig.setIntegration('testing/version')
const config = new ConnectionConfig({
headers: { 'User-Agent': 'my-app/1.0' },
})
assert.equal(config.headers?.['User-Agent'], 'my-app/1.0')
})
test('custom user agent survives config rebuilds', () => {
ConnectionConfig.setIntegration('testing/version')
const config = new ConnectionConfig({
apiHeaders: { 'User-Agent': 'my-app/1.0' },
})
const rebuiltConfig = new ConnectionConfig({ ...config })
assert.equal(rebuiltConfig.headers?.['User-Agent'], 'my-app/1.0')
})
test('clearing the integration propagates to config rebuilds', () => {
ConnectionConfig.setIntegration('testing/version')
const config = new ConnectionConfig()
ConnectionConfig.setIntegration(undefined)
const rebuiltConfig = new ConnectionConfig({ ...config })
assert.equal(
rebuiltConfig.headers?.['User-Agent']?.startsWith('e2b-js-sdk/'),
true
)
assert.equal(
rebuiltConfig.headers?.['User-Agent']?.includes('testing'),
false
)
})
test('getSignal returns user signal when no timeout is set', () => {
const config = new ConnectionConfig({ requestTimeoutMs: 0 })
const controller = new AbortController()
const signal = config.getSignal(0, controller.signal)
assert.strictEqual(signal, controller.signal)
})
test('getSignal aborts when user signal is aborted', () => {
const config = new ConnectionConfig({ requestTimeoutMs: 60_000 })
const controller = new AbortController()
const signal = config.getSignal(undefined, controller.signal)
assert.ok(signal)
assert.equal(signal!.aborted, false)
controller.abort()
assert.equal(signal!.aborted, true)
})
test('getSignal returns timeout signal when no user signal is provided', () => {
const config = new ConnectionConfig({ requestTimeoutMs: 60_000 })
const signal = config.getSignal()
assert.ok(signal)
assert.equal(signal!.aborted, false)
})
test('getSignal returns undefined when no timeout and no signal', () => {
const config = new ConnectionConfig({ requestTimeoutMs: 0 })
const signal = config.getSignal(0)
assert.equal(signal, undefined)
})
test('requestTimeoutMs 0 from the config disables the timeout', () => {
const config = new ConnectionConfig({ requestTimeoutMs: 0 })
// The stored value is kept as 0 (not replaced by the default).
assert.equal(config.requestTimeoutMs, 0)
// getSignal() with no per-call arg falls back to the stored 0, which must
// NOT produce a timeout signal.
assert.equal(config.getSignal(), undefined)
// With only a user signal, no timeout signal is layered on top.
const controller = new AbortController()
assert.strictEqual(
config.getSignal(undefined, controller.signal),
controller.signal
)
})
test('setupRequestController with config timeout 0 never auto-aborts', async () => {
const config = new ConnectionConfig({ requestTimeoutMs: 0 })
const { controller } = setupRequestController(
config.requestTimeoutMs,
undefined
)
await new Promise((resolve) => setTimeout(resolve, 40))
assert.equal(controller.signal.aborted, false)
})
test('setupRequestController aborts when user signal aborts', () => {
const userController = new AbortController()
const { controller } = setupRequestController(0, userController.signal)
assert.equal(controller.signal.aborted, false)
userController.abort()
assert.equal(controller.signal.aborted, true)
})
test('setupRequestController is already aborted when user signal was pre-aborted', () => {
const userController = new AbortController()
userController.abort()
const { controller } = setupRequestController(0, userController.signal)
assert.equal(controller.signal.aborted, true)
})
test('setupRequestController cleanup removes the user-signal listener', () => {
const userController = new AbortController()
const { controller, cleanup } = setupRequestController(
0,
userController.signal
)
cleanup()
// Internal controller is aborted by cleanup, so it stays aborted.
assert.equal(controller.signal.aborted, true)
// After cleanup the listener is detached — a subsequent abort on the
// user signal must not propagate (verified indirectly: cleanup is
// idempotent and no error is thrown).
userController.abort()
cleanup()
})
test('setupRequestController clearStartTimeout stops the handshake timer', async () => {
const { controller, clearStartTimeout } = setupRequestController(
20,
undefined
)
clearStartTimeout()
await new Promise((resolve) => setTimeout(resolve, 40))
assert.equal(controller.signal.aborted, false)
})
test('setupRequestController handshake timer aborts when not cleared', async () => {
const { controller } = setupRequestController(20, undefined)
await new Promise((resolve) => setTimeout(resolve, 40))
assert.equal(controller.signal.aborted, true)
})
test('setupRequestController handshake timeout aborts with TimeoutError reason', async () => {
const { controller } = setupRequestController(20, undefined)
await new Promise((resolve) => setTimeout(resolve, 40))
assert.equal(controller.signal.aborted, true)
assert.ok(controller.signal.reason instanceof DOMException)
assert.equal((controller.signal.reason as DOMException).name, 'TimeoutError')
})
test('setupRequestController user signal still cancels after clearStartTimeout', () => {
const userController = new AbortController()
const { controller, clearStartTimeout } = setupRequestController(
60_000,
userController.signal
)
clearStartTimeout()
assert.equal(controller.signal.aborted, false)
userController.abort()
assert.equal(controller.signal.aborted, true)
})
// Builds a source ReadableStream that records whether its underlying reader was
// cancelled, standing in for a fetch response body backed by a pooled
// connection. `cancel` being invoked is what releases that connection.
function trackedSource() {
const state: { cancelled: boolean; cancelReason: unknown } = {
cancelled: false,
cancelReason: undefined,
}
const chunks = ['a', 'b'].map((s) => new TextEncoder().encode(s))
let i = 0
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (i < chunks.length) {
controller.enqueue(chunks[i++])
} else {
controller.close()
}
},
cancel(reason) {
state.cancelled = true
state.cancelReason = reason
},
})
return { body, state }
}
async function readAll(stream: ReadableStream<Uint8Array>): Promise<string> {
const reader = stream.getReader()
let out = ''
const decoder = new TextDecoder()
for (;;) {
const { done, value } = await reader.read()
if (done) break
out += decoder.decode(value, { stream: true })
}
return out
}
// Builds a source ReadableStream that never produces a chunk and errors its
// pending read when `signal` aborts, standing in for a stalled fetch response
// body whose connection is torn down by aborting the request controller.
function stallingSource(signal: AbortSignal) {
const state: { cancelled: boolean } = { cancelled: false }
const body = new ReadableStream<Uint8Array>({
start(controller) {
signal.addEventListener('abort', () => controller.error(signal.reason), {
once: true,
})
},
cancel() {
state.cancelled = true
},
})
return { body, state }
}
test('wrapStreamWithConnectionCleanup releases once on full read', async () => {
const { body } = trackedSource()
let cleanups = 0
const stream = wrapStreamWithConnectionCleanup(body, {
clearStartTimeout: () => {},
cleanup: () => {
cleanups++
},
controller: new AbortController(),
})
assert.equal(await readAll(stream), 'ab')
assert.equal(cleanups, 1)
})
test('wrapStreamWithConnectionCleanup cancel cancels the underlying reader', async () => {
const { body, state } = trackedSource()
let cleanups = 0
const stream = wrapStreamWithConnectionCleanup(body, {
clearStartTimeout: () => {},
cleanup: () => {
cleanups++
},
controller: new AbortController(),
})
await stream.cancel('done')
assert.equal(state.cancelled, true)
assert.equal(state.cancelReason, 'done')
assert.equal(cleanups, 1)
})
test('wrapStreamWithConnectionCleanup handles a null body', async () => {
let cleanups = 0
let cleared = 0
const stream = wrapStreamWithConnectionCleanup(null, {
clearStartTimeout: () => {
cleared++
},
cleanup: () => {
cleanups++
},
controller: new AbortController(),
})
assert.equal(cleared, 1)
assert.equal(cleanups, 1)
assert.equal(await readAll(stream), '')
})
test('wrapStreamWithConnectionCleanup aborts and releases an idle stream', async () => {
const controller = new AbortController()
const { body } = stallingSource(controller.signal)
let cleanups = 0
const stream = wrapStreamWithConnectionCleanup(body, {
clearStartTimeout: () => {},
cleanup: () => {
cleanups++
},
controller,
idleTimeoutMs: 20,
})
// No chunk ever arrives, so the idle timer fires, aborts the controller,
// and the read rejects with the TimeoutError reason.
let error: unknown
try {
await readAll(stream)
} catch (err) {
error = err
}
assert.equal((error as DOMException)?.name, 'TimeoutError')
assert.equal(cleanups, 1)
assert.equal(controller.signal.aborted, true)
})
test('wrapStreamWithConnectionCleanup with idle timeout 0 never auto-aborts', async () => {
const { body } = trackedSource()
let cleanups = 0
const stream = wrapStreamWithConnectionCleanup(body, {
clearStartTimeout: () => {},
cleanup: () => {
cleanups++
},
controller: new AbortController(),
idleTimeoutMs: 0,
})
assert.equal(await readAll(stream), 'ab')
assert.equal(cleanups, 1)
})
test('wrapStreamWithConnectionCleanup does not abort a slow consumer (wire-only)', async () => {
// Source has both chunks ready immediately; the consumer pauses far longer
// than the idle timeout between reads. Because the timer is armed only around
// the network read and cleared as soon as a chunk arrives, the consumer's
// pace must not trip it.
const controller = new AbortController()
const { body } = trackedSource()
const stream = wrapStreamWithConnectionCleanup(body, {
clearStartTimeout: () => {},
cleanup: () => {},
controller,
idleTimeoutMs: 20,
})
const reader = stream.getReader()
const decoder = new TextDecoder()
const first = await reader.read()
assert.equal(decoder.decode(first.value), 'a')
await new Promise((resolve) => setTimeout(resolve, 50))
const second = await reader.read()
assert.equal(decoder.decode(second.value), 'b')
assert.equal(controller.signal.aborted, false)
})
@@ -0,0 +1,152 @@
import { assert, test, describe } from 'vitest'
import { handleEnvdApiError, handleEnvdApiFetchError } from '../../src/envd/api'
import {
AuthenticationError,
InvalidArgumentError,
NotEnoughSpaceError,
NotFoundError,
RateLimitError,
SandboxError,
TimeoutError,
} from '../../src/errors'
function createMockResponse(
status: number,
error?: { message?: string } | string
): {
error?: { message?: string } | string
response: Response
} {
return {
error,
response: {
status,
ok: status >= 200 && status < 300,
statusText: '',
// openapi-fetch consumes the body whenever it produces an error value
bodyUsed: error !== undefined,
text: async () => (typeof error === 'string' ? error : ''),
} as unknown as Response,
}
}
describe('handleEnvdApiError', () => {
test('returns undefined for a successful response', async () => {
const err = await handleEnvdApiError(createMockResponse(200))
assert.isUndefined(err)
})
test('returns an error for non-2xx response without content', async () => {
// openapi-fetch leaves `error` undefined for responses with
// Content-Length: 0
const res = createMockResponse(500)
const err = await handleEnvdApiError(res)
assert.instanceOf(err, SandboxError)
assert.include(err?.message, '500')
})
test('returns an error for non-2xx response with empty string error', async () => {
const res = createMockResponse(500, '')
const err = await handleEnvdApiError(res)
assert.instanceOf(err, SandboxError)
assert.include(err?.message, '500')
})
test('returns a mapped error for non-2xx response without content', async () => {
const res = createMockResponse(404)
const err = await handleEnvdApiError(res)
assert.instanceOf(err, NotFoundError)
})
test('returns InvalidArgumentError for 400', async () => {
const res = createMockResponse(400, { message: 'Bad request' })
const err = await handleEnvdApiError(res)
assert.instanceOf(err, InvalidArgumentError)
})
test('returns AuthenticationError for 401', async () => {
const res = createMockResponse(401, { message: 'Invalid token' })
const err = await handleEnvdApiError(res)
assert.instanceOf(err, AuthenticationError)
})
test('returns NotFoundError for 404', async () => {
const res = createMockResponse(404, { message: 'Not found' })
const err = await handleEnvdApiError(res)
assert.instanceOf(err, NotFoundError)
})
test('returns RateLimitError for 429', async () => {
const res = createMockResponse(429, { message: 'Too many requests' })
const err = await handleEnvdApiError(res)
assert.instanceOf(err, RateLimitError)
assert.include(err?.message, 'rate limited')
})
test('returns TimeoutError for 502', async () => {
const res = createMockResponse(502, { message: 'Bad gateway' })
const err = await handleEnvdApiError(res)
assert.instanceOf(err, TimeoutError)
})
test('returns NotEnoughSpaceError for 507', async () => {
const res = createMockResponse(507, { message: 'No space left' })
const err = await handleEnvdApiError(res)
assert.instanceOf(err, NotEnoughSpaceError)
})
test('falls back to SandboxError for unmapped status', async () => {
const res = createMockResponse(500, { message: 'Internal error' })
const err = await handleEnvdApiError(res)
assert.instanceOf(err, SandboxError)
assert.include(err?.message, '500')
})
})
describe('handleEnvdApiFetchError', () => {
test('returns the original error for terminated fetch without a health check', async () => {
const original = new TypeError('terminated')
const err = await handleEnvdApiFetchError(original)
assert.strictEqual(err, original)
})
test('returns a TimeoutError when the health check says the sandbox is not running', async () => {
const err = await handleEnvdApiFetchError(
new TypeError('terminated'),
async () => false
)
assert.instanceOf(err, TimeoutError)
assert.include(err.message, 'sandbox was killed or reached its end of life')
})
// Each JS runtime surfaces a dropped connection with different wording, and not
// always as a TypeError (Bun raises a plain Error), so match by message
const runtimeTerminatedErrors = {
Node: new TypeError('terminated'),
Bun: new Error('The socket connection was closed unexpectedly'),
Deno: new TypeError('error reading a body from connection'),
}
for (const [runtime, error] of Object.entries(runtimeTerminatedErrors)) {
test(`treats the ${runtime} dropped-connection error as terminated`, async () => {
const err = await handleEnvdApiFetchError(error, async () => false)
assert.instanceOf(err, TimeoutError)
assert.include(
err.message,
'sandbox was killed or reached its end of life'
)
})
}
test('returns the original error when the health check says the sandbox is running', async () => {
const original = new TypeError('terminated')
const err = await handleEnvdApiFetchError(original, async () => true)
assert.strictEqual(err, original)
})
test('returns the original error for other fetch failures', async () => {
const original = new TypeError('fetch failed')
const err = await handleEnvdApiFetchError(original)
assert.strictEqual(err, original)
})
})
@@ -0,0 +1,138 @@
import { assert, test, describe } from 'vitest'
import { Code, ConnectError } from '@connectrpc/connect'
import {
handleRpcError,
handleRpcErrorWithHealthCheck,
} from '../../src/envd/rpc'
import {
AuthenticationError,
InvalidArgumentError,
NotFoundError,
RateLimitError,
SandboxError,
TimeoutError,
} from '../../src/errors'
describe('handleRpcError', () => {
test('returns InvalidArgumentError for InvalidArgument', () => {
const err = handleRpcError(new ConnectError('bad', Code.InvalidArgument))
assert.instanceOf(err, InvalidArgumentError)
})
test('returns AuthenticationError for Unauthenticated', () => {
const err = handleRpcError(new ConnectError('nope', Code.Unauthenticated))
assert.instanceOf(err, AuthenticationError)
})
test('returns NotFoundError for NotFound', () => {
const err = handleRpcError(new ConnectError('missing', Code.NotFound))
assert.instanceOf(err, NotFoundError)
})
test('returns RateLimitError for ResourceExhausted', () => {
const err = handleRpcError(
new ConnectError('too many', Code.ResourceExhausted)
)
assert.instanceOf(err, RateLimitError)
assert.include(err.message, 'Rate limit')
})
test('returns TimeoutError for Unavailable', () => {
const err = handleRpcError(new ConnectError('gone', Code.Unavailable))
assert.instanceOf(err, TimeoutError)
})
test('falls back to SandboxError for unmapped code', () => {
const err = handleRpcError(new ConnectError('boom', Code.Internal))
assert.instanceOf(err, SandboxError)
})
test('falls back to generic SandboxError for Unknown "terminated"', () => {
const err = handleRpcError(new ConnectError('terminated', Code.Unknown))
assert.instanceOf(err, SandboxError)
assert.include(err.message, 'terminated')
assert.notInclude(err.message, 'killed')
})
test('returns the original error when not a ConnectError', () => {
const original = new Error('not connect')
const err = handleRpcError(original)
assert.strictEqual(err, original)
})
})
describe('handleRpcErrorWithHealthCheck', () => {
const terminated = () => new ConnectError('terminated', Code.Unknown)
// Each JS runtime surfaces a dropped connection with different wording
const runtimeTerminatedMessages = {
Node: 'terminated',
Bun: 'The socket connection was closed unexpectedly',
Deno: 'error reading a body from connection',
}
test('returns a TimeoutError when the health check says the sandbox is not running', async () => {
const err = await handleRpcErrorWithHealthCheck(
terminated(),
async () => false
)
assert.instanceOf(err, TimeoutError)
assert.include(err.message, 'sandbox was killed or reached its end of life')
})
for (const [runtime, message] of Object.entries(runtimeTerminatedMessages)) {
test(`treats the ${runtime} dropped-connection message as terminated`, async () => {
const err = await handleRpcErrorWithHealthCheck(
new ConnectError(message, Code.Unknown),
async () => false
)
assert.instanceOf(err, TimeoutError)
assert.include(
err.message,
'sandbox was killed or reached its end of life'
)
})
}
test('falls back to the generic mapping when the health check says the sandbox is running', async () => {
const err = await handleRpcErrorWithHealthCheck(
terminated(),
async () => true
)
assert.instanceOf(err, SandboxError)
assert.notInstanceOf(err, TimeoutError)
assert.notInclude(err.message, 'killed')
})
test('falls back to the generic mapping when the sandbox state is unknown', async () => {
const err = await handleRpcErrorWithHealthCheck(
terminated(),
async () => undefined
)
assert.instanceOf(err, SandboxError)
assert.notInstanceOf(err, TimeoutError)
assert.notInclude(err.message, 'killed')
})
test('falls back to the generic mapping when the health check itself fails', async () => {
const err = await handleRpcErrorWithHealthCheck(terminated(), async () => {
throw new Error('health check failed')
})
assert.instanceOf(err, SandboxError)
assert.notInstanceOf(err, TimeoutError)
assert.notInclude(err.message, 'killed')
})
test('does not run the health check for other errors', async () => {
let called = false
const err = await handleRpcErrorWithHealthCheck(
new ConnectError('missing', Code.NotFound),
async () => {
called = true
return false
}
)
assert.instanceOf(err, NotFoundError)
assert.isFalse(called)
})
})
+276
View File
@@ -0,0 +1,276 @@
import { afterEach, expect, test, vi } from 'vitest'
afterEach(() => {
vi.restoreAllMocks()
vi.resetModules()
vi.doUnmock('undici')
vi.doUnmock('../../src/utils')
delete process.env.E2B_ENVD_RPC_CONNECTIONS
delete process.env.E2B_ENVD_INFLIGHT_REQUESTS
delete process.env.E2B_ENVD_RPC_INFLIGHT_REQUESTS
})
test('uses undici with HTTP/2 enabled in Node', async () => {
const agents: Array<{ allowH2?: boolean; connections?: number }> = []
const requests: Array<{ init?: RequestInit & { dispatcher?: unknown } }> = []
class Agent {
constructor(options: { allowH2?: boolean; connections?: number }) {
agents.push(options)
}
}
const undiciFetch = vi.fn((input, init) => {
requests.push({ init })
return Promise.resolve(new Response('ok'))
})
const { createEnvdFetchForRuntime } = await import('../../src/envd/http2')
const fetcher = createEnvdFetchForRuntime('node', {
connectionLimit: 1,
loadUndici: () => Promise.resolve({ Agent, fetch: undiciFetch }),
})
const res = await fetcher('https://example.com/status')
expect(await res.text()).toBe('ok')
expect(agents).toEqual([{ allowH2: true, connections: 1 }])
expect(requests[0].init?.dispatcher).toBeInstanceOf(Agent)
})
test('uses a ProxyAgent dispatcher when a proxy is configured', async () => {
const proxyAgents: Array<{
uri?: string
allowH2?: boolean
connections?: number
}> = []
const agents: Array<unknown> = []
const requests: Array<{ init?: RequestInit & { dispatcher?: unknown } }> = []
class Agent {
constructor() {
agents.push(this)
}
}
class ProxyAgent {
constructor(options: {
uri?: string
allowH2?: boolean
connections?: number
}) {
proxyAgents.push(options)
}
}
const undiciFetch = vi.fn((input, init) => {
requests.push({ init })
return Promise.resolve(new Response('ok'))
})
const { createEnvdFetchForRuntime } = await import('../../src/envd/http2')
const fetcher = createEnvdFetchForRuntime('node', {
connectionLimit: 1,
proxy: 'http://127.0.0.1:8080',
loadUndici: () =>
Promise.resolve({ Agent, ProxyAgent, fetch: undiciFetch }),
})
await fetcher('https://example.com/status')
expect(agents).toHaveLength(0)
expect(proxyAgents).toEqual([
{ uri: 'http://127.0.0.1:8080', allowH2: true, connections: 1 },
])
expect(requests[0].init?.dispatcher).toBeInstanceOf(ProxyAgent)
})
test('caches envd fetchers per proxy', async () => {
const { createEnvdFetch, createEnvdRpcFetch } = await import(
'../../src/envd/http2'
)
const noProxy = createEnvdFetch()
const proxyA = createEnvdFetch('http://127.0.0.1:8080')
expect(createEnvdFetch()).toBe(noProxy)
expect(createEnvdFetch('http://127.0.0.1:8080')).toBe(proxyA)
expect(proxyA).not.toBe(noProxy)
const rpcNoProxy = createEnvdRpcFetch()
const rpcProxyA = createEnvdRpcFetch('http://127.0.0.1:8080')
expect(createEnvdRpcFetch()).toBe(rpcNoProxy)
expect(createEnvdRpcFetch('http://127.0.0.1:8080')).toBe(rpcProxyA)
expect(rpcProxyA).not.toBe(rpcNoProxy)
})
test('passes Request objects to undici as URL plus init', async () => {
const requests: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []
class Agent {}
const undiciFetch = vi.fn((input, init) => {
requests.push({ input, init })
return Promise.resolve(new Response('ok'))
})
const { createEnvdFetchForRuntime } = await import('../../src/envd/http2')
const fetcher = createEnvdFetchForRuntime('node', {
connectionLimit: 1,
loadUndici: () => Promise.resolve({ Agent, fetch: undiciFetch }),
})
const body = JSON.stringify({ ok: true })
await fetcher(
new Request('https://example.com/rpc', {
body,
headers: { 'content-type': 'application/json' },
method: 'POST',
})
)
expect(requests[0].input).toBe('https://example.com/rpc')
expect(requests[0].init?.method).toBe('POST')
expect(requests[0].init?.headers).toBeInstanceOf(Headers)
expect(requests[0].init?.body).toBeInstanceOf(ReadableStream)
})
test('can create a bounded dispatcher for RPC streams', async () => {
const agents: Array<{ allowH2?: boolean; connections?: number }> = []
class Agent {
constructor(options: { allowH2?: boolean; connections?: number }) {
agents.push(options)
}
}
const undiciFetch = vi.fn(() => Promise.resolve(new Response('ok')))
const { createEnvdFetchForRuntime } = await import('../../src/envd/http2')
const fetcher = createEnvdFetchForRuntime('node', {
connectionLimit: 100,
loadUndici: () => Promise.resolve({ Agent, fetch: undiciFetch }),
})
await fetcher('https://example.com/rpc')
expect(agents).toEqual([{ allowH2: true, connections: 100 }])
})
test('reads RPC stream dispatcher connection limit from env', async () => {
process.env.E2B_ENVD_RPC_CONNECTIONS = '200'
const { getEnvdRpcConnectionLimit } = await import('../../src/envd/http2')
expect(getEnvdRpcConnectionLimit()).toBe(200)
})
test('getEnvdRpcConnectionLimit throws on malformed env value', async () => {
process.env.E2B_ENVD_RPC_CONNECTIONS = 'bogus'
const { getEnvdRpcConnectionLimit } = await import('../../src/envd/http2')
expect(() => getEnvdRpcConnectionLimit()).toThrow(/E2B_ENVD_RPC_CONNECTIONS/)
})
test('getEnvdInflightLimit throws on malformed env value', async () => {
process.env.E2B_ENVD_INFLIGHT_REQUESTS = 'bogus'
const { getEnvdInflightLimit } = await import('../../src/envd/http2')
expect(() => getEnvdInflightLimit()).toThrow(/E2B_ENVD_INFLIGHT_REQUESTS/)
})
test('getEnvdRpcInflightLimit throws on malformed env value', async () => {
process.env.E2B_ENVD_RPC_INFLIGHT_REQUESTS = 'bogus'
const { getEnvdRpcInflightLimit } = await import('../../src/envd/http2')
expect(() => getEnvdRpcInflightLimit()).toThrow(
/E2B_ENVD_RPC_INFLIGHT_REQUESTS/
)
})
test('inflight limit env vars return 0 when explicitly disabled', async () => {
process.env.E2B_ENVD_INFLIGHT_REQUESTS = '0'
process.env.E2B_ENVD_RPC_INFLIGHT_REQUESTS = '0'
const { getEnvdInflightLimit, getEnvdRpcInflightLimit } = await import(
'../../src/envd/http2'
)
expect(getEnvdInflightLimit()).toBe(0)
expect(getEnvdRpcInflightLimit()).toBe(0)
})
test('getEnvdInflightLimit throws on negative env value', async () => {
process.env.E2B_ENVD_INFLIGHT_REQUESTS = '-1'
const { getEnvdInflightLimit } = await import('../../src/envd/http2')
expect(() => getEnvdInflightLimit()).toThrow(/E2B_ENVD_INFLIGHT_REQUESTS=-1/)
})
test('getEnvdRpcInflightLimit throws on negative env value', async () => {
process.env.E2B_ENVD_RPC_INFLIGHT_REQUESTS = '-5'
const { getEnvdRpcInflightLimit } = await import('../../src/envd/http2')
expect(() => getEnvdRpcInflightLimit()).toThrow(
/E2B_ENVD_RPC_INFLIGHT_REQUESTS=-5/
)
})
test('defers loading undici until the first Node request', async () => {
const Agent = vi.fn()
const undiciFetch = vi.fn(() => Promise.resolve(new Response('ok')))
const loadUndici = vi.fn(() => Promise.resolve({ Agent, fetch: undiciFetch }))
const { createEnvdFetchForRuntime } = await import('../../src/envd/http2')
const fetcher = createEnvdFetchForRuntime('node', {
connectionLimit: 1,
loadUndici,
})
expect(loadUndici).not.toHaveBeenCalled()
expect(Agent).not.toHaveBeenCalled()
expect(undiciFetch).not.toHaveBeenCalled()
await fetcher('https://example.com/status')
expect(loadUndici).toHaveBeenCalledOnce()
expect(Agent).toHaveBeenCalledOnce()
expect(undiciFetch).toHaveBeenCalledOnce()
})
test('falls back to global fetch when undici cannot be loaded', async () => {
const fallbackFetch = vi.fn(() =>
Promise.resolve(new Response('fallback ok'))
) as unknown as typeof fetch
vi.stubGlobal('fetch', fallbackFetch)
const { createEnvdFetchForRuntime } = await import('../../src/envd/http2')
const fetcher = createEnvdFetchForRuntime('node', {
loadUndici: () => Promise.resolve(undefined),
})
const res = await fetcher('https://example.com/status')
expect(await res.text()).toBe('fallback ok')
expect(fallbackFetch).toHaveBeenCalledWith(
'https://example.com/status',
undefined
)
})
test('uses global fetch outside Node', async () => {
const fallbackFetch = vi.fn() as unknown as typeof fetch
vi.stubGlobal('fetch', fallbackFetch)
const { createEnvdFetchForRuntime } = await import('../../src/envd/http2')
expect(createEnvdFetchForRuntime('browser')).toBe(fallbackFetch)
expect(createEnvdFetchForRuntime('vercel-edge')).toBe(fallbackFetch)
})
@@ -0,0 +1,53 @@
import { test, assert } from 'vitest'
import Sandbox from '../../src/index.js'
import { isIntegrationTest } from '../setup.js'
const integrationTestTemplate = 'en716jw99aj63v1k8ugh'
test.skipIf(!isIntegrationTest)(
'test python random number generation in same sandbox',
async () => {
const sbx = await Sandbox.create(integrationTestTemplate, {
timeoutMs: 120,
})
console.log('sandboxId', sbx.sandboxId)
const cmd1 = await sbx.commands.run(
'python -c "import numpy as np; print([np.random.normal(),np.random.normal(),np.random.normal()])"'
)
console.log('cmd1 stdout', cmd1.stdout)
const cmd2 = await sbx.commands.run(
'python -c "import numpy as np; print([np.random.normal(),np.random.normal(),np.random.normal()])"'
)
console.log('cmd2 stdout', cmd2.stdout)
assert.notEqual(cmd1.stdout, cmd2.stdout)
}
)
test.skipIf(!isIntegrationTest)(
'test python random number generation with same template',
async () => {
const sbx = await Sandbox.create(integrationTestTemplate, {
timeoutMs: 120,
})
console.log('sandboxId 1', sbx.sandboxId)
const cmd1 = await sbx.commands.run(
'python -c "import numpy as np; print([np.random.normal(),np.random.normal(),np.random.normal()])"'
)
console.log('cmd1 stdout', cmd1.stdout)
await sbx.kill()
const sbx2 = await Sandbox.create(integrationTestTemplate, {
timeoutMs: 120,
})
console.log('sandboxId 2', sbx2.sandboxId)
const cmd2 = await sbx2.commands.run(
'python -c "import numpy as np; print([np.random.normal(),np.random.normal(),np.random.normal()])"'
)
console.log('cmd2 stdout', cmd2.stdout)
assert.notEqual(cmd1.stdout, cmd2.stdout)
}
)
@@ -0,0 +1,77 @@
import { test } from 'vitest'
import Sandbox from '../../src/index.js'
import { isIntegrationTest, wait } from '../setup.js'
const heavyArray = new ArrayBuffer(256 * 1024 * 1024) // 256 MiB = 256 * 1024 * 1024 bytes
const view = new Uint8Array(heavyArray)
for (let i = 0; i < view.length; i++) {
view[i] = Math.floor(Math.random() * 256)
}
const integrationTestTemplate = 'integration-test-v1'
const sandboxCount = 10
test.skipIf(!isIntegrationTest)(
'stress test heavy file writes and reads',
async () => {
const promises: Array<Promise<string | void>> = []
for (let i = 0; i < sandboxCount; i++) {
promises.push(
Sandbox.create(integrationTestTemplate, { timeoutMs: 60 })
.then((sbx) => {
console.log(sbx.sandboxId)
return sbx.files
.write('heavy-file', heavyArray)
.then(() => sbx.files.read('heavy-file'))
})
.catch(console.error)
)
}
await wait(10_000)
await Promise.all(promises)
}
)
test.skipIf(!isIntegrationTest)('stress requests to nextjs app', async () => {
const hostPromises: Array<Promise<string | void>> = []
for (let i = 0; i < sandboxCount; i++) {
hostPromises.push(
Sandbox.create(integrationTestTemplate, { timeoutMs: 60_000 }).then(
(sbx) => {
console.log('created sandbox', sbx.sandboxId)
return new Promise((resolve, reject) => {
try {
resolve(sbx.getHost(3000))
} catch (e) {
console.error('error getting sbx host', e)
reject(e)
}
})
}
)
)
}
await wait(10_000)
const hosts = await Promise.all(hostPromises)
const fetchPromises: Array<Promise<string | void>> = []
for (let i = 0; i < 100; i++) {
for (const host of hosts) {
fetchPromises.push(
new Promise((resolve) => {
fetch('https://' + host)
.then((res) => {
console.log(`response for ${host}: ${res.status}`)
})
.then(resolve)
})
)
}
}
await Promise.all(fetchPromises)
})
@@ -0,0 +1,5 @@
# Integration test template
# Build the template
`$ e2b template build -c "cd /basic-nextjs-app/ && sudo npm run dev"`
@@ -0,0 +1,11 @@
FROM e2bdev/code-interpreter:latest
# Install stress-ng, a tool to load and stress computer systems
RUN apt update
RUN apt install -y stress-ng
# Create a basic Next.js app
RUN npx -y create-next-app@latest basic-nextjs-app --yes --ts --use-npm
# Install dependencies
RUN cd basic-nextjs-app && npm install
@@ -0,0 +1,18 @@
# This is a config for E2B sandbox template.
# You can use template ID (2e2z80zhv34yumbrybvn) or template name (integration-test-v1) to create a sandbox:
# Python SDK
# from e2b import Sandbox, AsyncSandbox
# sandbox = Sandbox("integration-test-v1") # Sync sandbox
# sandbox = await AsyncSandbox.create("integration-test-v1") # Async sandbox
# JS SDK
# import { Sandbox } from 'e2b'
# const sandbox = await Sandbox.create('integration-test-v1')
team_id = "460355b3-4f64-48f9-9a16-4442817f79f5"
memory_mb = 512
start_cmd = "npm run dev"
dockerfile = "e2b.Dockerfile"
template_name = "integration-test-v1"
template_id = "2e2z80zhv34yumbrybvn"
+39
View File
@@ -0,0 +1,39 @@
import { assert, describe, test } from 'vitest'
import { createRpcLogger } from '../src/logs'
const req = { url: 'http://localhost:49983/process.Process/Start' } as any
describe('createRpcLogger', () => {
test('logs unary responses containing bigint fields without throwing', async () => {
const logs: any[][] = []
const interceptor = createRpcLogger({ info: (...args) => logs.push(args) })
const res = { stream: false, message: { size: 42n, name: 'file' } } as any
const result = await interceptor(async () => res)(req)
assert.equal(result, res)
assert.deepEqual(logs[1], ['Response:', { size: '42', name: 'file' }])
})
test('logs streamed messages containing bigint fields without throwing', async () => {
const logs: any[][] = []
const interceptor = createRpcLogger({ debug: (...args) => logs.push(args) })
async function* stream() {
yield { offset: 9007199254740993n }
}
const res = { stream: true, message: stream() } as any
const result = (await interceptor(async () => res)(req)) as any
const received = []
for await (const m of result.message) {
received.push(m)
}
assert.deepEqual(received, [{ offset: 9007199254740993n }])
assert.deepEqual(logs[0], [
'Response stream:',
{ offset: '9007199254740993' },
])
})
})
+62
View File
@@ -0,0 +1,62 @@
import { assert, expect, test } from 'vitest'
import { Paginator } from '../src/paginator'
// Build a fake HTTP Response carrying only the `x-next-token` header.
function fakeResponse(nextToken?: string): Response {
const headers = new Headers()
if (nextToken !== undefined) {
headers.set('x-next-token', nextToken)
}
return new Response(null, { headers })
}
// Minimal concrete paginator that returns canned pages and drives the shared
// base state machine via `updatePagination`.
class FakePaginator extends Paginator<string> {
private call = 0
constructor(
private readonly pages: Array<{ items: string[]; nextToken?: string }>
) {
super()
}
async nextItems(): Promise<string[]> {
if (!this.hasNext) {
throw new Error('No more items to fetch')
}
const page = this.pages[this.call++]
this.updatePagination(fakeResponse(page.nextToken))
return page.items
}
}
test('paginator exposes pagination state and advances across pages', async () => {
const paginator = new FakePaginator([
{ items: ['a', 'b'], nextToken: 'tok-2' },
{ items: ['c'], nextToken: undefined },
])
assert.isTrue(paginator.hasNext)
assert.isUndefined(paginator.nextToken)
const first = await paginator.nextItems()
assert.deepEqual(first, ['a', 'b'])
assert.isTrue(paginator.hasNext)
assert.equal(paginator.nextToken, 'tok-2')
const second = await paginator.nextItems()
assert.deepEqual(second, ['c'])
assert.isFalse(paginator.hasNext)
assert.isUndefined(paginator.nextToken)
})
test('paginator throws once exhausted', async () => {
const paginator = new FakePaginator([{ items: [], nextToken: undefined }])
await paginator.nextItems()
assert.isFalse(paginator.hasNext)
await expect(paginator.nextItems()).rejects.toThrow('No more items to fetch')
})
@@ -0,0 +1,38 @@
import { expect, inject, test } from 'vitest'
import { render } from 'vitest-browser-react'
import React from 'react'
import { useEffect, useState } from 'react'
import { Sandbox } from '../../../src'
import { template } from '../../template'
function E2BTest() {
const [text, setText] = useState<string>()
useEffect(() => {
const getText = async () => {
const sandbox = await Sandbox.create(template, {
apiKey: inject('E2B_API_KEY'),
domain: inject('E2B_DOMAIN'),
})
try {
await sandbox.commands.run('echo "Hello World" > hello.txt')
const content = await sandbox.files.read('hello.txt')
setText(content)
} finally {
await sandbox.kill()
}
}
getText()
}, [])
return <div>{text}</div>
}
test('browser test', async () => {
const screen = await render(<E2BTest />)
await expect
.element(screen.getByText('Hello World'), { timeout: 30_000 })
.toBeInTheDocument()
}, 40_000)
@@ -0,0 +1,30 @@
import { expect, test } from 'bun:test'
import { Sandbox } from '../../../src'
import { template } from '../../template'
test(
'Bun test',
async () => {
const sbx = await Sandbox.create(template, { timeoutMs: 5_000 })
try {
const isRunning = await sbx.isRunning()
expect(isRunning).toBeTruthy()
const text = 'Hello, World!'
const cmd = await sbx.commands.run(`echo "${text}"`)
expect(cmd.exitCode).toBe(0)
expect(cmd.stdout).toEqual(`${text}\n`)
await sbx.files.write('test.txt', text)
const test = await sbx.files.read('test.txt')
expect(test).toEqual(text)
} finally {
await sbx.kill()
}
},
{ timeout: 20_000 }
)
@@ -0,0 +1,27 @@
import {
assert,
assertEquals,
} from 'https://deno.land/std@0.224.0/assert/mod.ts'
import { load } from 'https://deno.land/std@0.224.0/dotenv/mod.ts'
await load({ envPath: '.env', export: true })
import { Sandbox } from '../../../dist/index.mjs'
import { template } from '../../template'
Deno.test('Deno test', async () => {
const sbx = await Sandbox.create(template, { timeoutMs: 5_000 })
try {
const isRunning = await sbx.isRunning()
assert(isRunning)
const text = 'Hello, World!'
const cmd = await sbx.commands.run(`echo "${text}"`)
assertEquals(cmd.exitCode, 0)
assertEquals(cmd.stdout, `${text}\n`)
} finally {
await sbx.kill()
}
})
@@ -0,0 +1,116 @@
import { afterAll, afterEach, beforeAll, expect, test } from 'vitest'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { Sandbox } from '../../src'
import { TEST_API_KEY, apiUrl } from '../setup'
// Hold the request open until the caller aborts. If the signal is already
// aborted by the time the handler runs, `addEventListener('abort', …)` would
// never fire — so check `aborted` first to avoid hanging.
function holdUntilAborted(signal: AbortSignal): Promise<never> {
return new Promise<never>((_, reject) => {
const abort = () => reject(new DOMException('aborted', 'AbortError'))
if (signal.aborted) {
abort()
return
}
signal.addEventListener('abort', abort, { once: true })
})
}
const restHandlers = [
http.post(apiUrl('/sandboxes'), async ({ request }) => {
await holdUntilAborted(request.signal)
return HttpResponse.json({})
}),
http.delete(apiUrl('/sandboxes/:sandboxID'), async ({ request }) => {
await holdUntilAborted(request.signal)
return HttpResponse.json({})
}),
http.get(apiUrl('/v2/sandboxes'), async ({ request }) => {
await holdUntilAborted(request.signal)
return HttpResponse.json([])
}),
]
const server = setupServer(...restHandlers)
beforeAll(() =>
server.listen({
onUnhandledRequest: 'bypass',
})
)
afterAll(() => server.close())
afterEach(() => server.resetHandlers())
// Resolves once MSW has dispatched the next request, so tests can abort
// deterministically instead of guessing with `setTimeout`.
function nextRequestStart(): Promise<void> {
return new Promise<void>((resolve) => {
const listener = () => {
server.events.removeListener('request:start', listener)
resolve()
}
server.events.on('request:start', listener)
})
}
test('Sandbox.create rejects when AbortSignal is aborted', async () => {
const controller = new AbortController()
const requestStarted = nextRequestStart()
const promise = Sandbox.create('base', {
apiKey: TEST_API_KEY,
signal: controller.signal,
})
await requestStarted
controller.abort()
await expect(promise).rejects.toThrow()
})
test('Sandbox.create rejects immediately when signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
await expect(
Sandbox.create('base', {
apiKey: TEST_API_KEY,
signal: controller.signal,
})
).rejects.toThrow()
})
test('Sandbox.kill rejects when AbortSignal is aborted', async () => {
const controller = new AbortController()
const requestStarted = nextRequestStart()
const promise = Sandbox.kill('some-sandbox', {
apiKey: TEST_API_KEY,
signal: controller.signal,
})
await requestStarted
controller.abort()
await expect(promise).rejects.toThrow()
})
test('SandboxPaginator.nextItems rejects when per-call AbortSignal is aborted', async () => {
const controller = new AbortController()
const requestStarted = nextRequestStart()
const paginator = Sandbox.list({
apiKey: TEST_API_KEY,
})
const promise = paginator.nextItems({ signal: controller.signal })
await requestStarted
controller.abort()
await expect(promise).rejects.toThrow()
})
@@ -0,0 +1,477 @@
import { describe, expect, it, vi } from 'vitest'
import { CommandHandle } from '../../../src/sandbox/commands/commandHandle'
type EventKind = 'stdout' | 'stderr' | 'pty'
function createEvents(kind: EventKind): AsyncIterable<any> {
async function* events() {
if (kind === 'pty') {
yield {
event: {
event: {
case: 'data',
value: {
output: {
case: 'pty',
value: new Uint8Array([1, 2, 3]),
},
},
},
},
}
} else {
yield {
event: {
event: {
case: 'data',
value: {
output: {
case: kind,
value: new TextEncoder().encode(kind),
},
},
},
},
}
}
yield {
event: {
event: {
case: 'end',
value: {
exitCode: 0,
error: undefined,
},
},
},
}
}
return events()
}
function dataEvent(kind: 'stdout' | 'stderr', value: Uint8Array) {
return {
event: {
event: {
case: 'data',
value: {
output: {
case: kind,
value,
},
},
},
},
}
}
function endEvent(exitCode = 0) {
return {
event: {
event: {
case: 'end',
value: {
exitCode,
error: undefined,
},
},
},
}
}
// An async iterable whose events are delivered on demand. Lets a test hold the
// handle's event loop blocked on `next()` (idle between bursts) and then push a
// late event to simulate stdout arriving after `disconnect()` — the transport
// condition that triggers the production leak. `return()` (called when the
// handle's `for await` breaks) unblocks any pending read, mirroring the stream
// being torn down from the client side.
function createControllableEvents() {
const queue: any[] = []
let pending: ((result: IteratorResult<any>) => void) | undefined
let closed = false
const iterator: AsyncIterator<any> = {
next() {
if (queue.length > 0) {
return Promise.resolve({ value: queue.shift(), done: false })
}
if (closed) {
return Promise.resolve({ value: undefined, done: true })
}
return new Promise((resolve) => {
pending = resolve
})
},
return() {
closed = true
if (pending) {
const resolve = pending
pending = undefined
resolve({ value: undefined, done: true })
}
return Promise.resolve({ value: undefined, done: true })
},
}
return {
events: { [Symbol.asyncIterator]: () => iterator } as AsyncIterable<any>,
push(event: any) {
if (pending) {
const resolve = pending
pending = undefined
resolve({ value: event, done: false })
} else {
queue.push(event)
}
},
}
}
describe('CommandHandle', () => {
it.each<EventKind>(['stdout', 'stderr', 'pty'])(
'wait awaits async %s callbacks',
async (kind) => {
let callbackStarted = false
let releaseCallback: (() => void) | undefined
const callbackBlocked = new Promise<void>((resolve) => {
releaseCallback = resolve
})
const callback = async () => {
callbackStarted = true
await callbackBlocked
}
const handle = new CommandHandle(
1,
() => {},
async () => true,
createEvents(kind),
kind === 'stdout' ? callback : undefined,
kind === 'stderr' ? callback : undefined,
kind === 'pty' ? callback : undefined
)
let waitResolved = false
const waitPromise = handle.wait().then(() => {
waitResolved = true
})
await vi.waitFor(() => {
expect(callbackStarted).toBe(true)
})
expect(waitResolved).toBe(false)
releaseCallback?.()
await waitPromise
expect(waitResolved).toBe(true)
}
)
it('decodes multibyte characters split across chunks', async () => {
const emojiBytes = new TextEncoder().encode('😀')
async function* events() {
yield dataEvent(
'stdout',
new Uint8Array([
...new TextEncoder().encode('a'),
...emojiBytes.slice(0, 2),
])
)
yield dataEvent(
'stdout',
new Uint8Array([
...emojiBytes.slice(2),
...new TextEncoder().encode('b'),
])
)
yield dataEvent('stderr', emojiBytes.slice(0, 3))
yield dataEvent('stderr', emojiBytes.slice(3))
yield endEvent()
}
const stdoutChunks: string[] = []
const handle = new CommandHandle(
1,
() => {},
async () => true,
events(),
(out) => {
stdoutChunks.push(out)
}
)
const result = await handle.wait()
expect(result.stdout).toBe('a😀b')
expect(result.stderr).toBe('😀')
expect(result.stdout).not.toContain('')
expect(result.stderr).not.toContain('')
expect(stdoutChunks.join('')).toBe('a😀b')
})
it('replaces incomplete trailing utf-8 sequences at the end of the stream', async () => {
const emojiBytes = new TextEncoder().encode('😀')
async function* events() {
yield dataEvent(
'stdout',
new Uint8Array([
...new TextEncoder().encode('a'),
...emojiBytes.slice(0, 2),
])
)
yield endEvent()
}
const handle = new CommandHandle(
1,
() => {},
async () => true,
events()
)
const result = await handle.wait()
expect(result.stdout).toBe('a')
})
it('flushes incomplete trailing utf-8 sequences when the stream closes without an end event', async () => {
const emojiBytes = new TextEncoder().encode('😀')
async function* events() {
yield dataEvent(
'stdout',
new Uint8Array([
...new TextEncoder().encode('a'),
...emojiBytes.slice(0, 2),
])
)
}
const stdoutChunks: string[] = []
const handle = new CommandHandle(
1,
() => {},
async () => true,
events(),
(out) => {
stdoutChunks.push(out)
}
)
// No end event arrives, so wait() rejects, but the buffered bytes must
// still be flushed to the stdout callback as a replacement character.
await expect(handle.wait()).rejects.toThrow()
expect(stdoutChunks.join('')).toBe('a')
})
it('flushes incomplete trailing utf-8 sequences when the stream errors', async () => {
const emojiBytes = new TextEncoder().encode('😀')
async function* events() {
yield dataEvent(
'stdout',
new Uint8Array([
...new TextEncoder().encode('a'),
...emojiBytes.slice(0, 2),
])
)
throw new Error('stream died')
}
const stdoutChunks: string[] = []
const handle = new CommandHandle(
1,
() => {},
async () => true,
events(),
(out) => {
stdoutChunks.push(out)
}
)
// The stream errors before an end event arrives, so wait() rejects, but the
// buffered bytes must still be flushed to the stdout callback as a
// replacement character.
await expect(handle.wait()).rejects.toThrow()
expect(stdoutChunks.join('')).toBe('a')
})
it('onStdout stops firing after await disconnect()', async () => {
const controllable = createControllableEvents()
const chunks: string[] = []
const handle = new CommandHandle(
1,
() => {},
async () => true,
controllable.events,
(out) => {
chunks.push(out)
}
)
// First burst is delivered to the live subscriber.
controllable.push(dataEvent('stdout', new TextEncoder().encode('a')))
await vi.waitFor(() => {
expect(chunks).toEqual(['a'])
})
// Disconnect while the event loop is idle (blocked on the next read), then
// push a late event — as if stdout arrived after the abort but before the
// stream was torn down. The late event must never reach the callback.
await handle.disconnect()
controllable.push(dataEvent('stdout', new TextEncoder().encode('b')))
await new Promise((r) => setTimeout(r, 0))
expect(chunks).toEqual(['a'])
// Any further output is ignored too.
controllable.push(dataEvent('stdout', new TextEncoder().encode('c')))
await new Promise((r) => setTimeout(r, 0))
expect(chunks).toEqual(['a'])
})
it('disconnect() resolves promptly when a stream is idle', async () => {
// An idle long-running command (e.g. `sleep`) produces no further output,
// so the event handler stays blocked on the next read. disconnect() must
// not wait for that read and must resolve right away.
const controllable = createControllableEvents()
const handle = new CommandHandle(
1,
() => {},
async () => true,
controllable.events,
() => {}
)
// No event is ever pushed; this would hang if disconnect() awaited the
// event handler.
await handle.disconnect()
})
it('disconnect() does not block on an in-flight callback', async () => {
const controllable = createControllableEvents()
let callbackStarted = false
let releaseCallback: (() => void) | undefined
const callbackBlocked = new Promise<void>((resolve) => {
releaseCallback = resolve
})
const handle = new CommandHandle(
1,
() => {},
async () => true,
controllable.events,
async () => {
callbackStarted = true
await callbackBlocked
}
)
controllable.push(dataEvent('stdout', new TextEncoder().encode('a')))
await vi.waitFor(() => {
expect(callbackStarted).toBe(true)
})
// The callback is still in flight; disconnect() must resolve without
// waiting for it to settle.
await handle.disconnect()
// Releasing the callback afterwards is harmless.
releaseCallback?.()
})
it('disconnect() resolves when called from inside a callback', async () => {
const controllable = createControllableEvents()
let disconnectReturned = false
const handle: CommandHandle = new CommandHandle(
1,
() => {},
async () => true,
controllable.events,
async () => {
await handle.disconnect()
disconnectReturned = true
}
)
controllable.push(dataEvent('stdout', new TextEncoder().encode('a')))
await vi.waitFor(() => {
expect(disconnectReturned).toBe(true)
})
})
it('records the exit code when disconnected before an end event with trailing bytes', async () => {
const controllable = createControllableEvents()
const emojiBytes = new TextEncoder().encode('😀')
const chunks: string[] = []
const handle = new CommandHandle(
1,
() => {},
async () => true,
controllable.events,
(out) => {
chunks.push(out)
}
)
// First chunk leaves an incomplete multibyte sequence buffered in the
// decoder, so the end event will flush a trailing replacement character.
controllable.push(
dataEvent(
'stdout',
new Uint8Array([
...new TextEncoder().encode('a'),
...emojiBytes.slice(0, 2),
])
)
)
await vi.waitFor(() => {
expect(chunks).toEqual(['a'])
})
// Disconnect, then the process exits. The end event carries a flushed
// chunk; wait() must still surface the exit code instead of failing as if
// the process never produced a result.
await handle.disconnect()
controllable.push(endEvent(0))
const result = await handle.wait()
expect(result.exitCode).toBe(0)
expect(result.stdout).toBe('a')
// The flushed chunk was produced after disconnect, so it must not reach
// the live callback.
expect(chunks).toEqual(['a'])
})
it('surfaces an async callback error through wait()', async () => {
async function* events() {
yield dataEvent('stdout', new TextEncoder().encode('a'))
yield endEvent()
}
const handle = new CommandHandle(
1,
() => {},
async () => true,
events(),
async () => {
throw new Error('callback failed')
}
)
await expect(handle.wait()).rejects.toThrow('callback failed')
})
})
@@ -0,0 +1,18 @@
import { assert, expect } from 'vitest'
import { sandboxTest } from '../../setup.js'
sandboxTest('connect to process', async ({ sandbox }) => {
const cmd = await sandbox.commands.run('sleep 10', { background: true })
const pid = cmd.pid
const processInfo = await sandbox.commands.connect(pid)
assert.isObject(processInfo)
assert.equal(processInfo.pid, pid)
})
sandboxTest('connect to non-existing process', async ({ sandbox }) => {
const nonExistingPid = 999999
await expect(sandbox.commands.connect(nonExistingPid)).rejects.toThrowError()
})
@@ -0,0 +1,45 @@
import { assert, describe } from 'vitest'
import { sandboxTest, isDebug } from '../../setup.js'
describe('sandbox global env vars', () => {
sandboxTest.scoped({
sandboxOpts: {
envs: { FOO: 'bar' },
},
})
sandboxTest.skipIf(isDebug)(
'sandbox global env vars',
async ({ sandbox }) => {
const cmd = await sandbox.commands.run('echo $FOO')
assert.equal(cmd.exitCode, 0)
assert.equal(cmd.stdout.trim(), 'bar')
}
)
})
sandboxTest('bash command scoped env vars', async ({ sandbox }) => {
const cmd = await sandbox.commands.run('echo $FOO', {
envs: { FOO: 'bar' },
})
assert.equal(cmd.exitCode, 0)
assert.equal(cmd.stdout.trim(), 'bar')
// test that it is secure and not accessible to subsequent commands
const cmd2 = await sandbox.commands.run('sudo echo "$FOO"')
assert.equal(cmd2.exitCode, 0)
assert.equal(cmd2.stdout.trim(), '')
})
sandboxTest('python command scoped env vars', async ({ sandbox }) => {
const cmd = await sandbox.commands.run(
'python3 -c "import os; print(os.environ[\'FOO\'])"',
{ envs: { FOO: 'bar' } }
)
assert.equal(cmd.exitCode, 0)
assert.equal(cmd.stdout.trim(), 'bar')
})
@@ -0,0 +1,21 @@
import { expect } from 'vitest'
import { ProcessExitError } from '../../../src/index.js'
import { sandboxTest } from '../../setup.js'
sandboxTest('kill process', async ({ sandbox }) => {
const cmd = await sandbox.commands.run('sleep 10', { background: true })
const pid = cmd.pid
await sandbox.commands.kill(pid)
await expect(sandbox.commands.run(`kill -0 ${pid}`)).rejects.toThrowError(
ProcessExitError
)
})
sandboxTest('kill non-existing process', async ({ sandbox }) => {
const nonExistingPid = 999999
await expect(sandbox.commands.kill(nonExistingPid)).resolves.toBe(false)
})
@@ -0,0 +1,17 @@
import { assert } from 'vitest'
import { sandboxTest } from '../../setup.js'
sandboxTest('list processes', async ({ sandbox }) => {
// Start them firsts
await sandbox.commands.run('sleep 10', { background: true })
await sandbox.commands.run('sleep 10', { background: true })
const processes = await sandbox.commands.list()
assert.isArray(processes)
assert.isAtLeast(processes.length, 2)
processes.forEach((process) => {
assert.containsAllKeys(process, ['pid'])
})
})
@@ -0,0 +1,54 @@
import { expect, assert } from 'vitest'
import { sandboxTest } from '../../setup.js'
import { TimeoutError } from '../../../src/index.js'
sandboxTest('run', async ({ sandbox }) => {
const text = 'Hello, World!'
const cmd = await sandbox.commands.run(`echo "${text}"`)
assert.equal(cmd.exitCode, 0)
assert.equal(cmd.stdout, `${text}\n`)
})
sandboxTest('run with special characters', async ({ sandbox }) => {
const text = '!@#$%^&*()_+'
const cmd = await sandbox.commands.run(`echo "${text}"`)
assert.equal(cmd.exitCode, 0)
assert.equal(cmd.stdout, `${text}\n`)
})
sandboxTest('run with multiline string', async ({ sandbox }) => {
const text = 'Hello,\nWorld!'
const cmd = await sandbox.commands.run(`echo "${text}"`)
assert.equal(cmd.exitCode, 0)
assert.equal(cmd.stdout, `${text}\n`)
})
sandboxTest('run with timeout', async ({ sandbox }) => {
const cmd = await sandbox.commands.run('echo "Hello, World!"', {
timeoutMs: 4000,
})
assert.equal(cmd.exitCode, 0)
})
sandboxTest('run with too short timeout', async ({ sandbox }) => {
await expect(
sandbox.commands.run('sleep 10', { timeoutMs: 1000 })
).rejects.toThrow()
})
sandboxTest('run with too short timeout iterating', async ({ sandbox }) => {
const handle = await sandbox.commands.run('sleep 10', {
timeoutMs: 2000,
background: true,
})
await expect(handle.wait()).rejects.toThrowError(TimeoutError)
})
@@ -0,0 +1,20 @@
import { expect } from 'vitest'
import { TimeoutError } from '../../../src/index.js'
import { sandboxTest, isDebug } from '../../setup.js'
sandboxTest.skipIf(isDebug)(
'killing the sandbox while a command is running throws an actionable error',
async ({ sandbox }) => {
const cmd = await sandbox.commands.run('sleep 60', { background: true })
await sandbox.kill()
const err = await cmd.wait().catch((e) => e)
expect(err).toBeInstanceOf(TimeoutError)
// The health check confirms the sandbox is gone, so the error states it outright
expect(err.message).toContain(
'sandbox was killed or reached its end of life'
)
}
)
@@ -0,0 +1,138 @@
import { assert } from 'vitest'
import { sandboxTest } from '../../setup.js'
sandboxTest('send stdin to process', async ({ sandbox }) => {
const text = 'Hello, World!'
const cmd = await sandbox.commands.run('cat', {
background: true,
stdin: true,
})
await sandbox.commands.sendStdin(cmd.pid, text)
for (let i = 0; i < 5; i++) {
if (cmd.stdout === text) {
break
}
await new Promise((r) => setTimeout(r, 500))
}
await cmd.kill()
assert.equal(cmd.stdout, text)
})
sandboxTest('send Uint8Array stdin to process', async ({ sandbox }) => {
const text = 'Hello, World!'
const cmd = await sandbox.commands.run('cat', {
background: true,
stdin: true,
})
await sandbox.commands.sendStdin(cmd.pid, new TextEncoder().encode(text))
for (let i = 0; i < 5; i++) {
if (cmd.stdout === text) {
break
}
await new Promise((r) => setTimeout(r, 500))
}
await cmd.kill()
assert.equal(cmd.stdout, text)
})
sandboxTest('send stdin via command handle', async ({ sandbox }) => {
const text = 'Hello, World!'
const cmd = await sandbox.commands.run('cat', {
background: true,
stdin: true,
})
await cmd.sendStdin(text)
for (let i = 0; i < 5; i++) {
if (cmd.stdout === text) {
break
}
await new Promise((r) => setTimeout(r, 500))
}
await cmd.kill()
assert.equal(cmd.stdout, text)
})
sandboxTest('close stdin via command handle', async ({ sandbox }) => {
const text = 'Hello, World!'
const cmd = await sandbox.commands.run('cat', {
background: true,
stdin: true,
})
await cmd.sendStdin(text)
await cmd.closeStdin()
// `cat` exits once stdin is closed (EOF).
const result = await cmd.wait()
assert.equal(result.exitCode, 0)
assert.equal(result.stdout, text)
})
sandboxTest('send empty stdin to process', async ({ sandbox }) => {
const text = ''
const cmd = await sandbox.commands.run('cat', {
background: true,
stdin: true,
})
await sandbox.commands.sendStdin(cmd.pid, text)
await cmd.kill()
assert.equal(cmd.stdout, text)
})
sandboxTest('send special characters to stdin', async ({ sandbox }) => {
const text = '!@#$%^&*()_+'
const cmd = await sandbox.commands.run('cat', {
background: true,
stdin: true,
})
await sandbox.commands.sendStdin(cmd.pid, text)
for (let i = 0; i < 5; i++) {
if (cmd.stdout === text) {
break
}
await new Promise((r) => setTimeout(r, 500))
}
await cmd.kill()
assert.equal(cmd.stdout, text)
})
sandboxTest('send multiline string to stdin', async ({ sandbox }) => {
const text = 'Hello,\nWorld!'
const cmd = await sandbox.commands.run('cat', {
background: true,
stdin: true,
})
await sandbox.commands.sendStdin(cmd.pid, text)
for (let i = 0; i < 5; i++) {
if (cmd.stdout === text) {
break
}
await new Promise((r) => setTimeout(r, 500))
}
await cmd.kill()
assert.equal(cmd.stdout, text)
})
@@ -0,0 +1,109 @@
import { afterEach, assert, beforeEach, describe, test, vi } from 'vitest'
import { Sandbox } from '../../src'
import { SandboxApi } from '../../src/sandbox/sandboxApi'
import { TEST_API_KEY } from '../setup'
let originalSandboxUrl: string | undefined
const baseConfig = {
apiKey: TEST_API_KEY,
domain: 'base.e2b.dev',
requestTimeoutMs: 1111,
debug: false,
apiHeaders: { 'X-Test': 'base' },
}
function createSandbox() {
return new Sandbox({
sandboxId: 'sbx-test',
sandboxDomain: 'sandbox.e2b.dev',
envdVersion: '0.2.4',
envdAccessToken: 'tok',
trafficAccessToken: 'tok',
...baseConfig,
})
}
describe('Sandbox API config propagation', () => {
beforeEach(() => {
originalSandboxUrl = process.env.E2B_SANDBOX_URL
})
afterEach(() => {
vi.restoreAllMocks()
if (originalSandboxUrl === undefined) {
delete process.env.E2B_SANDBOX_URL
} else {
process.env.E2B_SANDBOX_URL = originalSandboxUrl
}
})
test('passes connectionConfig to public API methods when called without overrides', async () => {
const pauseSpy = vi.spyOn(SandboxApi, 'pause').mockResolvedValue(true)
const sandbox = createSandbox()
await sandbox.pause()
const opts = pauseSpy.mock.calls[0][1]
assert.equal(opts?.apiKey, baseConfig.apiKey)
assert.equal(opts?.domain, baseConfig.domain)
assert.equal(opts?.requestTimeoutMs, baseConfig.requestTimeoutMs)
assert.equal(opts?.debug, baseConfig.debug)
assert.equal(opts?.headers?.['X-Test'], baseConfig.apiHeaders['X-Test'])
})
test('accepts apiHeaders in static Sandbox API options', () => {
const opts = { apiHeaders: baseConfig.apiHeaders } satisfies Parameters<
typeof Sandbox.kill
>[1]
assert.equal(opts.apiHeaders['X-Test'], baseConfig.apiHeaders['X-Test'])
})
test('lets public method call overrides win over connectionConfig', async () => {
const pauseSpy = vi.spyOn(SandboxApi, 'pause').mockResolvedValue(true)
const sandbox = createSandbox()
await sandbox.pause({
domain: 'override.e2b.dev',
requestTimeoutMs: 9999,
})
const opts = pauseSpy.mock.calls[0][1]
assert.equal(opts?.apiKey, baseConfig.apiKey)
assert.equal(opts?.domain, 'override.e2b.dev')
assert.equal(opts?.requestTimeoutMs, 9999)
assert.equal(opts?.debug, baseConfig.debug)
})
test('updateNetwork forwards per-call signal', async () => {
const updateNetworkSpy = vi
.spyOn(SandboxApi, 'updateNetwork')
.mockResolvedValue()
const sandbox = createSandbox()
const controller = new AbortController()
await sandbox.updateNetwork({}, { signal: controller.signal })
const opts = updateNetworkSpy.mock.calls[0][2]
assert.equal(opts?.signal, controller.signal)
})
test('downloadUrl keeps sandbox identity in production direct URLs', async () => {
delete process.env.E2B_SANDBOX_URL
const sandbox = new Sandbox({
sandboxId: 'sbx-test',
sandboxDomain: 'e2b.app',
envdVersion: '0.2.4',
domain: 'e2b.app',
debug: false,
})
assert.equal(
await sandbox.downloadUrl('/hello.txt'),
'https://49983-sbx-test.e2b.app/files?username=user&path=%2Fhello.txt'
)
})
})
@@ -0,0 +1,121 @@
import { assert, test, expect, vi } from 'vitest'
import { Sandbox } from '../../src'
import { isDebug, sandboxTest, template } from '../setup.js'
test('connect in debug mode does not call the API', async () => {
const fetchSpy = vi.fn(() => {
throw new Error('unexpected request in debug mode')
})
vi.stubGlobal('fetch', fetchSpy)
try {
const sbx = await Sandbox.connect('debug-sandbox-id', {
debug: true,
apiKey: 'test-api-key',
})
assert.equal(sbx.sandboxId, 'debug-sandbox-id')
const sameSbx = await sbx.connect()
assert.strictEqual(sameSbx, sbx)
expect(fetchSpy).not.toHaveBeenCalled()
} finally {
vi.unstubAllGlobals()
}
})
test.skipIf(isDebug)('connect', async () => {
const sbx = await Sandbox.create(template, { timeoutMs: 10_000 })
try {
const isRunning = await sbx.isRunning()
assert.isTrue(isRunning)
const sbxConnection = await Sandbox.connect(sbx.sandboxId)
const isRunning2 = await sbxConnection.isRunning()
assert.isTrue(isRunning2)
} finally {
if (!isDebug) {
await sbx.kill()
}
}
})
sandboxTest.skipIf(isDebug)(
'connect resumes paused sandbox',
async ({ sandbox }) => {
await sandbox.pause()
assert.isFalse(await sandbox.isRunning())
const resumed = await Sandbox.connect(sandbox.sandboxId)
assert.isTrue(await resumed.isRunning())
}
)
sandboxTest.skipIf(isDebug)(
'connect to non-running sandbox',
async ({ sandbox }) => {
const isRunning = await sandbox.isRunning()
assert.isTrue(isRunning)
await sandbox.kill()
const connectPromise = Sandbox.connect(sandbox.sandboxId)
await expect(connectPromise).rejects.toThrowError(
expect.objectContaining({
name: 'SandboxNotFoundError',
})
)
}
)
test.skipIf(isDebug)(
'connect does not shorten timeout on running sandbox',
async () => {
// Create sandbox with a 300 second timeout
const sbx = await Sandbox.create(template, { timeoutMs: 300_000 })
try {
const isRunning = await sbx.isRunning()
assert.isTrue(isRunning)
// Get initial info to check endAt
const infoBefore = await Sandbox.getInfo(sbx.sandboxId)
// Connect with a shorter timeout (10 seconds)
await Sandbox.connect(sbx.sandboxId, { timeoutMs: 10_000 })
// Get info after connection
const infoAfter = await sbx.getInfo()
// The endAt time should not have been shortened. It should be the same
assert.equal(
infoAfter.endAt.getTime(),
infoBefore.endAt.getTime(),
`Timeout was shortened: before=${infoBefore.endAt.toISOString()}, after=${infoAfter.endAt.toISOString()}`
)
} finally {
await sbx.kill()
}
}
)
sandboxTest.skipIf(isDebug)(
'connect extends timeout on running sandbox',
async ({ sandbox }) => {
// Get initial info to check endAt
const infoBefore = await sandbox.getInfo()
// Connect with a longer timeout
await sandbox.connect({ timeoutMs: 600_000 })
// Get info after connection
const infoAfter = await sandbox.getInfo()
// The endAt time should have been extended
assert.isTrue(
infoAfter.endAt.getTime() > infoBefore.endAt.getTime(),
`Timeout was not extended: before=${infoBefore.endAt.toISOString()}, after=${infoAfter.endAt.toISOString()}`
)
}
)
@@ -0,0 +1,34 @@
import { assert, test } from 'vitest'
import { Sandbox } from '../../src'
import { template, isDebug } from '../setup.js'
test.skipIf(isDebug)('create', async () => {
const sbx = await Sandbox.create(template, { timeoutMs: 5_000 })
try {
const isRunning = await sbx.isRunning()
// @ts-ignore It's only for testing
assert.isDefined(sbx.envdApi.version)
assert.isTrue(isRunning)
} finally {
await sbx.kill()
}
})
test.skipIf(isDebug)('metadata', async () => {
const metadata = {
'test-key': 'test-value',
}
const sbx = await Sandbox.create(template, { timeoutMs: 5_000, metadata })
try {
const paginator = Sandbox.list()
const sbxs = await paginator.nextItems()
const sbxInfo = sbxs.find((s) => s.sandboxId === sbx.sandboxId)
assert.deepEqual(sbxInfo?.metadata, metadata)
} finally {
await sbx.kill()
}
})
@@ -0,0 +1,94 @@
import { assert } from 'vitest'
import { WriteEntry } from '../../../src/sandbox/filesystem'
import { isDebug, sandboxTest } from '../../setup.js'
sandboxTest(
'write and read file with gzip content encoding',
async ({ sandbox }) => {
const filename = 'test_gzip_write.txt'
const content = 'This is a test file with gzip encoding.'
const info = await sandbox.files.write(filename, content, {
gzip: true,
})
assert.equal(info.name, filename)
assert.equal(info.type, 'file')
assert.equal(info.path, `/home/user/${filename}`)
const readContent = await sandbox.files.read(filename, {
gzip: true,
})
assert.equal(readContent, content)
if (isDebug) {
await sandbox.files.remove(filename)
}
}
)
sandboxTest(
'write with gzip and read without encoding',
async ({ sandbox }) => {
const filename = 'test_gzip_write_plain_read.txt'
const content = 'Written with gzip, read without.'
await sandbox.files.write(filename, content, {
gzip: true,
})
const readContent = await sandbox.files.read(filename)
assert.equal(readContent, content)
if (isDebug) {
await sandbox.files.remove(filename)
}
}
)
sandboxTest('writeFiles with gzip content encoding', async ({ sandbox }) => {
const files: WriteEntry[] = [
{ path: 'gzip_multi_1.txt', data: 'File 1 content' },
{ path: 'gzip_multi_2.txt', data: 'File 2 content' },
{ path: 'gzip_multi_3.txt', data: 'File 3 content' },
]
const infos = await sandbox.files.writeFiles(files, {
gzip: true,
})
assert.equal(infos.length, files.length)
for (let i = 0; i < files.length; i++) {
const readContent = await sandbox.files.read(files[i].path)
assert.equal(readContent, files[i].data)
}
if (isDebug) {
for (const file of files) {
await sandbox.files.remove(file.path)
}
}
})
sandboxTest(
'read file as bytes with gzip content encoding',
async ({ sandbox }) => {
const filename = 'test_gzip_bytes.txt'
const content = 'Binary content with gzip.'
await sandbox.files.write(filename, content)
const readBytes = await sandbox.files.read(filename, {
format: 'bytes',
gzip: true,
})
assert.instanceOf(readBytes, Uint8Array)
const decoded = new TextDecoder().decode(readBytes)
assert.equal(decoded, content)
if (isDebug) {
await sandbox.files.remove(filename)
}
}
)
@@ -0,0 +1,18 @@
import { assert } from 'vitest'
import { sandboxTest } from '../../setup.js'
sandboxTest('file exists', async ({ sandbox }) => {
const filename = 'test_exists.txt'
await sandbox.files.write(filename, 'test')
const exists = await sandbox.files.exists(filename)
assert.isTrue(exists)
})
sandboxTest('file does not exist', async ({ sandbox }) => {
const filename = 'test_does_not_exist.txt'
const exists = await sandbox.files.exists(filename)
assert.isFalse(exists)
})
@@ -0,0 +1,72 @@
import { assert } from 'vitest'
import { expect } from 'vitest'
import { FileNotFoundError } from '../../../src/errors.js'
import { sandboxTest } from '../../setup.js'
sandboxTest('get info of a file', async ({ sandbox }) => {
const filename = 'test_file.txt'
await sandbox.files.write(filename, 'test')
const info = await sandbox.files.getInfo(filename)
const { stdout: currentPath } = await sandbox.commands.run('pwd')
assert.equal(info.name, filename)
assert.equal(info.type, 'file')
assert.equal(info.path, currentPath.trim() + '/' + filename)
assert.equal(info.size, 4)
assert.equal(info.mode, 0o644)
assert.equal(info.permissions, '-rw-r--r--')
assert.equal(info.owner, 'user')
assert.equal(info.group, 'user')
assert.property(info, 'modifiedTime')
})
sandboxTest('get info of a file that does not exist', async ({ sandbox }) => {
const filename = 'test_does_not_exist.txt'
await expect(sandbox.files.getInfo(filename)).rejects.toThrow(
FileNotFoundError
)
})
sandboxTest('get info of a directory', async ({ sandbox }) => {
const dirname = 'test_dir'
await sandbox.files.makeDir(dirname)
const info = await sandbox.files.getInfo(dirname)
const { stdout: currentPath } = await sandbox.commands.run('pwd')
assert.equal(info.name, dirname)
assert.equal(info.type, 'dir')
assert.equal(info.path, currentPath.trim() + '/' + dirname)
assert.isAbove(info.size, 0)
assert.equal(info.mode, 0o755)
assert.equal(info.permissions, 'drwxr-xr-x')
assert.equal(info.owner, 'user')
assert.equal(info.group, 'user')
assert.property(info, 'modifiedTime')
})
sandboxTest(
'get info of a directory that does not exist',
async ({ sandbox }) => {
const dirname = 'test_does_not_exist_dir'
await expect(sandbox.files.getInfo(dirname)).rejects.toThrow(
FileNotFoundError
)
}
)
sandboxTest('get info of a symlink', async ({ sandbox }) => {
const filename = 'test_file.txt'
await sandbox.files.write(filename, 'test')
const symlinkName = 'test_symlink.txt'
await sandbox.commands.run(`ln -s ${filename} ${symlinkName}`)
const info = await sandbox.files.getInfo(symlinkName)
const { stdout: currentPath } = await sandbox.commands.run('pwd')
assert.equal(info.name, symlinkName)
assert.equal(info.symlinkTarget, currentPath.trim() + '/' + filename)
})
@@ -0,0 +1,206 @@
import { assert } from 'vitest'
import { sandboxTest } from '../../setup.js'
const parentDirName = 'test_directory'
sandboxTest('list directory', async ({ sandbox }) => {
const homeDirName = '/home/user'
await sandbox.files.makeDir(parentDirName)
await sandbox.files.makeDir(`${parentDirName}/subdir1`)
await sandbox.files.makeDir(`${parentDirName}/subdir2`)
await sandbox.files.makeDir(`${parentDirName}/subdir1/subdir1_1`)
await sandbox.files.makeDir(`${parentDirName}/subdir1/subdir1_2`)
await sandbox.files.makeDir(`${parentDirName}/subdir2/subdir2_1`)
await sandbox.files.makeDir(`${parentDirName}/subdir2/subdir2_2`)
await sandbox.files.write(`${parentDirName}/file1.txt`, 'Hello, world!')
const testCases = [
{
test_name: 'default depth (1)',
depth: undefined,
expectedLen: 3,
expectedFileNames: ['file1.txt', 'subdir1', 'subdir2'],
expectedFileTypes: ['file', 'dir', 'dir'],
expectedFilePaths: [
`${homeDirName}/${parentDirName}/file1.txt`,
`${homeDirName}/${parentDirName}/subdir1`,
`${homeDirName}/${parentDirName}/subdir2`,
],
},
{
test_name: 'explicit depth 1',
depth: 1,
expectedLen: 3,
expectedFileNames: ['file1.txt', 'subdir1', 'subdir2'],
expectedFileTypes: ['file', 'dir', 'dir'],
expectedFilePaths: [
`${homeDirName}/${parentDirName}/file1.txt`,
`${homeDirName}/${parentDirName}/subdir1`,
`${homeDirName}/${parentDirName}/subdir2`,
],
},
{
test_name: 'explicit depth 2',
depth: 2,
expectedLen: 7,
expectedFileTypes: ['file', 'dir', 'dir', 'dir', 'dir', 'dir', 'dir'],
expectedFileNames: [
'file1.txt',
'subdir1',
'subdir1_1',
'subdir1_2',
'subdir2',
'subdir2_1',
'subdir2_2',
],
expectedFilePaths: [
`${homeDirName}/${parentDirName}/file1.txt`,
`${homeDirName}/${parentDirName}/subdir1`,
`${homeDirName}/${parentDirName}/subdir1/subdir1_1`,
`${homeDirName}/${parentDirName}/subdir1/subdir1_2`,
`${homeDirName}/${parentDirName}/subdir2`,
`${homeDirName}/${parentDirName}/subdir2/subdir2_1`,
`${homeDirName}/${parentDirName}/subdir2/subdir2_2`,
],
},
{
test_name: 'explicit depth 3 (should be the same as depth 2)',
depth: 3,
expectedLen: 7,
expectedFileTypes: ['file', 'dir', 'dir', 'dir', 'dir', 'dir', 'dir'],
expectedFileNames: [
'file1.txt',
'subdir1',
'subdir1_1',
'subdir1_2',
'subdir2',
'subdir2_1',
'subdir2_2',
],
expectedFilePaths: [
`${homeDirName}/${parentDirName}/file1.txt`,
`${homeDirName}/${parentDirName}/subdir1`,
`${homeDirName}/${parentDirName}/subdir1/subdir1_1`,
`${homeDirName}/${parentDirName}/subdir1/subdir1_2`,
`${homeDirName}/${parentDirName}/subdir2`,
`${homeDirName}/${parentDirName}/subdir2/subdir2_1`,
`${homeDirName}/${parentDirName}/subdir2/subdir2_2`,
],
},
]
for (const testCase of testCases) {
const files = await sandbox.files.list(
parentDirName,
testCase.depth !== undefined ? { depth: testCase.depth } : undefined
)
assert.equal(files.length, testCase.expectedLen)
for (let i = 0; i < testCase.expectedFilePaths.length; i++) {
assert.equal(files[i].type, testCase.expectedFileTypes[i])
assert.equal(files[i].name, testCase.expectedFileNames[i])
assert.equal(files[i].path, testCase.expectedFilePaths[i])
}
}
})
sandboxTest('list directory with invalid depth', async ({ sandbox }) => {
await sandbox.files.makeDir(parentDirName)
try {
await sandbox.files.list(parentDirName, { depth: -1 })
assert.fail('Expected error but none was thrown')
} catch (err) {
const expectedErrorMessage = 'depth should be at least one'
assert.ok(
err.message.includes(expectedErrorMessage),
`expected error message to include "${expectedErrorMessage}"`
)
}
})
sandboxTest('file entry details', async ({ sandbox }) => {
const testDir = 'test-file-entry'
const filePath = `${testDir}/test.txt`
const content = 'Hello, World!'
await sandbox.files.makeDir(testDir)
await sandbox.files.write(filePath, content)
const files = await sandbox.files.list(testDir, { depth: 1 })
assert.equal(files.length, 1)
const fileEntry = files[0]
assert.equal(fileEntry.name, 'test.txt')
assert.equal(fileEntry.path, `/home/user/${filePath}`)
assert.equal(fileEntry.type, 'file')
assert.equal(fileEntry.mode, 0o644)
assert.equal(fileEntry.permissions, '-rw-r--r--')
assert.equal(fileEntry.owner, 'user')
assert.equal(fileEntry.group, 'user')
assert.equal(fileEntry.size, content.length)
assert.ok(fileEntry.modifiedTime)
assert.isUndefined(fileEntry.symlinkTarget)
})
sandboxTest('directory entry details', async ({ sandbox }) => {
const testDir = 'test-entry-info'
const subDir = `${testDir}/subdir`
await sandbox.files.makeDir(testDir)
await sandbox.files.makeDir(subDir)
const files = await sandbox.files.list(testDir, { depth: 1 })
assert.equal(files.length, 1)
const dirEntry = files[0]
assert.equal(dirEntry.name, 'subdir')
assert.equal(dirEntry.path, `/home/user/${subDir}`)
assert.equal(dirEntry.type, 'dir')
assert.equal(dirEntry.mode, 0o755)
assert.equal(dirEntry.permissions, 'drwxr-xr-x')
assert.equal(dirEntry.owner, 'user')
assert.equal(dirEntry.group, 'user')
assert.ok(dirEntry.modifiedTime)
})
sandboxTest('mixed entries (files and directories)', async ({ sandbox }) => {
const testDir = 'test-mixed-entries'
const subDir = `${testDir}/subdir`
const filePath = `${testDir}/test.txt`
const content = 'Hello, World!'
await sandbox.files.makeDir(testDir)
await sandbox.files.makeDir(subDir)
await sandbox.files.write(filePath, content)
const files = await sandbox.files.list(testDir, { depth: 1 })
assert.equal(files.length, 2)
// Create a map of entries by name for easier verification
const entries = new Map(files.map((entry) => [entry.name, entry]))
// Verify directory entry
const dirEntry = entries.get('subdir')
assert.ok(dirEntry)
assert.equal(dirEntry!.path, `/home/user/${subDir}`)
assert.equal(dirEntry!.type, 'dir')
assert.equal(dirEntry!.mode, 0o755)
assert.equal(dirEntry!.permissions, 'drwxr-xr-x')
assert.equal(dirEntry!.owner, 'user')
assert.equal(dirEntry!.group, 'user')
assert.ok(dirEntry!.modifiedTime)
// Verify file entry
const fileEntry = entries.get('test.txt')
assert.ok(fileEntry)
assert.equal(fileEntry!.path, `/home/user/${filePath}`)
assert.equal(fileEntry!.type, 'file')
assert.equal(fileEntry!.mode, 0o644)
assert.equal(fileEntry!.permissions, '-rw-r--r--')
assert.equal(fileEntry!.owner, 'user')
assert.equal(fileEntry!.group, 'user')
assert.equal(fileEntry!.size, content.length)
assert.ok(fileEntry!.modifiedTime)
})
@@ -0,0 +1,30 @@
import { assert } from 'vitest'
import { sandboxTest } from '../../setup.js'
sandboxTest('make directory', async ({ sandbox }) => {
const dirName = 'test_directory1'
await sandbox.files.makeDir(dirName)
const exists = await sandbox.files.exists(dirName)
assert.isTrue(exists)
})
sandboxTest('make existing directory', async ({ sandbox }) => {
const dirName = 'test_directory2'
await sandbox.files.makeDir(dirName)
const exists = await sandbox.files.exists(dirName)
assert.isTrue(exists)
const exists2 = await sandbox.files.makeDir(dirName)
assert.isFalse(exists2)
})
sandboxTest('make nested directory', async ({ sandbox }) => {
const nestedDirName = 'test_directory3/nested_directory'
await sandbox.files.makeDir(nestedDirName)
const exists = await sandbox.files.exists(nestedDirName)
assert.isTrue(exists)
})
@@ -0,0 +1,194 @@
import { assert, expect } from 'vitest'
import { WriteEntry } from '../../../src/sandbox/filesystem'
import { InvalidArgumentError } from '../../../src/errors.js'
import { isDebug, sandboxTest } from '../../setup.js'
sandboxTest('write file with metadata', async ({ sandbox }) => {
const filename = 'test_metadata.txt'
const content = 'This is a test file with metadata.'
const metadata = { author: 'mish', purpose: 'upload' }
const info = await sandbox.files.write(filename, content, { metadata })
assert.isFalse(Array.isArray(info))
assert.deepEqual(info.metadata, metadata)
// Metadata is persisted and surfaced on subsequent reads.
const stat = await sandbox.files.getInfo(filename)
assert.deepEqual(stat.metadata, metadata)
if (isDebug) {
await sandbox.files.remove(filename)
}
})
sandboxTest(
'write file with metadata using octet-stream',
async ({ sandbox }) => {
const filename = 'test_metadata_octet.txt'
const content = 'This is a test file with metadata.'
const metadata = { author: 'mish', purpose: 'upload' }
const info = await sandbox.files.write(filename, content, {
metadata,
useOctetStream: true,
})
assert.deepEqual(info.metadata, metadata)
const stat = await sandbox.files.getInfo(filename)
assert.deepEqual(stat.metadata, metadata)
if (isDebug) {
await sandbox.files.remove(filename)
}
}
)
sandboxTest('write file without metadata', async ({ sandbox }) => {
const filename = 'test_no_metadata.txt'
const info = await sandbox.files.write(filename, 'no metadata here')
assert.isUndefined(info.metadata)
const stat = await sandbox.files.getInfo(filename)
assert.isUndefined(stat.metadata)
if (isDebug) {
await sandbox.files.remove(filename)
}
})
sandboxTest(
'writeFiles applies metadata to every file',
async ({ sandbox }) => {
// The same metadata is applied to every file in the upload.
const metadata = { source: 'test-suite' }
const files: WriteEntry[] = [
{ path: 'metadata_multi_1.txt', data: 'File 1' },
{ path: 'metadata_multi_2.txt', data: 'File 2' },
]
const infos = await sandbox.files.writeFiles(files, { metadata })
assert.equal(infos.length, files.length)
for (const info of infos) {
assert.deepEqual(info.metadata, metadata)
const stat = await sandbox.files.getInfo(info.path)
assert.deepEqual(stat.metadata, metadata)
}
if (isDebug) {
for (const file of files) {
await sandbox.files.remove(file.path)
}
}
}
)
sandboxTest('metadata is surfaced when listing', async ({ sandbox }) => {
const dirname = 'metadata_list_dir'
const filename = 'listed.txt'
const metadata = { tag: 'listed' }
await sandbox.files.makeDir(dirname)
await sandbox.files.write(`${dirname}/${filename}`, 'content', { metadata })
const entries = await sandbox.files.list(dirname)
const entry = entries.find((e) => e.name === filename)
assert.isDefined(entry)
assert.deepEqual(entry?.metadata, metadata)
if (isDebug) {
await sandbox.files.remove(dirname)
}
})
sandboxTest('metadata is surfaced after rename', async ({ sandbox }) => {
const oldPath = 'metadata_rename_old.txt'
const newPath = 'metadata_rename_new.txt'
const metadata = { stage: 'renamed' }
await sandbox.files.write(oldPath, 'content', { metadata })
const info = await sandbox.files.rename(oldPath, newPath)
assert.deepEqual(info.metadata, metadata)
if (isDebug) {
await sandbox.files.remove(newPath)
}
})
sandboxTest('overwriting a file clears stale metadata', async ({ sandbox }) => {
const filename = 'metadata_overwrite.txt'
await sandbox.files.write(filename, 'first', {
metadata: { author: 'mish' },
})
// Overwriting without metadata removes the previously stored metadata.
const info = await sandbox.files.write(filename, 'second')
assert.isUndefined(info.metadata)
const stat = await sandbox.files.getInfo(filename)
assert.isUndefined(stat.metadata)
if (isDebug) {
await sandbox.files.remove(filename)
}
})
sandboxTest(
'metadata set via xattrs is surfaced in getInfo',
async ({ sandbox }) => {
const filename = 'metadata_xattr.txt'
await sandbox.files.write(filename, 'content')
const { stdout: filePath } = await sandbox.commands.run(
`realpath ${filename}`
)
// Set an xattr directly in the `user.e2b.` namespace (out-of-band, not via
// the SDK upload); it should surface as metadata (with the namespace prefix
// stripped) when reading the file info.
await sandbox.commands.run(
`python3 -c "import os; os.setxattr('${filePath.trim()}', 'user.e2b.author', b'mish')"`
)
const info = await sandbox.files.getInfo(filename)
assert.deepEqual(info.metadata, { author: 'mish' })
if (isDebug) {
await sandbox.files.remove(filename)
}
}
)
sandboxTest('rejects invalid metadata', async ({ sandbox }) => {
const filename = 'invalid_metadata.txt'
// Key with a space is not a valid HTTP header token.
await expect(
sandbox.files.write(filename, 'x', { metadata: { 'bad key': 'value' } })
).rejects.toThrow(InvalidArgumentError)
// Empty key.
await expect(
sandbox.files.write(filename, 'x', { metadata: { '': 'value' } })
).rejects.toThrow(InvalidArgumentError)
// Value with a non-printable / non-ASCII character.
await expect(
sandbox.files.write(filename, 'x', { metadata: { good: 'bad\nvalue' } })
).rejects.toThrow(InvalidArgumentError)
// Trailing newline in value or key.
await expect(
sandbox.files.write(filename, 'x', { metadata: { good: 'value\n' } })
).rejects.toThrow(InvalidArgumentError)
await expect(
sandbox.files.write(filename, 'x', { metadata: { 'key\n': 'value' } })
).rejects.toThrow(InvalidArgumentError)
// The file must not have been created by a rejected write.
assert.isFalse(await sandbox.files.exists(filename))
})
@@ -0,0 +1,126 @@
import { expect, assert } from 'vitest'
import { FileNotFoundError, NotFoundError } from '../../../src'
import { sandboxTest } from '../../setup.js'
sandboxTest('read file', async ({ sandbox }) => {
const filename = 'test_read.txt'
const content = 'Hello, world!'
await sandbox.files.write(filename, content)
const readContent = await sandbox.files.read(filename)
assert.equal(readContent, content)
})
sandboxTest('read non-existing file', async ({ sandbox }) => {
const filename = 'non_existing_file.txt'
await expect(sandbox.files.read(filename)).rejects.toThrowError(
FileNotFoundError
)
})
sandboxTest(
'read non-existing file catches with deprecated NotFoundError',
async ({ sandbox }) => {
const filename = 'non_existing_file.txt'
await expect(sandbox.files.read(filename)).rejects.toThrowError(
NotFoundError
)
}
)
sandboxTest('read file as stream', async ({ sandbox }) => {
const filename = 'test_read_stream.txt'
const content = 'Streamed read content. '.repeat(10_000)
await sandbox.files.write(filename, content)
const stream = await sandbox.files.read(filename, { format: 'stream' })
const chunks: Uint8Array[] = []
for await (const chunk of stream as unknown as AsyncIterable<Uint8Array>) {
chunks.push(chunk)
}
const readContent = Buffer.concat(chunks).toString('utf-8')
assert.equal(readContent, content)
})
sandboxTest('read non-existing file as stream', async ({ sandbox }) => {
const filename = 'non_existing_file.txt'
await expect(
sandbox.files.read(filename, { format: 'stream' })
).rejects.toThrowError(FileNotFoundError)
})
sandboxTest('read empty file in all formats', async ({ sandbox }) => {
const filename = 'empty-file-formats.txt'
await sandbox.commands.run(`touch ${filename}`)
const text = await sandbox.files.read(filename, { format: 'text' })
expect(text).toBe('')
const bytes = await sandbox.files.read(filename, { format: 'bytes' })
expect(bytes).toBeInstanceOf(Uint8Array)
expect(bytes.length).toBe(0)
const blob = await sandbox.files.read(filename, { format: 'blob' })
expect(blob).toBeInstanceOf(Blob)
expect(blob.size).toBe(0)
const stream = await sandbox.files.read(filename, { format: 'stream' })
expect(stream).toBeInstanceOf(ReadableStream)
const chunks: Uint8Array[] = []
for await (const chunk of stream as unknown as AsyncIterable<Uint8Array>) {
chunks.push(chunk)
}
expect(Buffer.concat(chunks).length).toBe(0)
})
sandboxTest('read file as stream', async ({ sandbox }) => {
const filename = 'test_read_stream.txt'
const content = 'Streamed read content. '.repeat(10_000)
await sandbox.files.write(filename, content)
const stream = await sandbox.files.read(filename, { format: 'stream' })
const chunks: Uint8Array[] = []
for await (const chunk of stream as unknown as AsyncIterable<Uint8Array>) {
chunks.push(chunk)
}
const readContent = Buffer.concat(chunks).toString('utf-8')
assert.equal(readContent, content)
})
sandboxTest('read non-existing file as stream', async ({ sandbox }) => {
const filename = 'non_existing_file.txt'
await expect(
sandbox.files.read(filename, { format: 'stream' })
).rejects.toThrowError(FileNotFoundError)
})
sandboxTest('read empty file in all formats', async ({ sandbox }) => {
const filename = 'empty-file-formats.txt'
await sandbox.commands.run(`touch ${filename}`)
const text = await sandbox.files.read(filename, { format: 'text' })
expect(text).toBe('')
const bytes = await sandbox.files.read(filename, { format: 'bytes' })
expect(bytes).toBeInstanceOf(Uint8Array)
expect(bytes.length).toBe(0)
const blob = await sandbox.files.read(filename, { format: 'blob' })
expect(blob).toBeInstanceOf(Blob)
expect(blob.size).toBe(0)
const stream = await sandbox.files.read(filename, { format: 'stream' })
expect(stream).toBeInstanceOf(ReadableStream)
const chunks: Uint8Array[] = []
for await (const chunk of stream as unknown as AsyncIterable<Uint8Array>) {
chunks.push(chunk)
}
expect(Buffer.concat(chunks).length).toBe(0)
})
@@ -0,0 +1,20 @@
import { assert } from 'vitest'
import { sandboxTest } from '../../setup.js'
sandboxTest('remove file', async ({ sandbox }) => {
const filename = 'test_remove.txt'
const content = 'This file will be removed.'
await sandbox.files.write(filename, content)
await sandbox.files.remove(filename)
const exists = await sandbox.files.exists(filename)
assert.isFalse(exists)
})
sandboxTest('remove non-existing file', async ({ sandbox }) => {
const filename = 'non_existing_file.txt'
await sandbox.files.remove(filename)
})
@@ -0,0 +1,32 @@
import { assert, expect } from 'vitest'
import { FileNotFoundError } from '../../../src'
import { sandboxTest } from '../../setup.js'
sandboxTest('rename file', async ({ sandbox }) => {
const oldFilename = 'test_rename_old.txt'
const newFilename = 'test_rename_new.txt'
const content = 'This file will be renamed.'
await sandbox.files.write(oldFilename, content)
const info = await sandbox.files.rename(oldFilename, newFilename)
assert.equal(info.name, newFilename)
assert.equal(info.type, 'file')
assert.equal(info.path, `/home/user/${newFilename}`)
const existsOld = await sandbox.files.exists(oldFilename)
const existsNew = await sandbox.files.exists(newFilename)
assert.isFalse(existsOld)
assert.isTrue(existsNew)
const readContent = await sandbox.files.read(newFilename)
assert.equal(readContent, content)
})
sandboxTest('rename non-existing file', async ({ sandbox }) => {
const oldFilename = 'non_existing_file.txt'
const newFilename = 'new_non_existing_file.txt'
await expect(
sandbox.files.rename(oldFilename, newFilename)
).rejects.toThrowError(FileNotFoundError)
})
@@ -0,0 +1,152 @@
import { assert, describe } from 'vitest'
import { sandboxTest, isDebug } from '../../setup'
describe('file signing', () => {
sandboxTest.scoped({
sandboxOpts: {
secure: true,
},
})
sandboxTest.skipIf(isDebug)(
'test access file with expired signing',
async ({ sandbox }) => {
await sandbox.files.write('hello.txt', 'hello world')
const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt', {
useSignatureExpiration: -10_000,
})
const res = await fetch(fileUrlWithSigning)
const resBody = await res.text()
const resStatus = res.status
assert.equal(resStatus, 401)
assert.deepEqual(JSON.parse(resBody), {
code: 401,
message: 'signature is already expired',
})
}
)
sandboxTest.skipIf(isDebug)(
'test access file with valid signing',
async ({ sandbox }) => {
await sandbox.files.write('hello.txt', 'hello world')
const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt', {
useSignatureExpiration: 10_000,
})
const res = await fetch(fileUrlWithSigning)
const resBody = await res.text()
const resStatus = res.status
assert.equal(resStatus, 200)
assert.equal(resBody, 'hello world')
}
)
sandboxTest.skipIf(isDebug)(
'test access file with valid signing as root',
async ({ sandbox }) => {
await sandbox.files.write('hello.txt', 'hello world', { user: 'root' })
const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt', {
user: 'root',
useSignatureExpiration: 10_000,
})
const res = await fetch(fileUrlWithSigning)
const resBody = await res.text()
const resStatus = res.status
assert.equal(resStatus, 200)
assert.equal(resBody, 'hello world')
}
)
sandboxTest.skipIf(isDebug)(
'test upload file with valid signing',
async ({ sandbox }) => {
const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', {
useSignatureExpiration: 10_000,
})
const form = new FormData()
form.append('file', 'file content')
const res = await fetch(fileUrlWithSigning, {
method: 'POST',
body: form,
})
const resBody = await res.text()
const resStatus = res.status
assert.equal(resStatus, 200)
assert.deepEqual(JSON.parse(resBody), [
{ name: 'hello.txt', path: '/home/user/hello.txt', type: 'file' },
])
}
)
sandboxTest.skipIf(isDebug)(
'test upload file with valid signing as root user',
async ({ sandbox }) => {
const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', {
user: 'root',
useSignatureExpiration: 10_000,
})
const form = new FormData()
form.append('file', 'file content')
const res = await fetch(fileUrlWithSigning, {
method: 'POST',
body: form,
})
const resBody = await res.text()
const resStatus = res.status
assert.equal(resStatus, 200)
assert.deepEqual(JSON.parse(resBody), [
{ name: 'hello.txt', path: '/root/hello.txt', type: 'file' },
])
}
)
sandboxTest.skipIf(isDebug)(
'test upload file with invalid signing',
async ({ sandbox }) => {
const fileUrlWithSigning = await sandbox.uploadUrl('hello.txt', {
useSignatureExpiration: -100_000,
})
const form = new FormData()
form.append('file', 'file content')
const res = await fetch(fileUrlWithSigning, {
method: 'POST',
body: form,
})
const resBody = await res.text()
const resStatus = res.status
assert.equal(resStatus, 401)
assert.deepEqual(JSON.parse(resBody), {
code: 401,
message: 'signature is already expired',
})
}
)
sandboxTest.skipIf(isDebug)(
'test command run with secured sbx',
async ({ sandbox }) => {
const response = await sandbox.commands.run('echo Hello World!')
assert.equal(response.stdout, 'Hello World!\n')
}
)
})
@@ -0,0 +1,236 @@
import { expect, onTestFinished } from 'vitest'
import { isDebug, sandboxTest } from '../../setup.js'
import {
FileNotFoundError,
FilesystemEvent,
FilesystemEventType,
FileType,
SandboxError,
} from '../../../src'
sandboxTest('watch directory changes', async ({ sandbox }) => {
const dirname = 'test_watch_dir'
const filename = 'test_watch.txt'
const content = 'This file will be watched.'
const newContent = 'This file has been modified.'
await sandbox.files.makeDir(dirname)
await sandbox.files.write(`${dirname}/${filename}`, content)
let trigger: () => void
const eventPromise = new Promise<void>((resolve) => {
trigger = resolve
})
const handle = await sandbox.files.watchDir(dirname, async (event) => {
if (event.type === FilesystemEventType.WRITE && event.name === filename) {
trigger()
}
})
await sandbox.files.write(`${dirname}/${filename}`, newContent)
await eventPromise
await handle.stop()
})
sandboxTest('watch recursive directory changes', async ({ sandbox }) => {
const dirname = 'test_recursive_watch_dir'
const nestedDirname = 'test_nested_watch_dir'
const filename = 'test_watch.txt'
const content = 'This file will be watched.'
const newContent = 'This file has been modified.'
await sandbox.files.makeDir(`${dirname}/${nestedDirname}`)
if (isDebug) {
onTestFinished(() => sandbox.files.remove(dirname))
}
await sandbox.files.write(`${dirname}/${nestedDirname}/${filename}`, content)
let trigger: () => void
const eventPromise = new Promise<void>((resolve) => {
trigger = resolve
})
const expectedFileName = `${nestedDirname}/${filename}`
const handle = await sandbox.files.watchDir(
dirname,
async (event) => {
if (
event.type === FilesystemEventType.WRITE &&
event.name === expectedFileName
) {
trigger()
}
},
{
recursive: true,
}
)
await sandbox.files.write(
`${dirname}/${nestedDirname}/${filename}`,
newContent
)
await eventPromise
await handle.stop()
})
sandboxTest(
'watch recursive directory after nested folder addition',
async ({ sandbox }) => {
const dirname = 'test_recursive_watch_dir_add'
const nestedDirname = 'test_nested_watch_dir'
const filename = 'test_watch.txt'
const content = 'This file will be watched.'
await sandbox.files.makeDir(dirname)
if (isDebug) {
onTestFinished(() => sandbox.files.remove(dirname))
}
let triggerFile: () => void
let triggerFolder: () => void
const eventFilePromise = new Promise<void>((resolve) => {
triggerFile = resolve
})
const eventFolderPromise = new Promise<void>((resolve) => {
triggerFolder = resolve
})
const expectedFileName = `${nestedDirname}/${filename}`
const handle = await sandbox.files.watchDir(
dirname,
async (event) => {
if (
event.type === FilesystemEventType.WRITE &&
event.name === expectedFileName
) {
triggerFile()
} else if (
event.type === FilesystemEventType.CREATE &&
event.name === nestedDirname
) {
triggerFolder()
}
},
{
recursive: true,
}
)
await sandbox.files.makeDir(`${dirname}/${nestedDirname}`)
await eventFolderPromise
await sandbox.files.write(
`${dirname}/${nestedDirname}/${filename}`,
content
)
await eventFilePromise
await handle.stop()
}
)
sandboxTest('watch directory changes with entry info', async ({ sandbox }) => {
const dirname = 'test_watch_dir_entry'
const filename = 'test_watch.txt'
const content = 'This file will be watched.'
const newContent = 'This file has been modified.'
await sandbox.files.makeDir(dirname)
await sandbox.files.write(`${dirname}/${filename}`, content)
let resolveEvent: (event: FilesystemEvent) => void
const eventPromise = new Promise<FilesystemEvent>((resolve) => {
resolveEvent = resolve
})
const handle = await sandbox.files.watchDir(
dirname,
async (event) => {
if (event.type === FilesystemEventType.WRITE && event.name === filename) {
resolveEvent(event)
}
},
{ includeEntry: true }
)
await sandbox.files.write(`${dirname}/${filename}`, newContent)
const event = await eventPromise
// The entry is populated best-effort for events where the path still exists.
expect(event.entry).toBeDefined()
expect(event.entry?.name).toBe(filename)
expect(event.entry?.path).toBe(`/home/user/${dirname}/${filename}`)
expect(event.entry?.type).toBe(FileType.FILE)
await handle.stop()
})
sandboxTest(
'watch directory changes with network mounts allowed',
async ({ sandbox }) => {
const dirname = 'test_watch_dir_network_mounts'
const filename = 'test_watch.txt'
const content = 'This file will be watched.'
const newContent = 'This file has been modified.'
await sandbox.files.makeDir(dirname)
await sandbox.files.write(`${dirname}/${filename}`, content)
let trigger: () => void
const eventPromise = new Promise<void>((resolve) => {
trigger = resolve
})
// The flag only lifts the network-mount restriction — watching a regular
// directory must work the same with it enabled.
const handle = await sandbox.files.watchDir(
dirname,
async (event) => {
if (
event.type === FilesystemEventType.WRITE &&
event.name === filename
) {
trigger()
}
},
{ allowNetworkMounts: true }
)
await sandbox.files.write(`${dirname}/${filename}`, newContent)
await eventPromise
await handle.stop()
}
)
sandboxTest('watch non-existing directory', async ({ sandbox }) => {
const dirname = 'non_existing_watch_dir'
await expect(sandbox.files.watchDir(dirname, () => {})).rejects.toThrowError(
FileNotFoundError
)
})
sandboxTest('watch file', async ({ sandbox }) => {
const filename = 'test_watch.txt'
const content = 'This file will be watched.'
await sandbox.files.write(filename, content)
await expect(sandbox.files.watchDir(filename, () => {})).rejects.toThrowError(
SandboxError
)
})
@@ -0,0 +1,134 @@
import { describe, expect, it, vi } from 'vitest'
import { EventType } from '../../../src/envd/filesystem/filesystem_pb'
import {
FilesystemEventType,
WatchHandle,
} from '../../../src/sandbox/filesystem/watchHandle'
function filesystemEvent(name: string, type: EventType = EventType.WRITE) {
return {
event: {
case: 'filesystem' as const,
value: { name, type, entry: undefined },
},
}
}
function events(items: ReturnType<typeof filesystemEvent>[]) {
async function* gen() {
for (const item of items) {
yield item as any
}
}
return gen()
}
describe('WatchHandle', () => {
it('awaits an async onEvent before finishing and firing onExit', async () => {
let callbackStarted = false
let releaseCallback: (() => void) | undefined
const callbackBlocked = new Promise<void>((resolve) => {
releaseCallback = resolve
})
const onEvent = async () => {
callbackStarted = true
await callbackBlocked
}
let exitCalled = false
const onExit = () => {
exitCalled = true
}
new WatchHandle(
() => {},
events([filesystemEvent('a.txt')]),
onEvent,
onExit
)
await vi.waitFor(() => {
expect(callbackStarted).toBe(true)
})
// onExit must not fire while onEvent is still pending.
expect(exitCalled).toBe(false)
releaseCallback?.()
await vi.waitFor(() => {
expect(exitCalled).toBe(true)
})
})
it('routes a rejecting async onEvent to onExit and stops the watch', async () => {
const error = new Error('callback failed')
let stopped = false
let exitErr: Error | undefined | 'unset' = 'unset'
new WatchHandle(
() => {
stopped = true
},
events([filesystemEvent('a.txt')]),
async () => {
throw error
},
(err) => {
exitErr = err ?? undefined
}
)
await vi.waitFor(() => {
expect(exitErr).not.toBe('unset')
})
expect(exitErr).toBe(error)
expect(stopped).toBe(true)
})
it('calls onExit with no argument when the stream ends cleanly', async () => {
const received: FilesystemEventType[] = []
let exitArgs: unknown[] | undefined
new WatchHandle(
() => {},
events([filesystemEvent('a.txt')]),
(event) => {
received.push(event.type)
},
(...args: unknown[]) => {
exitArgs = args
}
)
await vi.waitFor(() => {
expect(exitArgs).toBeDefined()
})
// No error is passed on a clean exit — onExit is called with zero args.
expect(exitArgs).toEqual([])
expect(received).toEqual([FilesystemEventType.WRITE])
})
it('does not let a throwing onExit become an unhandled rejection', async () => {
let stopped = false
new WatchHandle(
() => {
stopped = true
},
events([filesystemEvent('a.txt')]),
() => {},
() => {
throw new Error('onExit failed')
}
)
// handleStop runs after onExit, so observing the stop confirms the loop
// ran the throwing onExit to completion. A leaked rejection would fail the
// test run instead.
await vi.waitFor(() => {
expect(stopped).toBe(true)
})
})
})
@@ -0,0 +1,366 @@
import path from 'path'
import { assert } from 'vitest'
import { WriteEntry } from '../../../src/sandbox/filesystem'
import { isDebug, sandboxTest } from '../../setup.js'
sandboxTest('write file', async ({ sandbox }) => {
const filename = 'test_write.txt'
const content = 'This is a test file.'
// Attempt to write with undefined path and content
await sandbox.files
// @ts-ignore
.write(undefined, content)
.then((e) => {
assert.isUndefined(e)
})
.catch((err) => {
assert.instanceOf(err, Error)
assert.include(err.message, 'Path or files are required')
})
const info = await sandbox.files.write(filename, content)
assert.isFalse(Array.isArray(info))
assert.equal(info.name, filename)
assert.equal(info.type, 'file')
assert.equal(info.path, `/home/user/${filename}`)
const exists = await sandbox.files.exists(filename)
assert.isTrue(exists)
const readContent = await sandbox.files.read(filename)
assert.equal(readContent, content)
if (isDebug) {
await sandbox.files.remove(filename)
}
})
sandboxTest('write multiple files', async ({ sandbox }) => {
const numTestFiles = 10
// Attempt to write with empty files array
const emptyInfo = await sandbox.files.write([])
assert.isTrue(Array.isArray(emptyInfo))
assert.equal(emptyInfo.length, 0)
// Attempt to write with undefined path and file array
await sandbox.files
// @ts-ignore
.write(undefined, [
{ path: 'one_test_file.txt', data: 'This is a test file.' },
])
.then((e) => {
assert.isUndefined(e)
})
.catch((err) => {
assert.instanceOf(err, Error)
assert.include(err.message, 'Path or files are required')
})
// Attempt to write with path and file array
await sandbox.files
// @ts-ignore
.write('/path/to/file', [
{ path: 'one_test_file.txt', data: 'This is a test file.' },
])
.then((e) => {
assert.isUndefined(e)
})
.catch((err) => {
assert.instanceOf(err, Error)
assert.include(
err.message,
'Cannot specify both path and array of files. You have to specify either path and data for a single file or an array for multiple files.'
)
})
// Attempt to write with one file in array
const info = await sandbox.files.write([
{ path: 'one_test_file.txt', data: 'This is a test file.' },
])
assert.isTrue(Array.isArray(info))
assert.equal(info[0].name, 'one_test_file.txt')
assert.equal(info[0].type, 'file')
assert.equal(info[0].path, '/home/user/one_test_file.txt')
// Attempt to write with multiple files in array
const files: WriteEntry[] = []
for (let i = 0; i < numTestFiles; i++) {
let path = ''
if (i % 2 == 0) {
path = `/${i}/multi_test_file${i}.txt`
} else {
path = `/home/user/multi_test_file${i}.txt`
}
files.push({
path: path,
data: `This is a test file ${i}.`,
})
}
const infos = await sandbox.files.write(files)
assert.isTrue(Array.isArray(infos))
assert.equal(infos.length, files.length)
// Attempt to write with multiple files in array
for (let i = 0; i < files.length; i++) {
const file = files[i]
const info = infos[i]
assert.equal(info.name, path.basename(file.path))
assert.equal(info.path, file.path)
assert.equal(info.type, 'file')
const exists = await sandbox.files.exists(file.path)
assert.isTrue(exists)
const readContent = await sandbox.files.read(file.path)
assert.equal(readContent, file.data)
}
if (isDebug) {
for (const file of files) {
await sandbox.files.remove(file.path)
}
}
})
sandboxTest('write file', async ({ sandbox }) => {
const filename = 'test_write.txt'
const content = 'This is a test file.'
const info = await sandbox.files.write(filename, content)
assert.isFalse(Array.isArray(info))
assert.equal(info.name, filename)
assert.equal(info.type, 'file')
assert.equal(info.path, `/home/user/${filename}`)
const exists = await sandbox.files.exists(filename)
assert.isTrue(exists)
const readContent = await sandbox.files.read(filename)
assert.equal(readContent, content)
if (isDebug) {
await sandbox.files.remove(filename)
}
})
sandboxTest('overwrite file', async ({ sandbox }) => {
const filename = 'test_overwrite.txt'
const initialContent = 'Initial content.'
const newContent = 'New content.'
await sandbox.files.write(filename, initialContent)
await sandbox.files.write(filename, newContent)
const readContent = await sandbox.files.read(filename)
assert.equal(readContent, newContent)
if (isDebug) {
await sandbox.files.remove(filename)
}
})
sandboxTest('write to non-existing directory', async ({ sandbox }) => {
const filename = 'non_existing_dir/test_write.txt'
const content = 'This should succeed too.'
await sandbox.files.write(filename, content)
const exists = await sandbox.files.exists(filename)
assert.isTrue(exists)
const readContent = await sandbox.files.read(filename)
assert.equal(readContent, content)
if (isDebug) {
await sandbox.files.remove(filename)
}
})
sandboxTest('writeFiles with empty array', async ({ sandbox }) => {
const emptyInfo = await sandbox.files.writeFiles([])
assert.isTrue(Array.isArray(emptyInfo))
assert.equal(emptyInfo.length, 0)
})
sandboxTest('writeFiles with multiple files', async ({ sandbox }) => {
const numTestFiles = 10
const files: WriteEntry[] = []
for (let i = 0; i < numTestFiles; i++) {
const filePath = `writefiles_test_${i}.txt`
files.push({
path: filePath,
data: `This is a test file ${i}.`,
})
}
const infos = await sandbox.files.writeFiles(files)
assert.isTrue(Array.isArray(infos))
assert.equal(infos.length, files.length)
for (let i = 0; i < files.length; i++) {
const file = files[i]
const info = infos[i]
assert.equal(info.name, path.basename(file.path))
assert.equal(info.path, `/home/user/${file.path}`)
assert.equal(info.type, 'file')
const exists = await sandbox.files.exists(info.path)
assert.isTrue(exists)
const readContent = await sandbox.files.read(info.path)
assert.equal(readContent, file.data)
}
if (isDebug) {
for (const file of files) {
await sandbox.files.remove(file.path)
}
}
})
sandboxTest('writeFiles with different data types', async ({ sandbox }) => {
const textData = 'Text string data'
const arrayBufferData = new TextEncoder().encode('ArrayBuffer data').buffer
const blobData = new Blob(['Blob data'], { type: 'text/plain' })
const streamContent = 'ReadableStream data'
const encoder = new TextEncoder()
const streamData = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(streamContent))
controller.close()
},
})
const files: WriteEntry[] = [
{ path: 'writefiles_text.txt', data: textData },
{ path: 'writefiles_arraybuffer.txt', data: arrayBufferData },
{ path: 'writefiles_blob.txt', data: blobData },
{ path: 'writefiles_stream.txt', data: streamData },
]
const infos = await sandbox.files.writeFiles(files)
assert.equal(infos.length, 4)
// Verify text file
const textContent = await sandbox.files.read('writefiles_text.txt')
assert.equal(textContent, textData)
// Verify ArrayBuffer file
const arrayBufferContent = await sandbox.files.read(
'writefiles_arraybuffer.txt'
)
assert.equal(arrayBufferContent, 'ArrayBuffer data')
// Verify Blob file
const blobContent = await sandbox.files.read('writefiles_blob.txt')
assert.equal(blobContent, 'Blob data')
// Verify ReadableStream file
const streamFileContent = await sandbox.files.read('writefiles_stream.txt')
assert.equal(streamFileContent, streamContent)
if (isDebug) {
for (const file of files) {
await sandbox.files.remove(file.path)
}
}
})
sandboxTest('writeFiles creates parent directories', async ({ sandbox }) => {
const files: WriteEntry[] = [
{
path: 'writefiles_nested_dir/nested/file1.txt',
data: 'Content in nested directory',
},
]
const infos = await sandbox.files.writeFiles(files)
assert.equal(infos.length, 1)
assert.equal(
infos[0].path,
'/home/user/writefiles_nested_dir/nested/file1.txt'
)
const exists = await sandbox.files.exists(infos[0].path)
assert.isTrue(exists)
const content = await sandbox.files.read(infos[0].path)
assert.equal(content, 'Content in nested directory')
if (isDebug) {
await sandbox.files.remove(files[0].path)
}
})
sandboxTest('writeFiles overwrites existing files', async ({ sandbox }) => {
const filename = 'writefiles_overwrite.txt'
const initialContent = 'Initial content'
const newContent = 'New content'
// Write initial file
await sandbox.files.writeFiles([{ path: filename, data: initialContent }])
let readContent = await sandbox.files.read(filename)
assert.equal(readContent, initialContent)
// Overwrite with new content
await sandbox.files.writeFiles([{ path: filename, data: newContent }])
readContent = await sandbox.files.read(filename)
assert.equal(readContent, newContent)
if (isDebug) {
await sandbox.files.remove(filename)
}
})
sandboxTest(
'write ReadableStream with octet stream upload',
async ({ sandbox }) => {
const filename = 'test_write_octet_stream.bin'
const content = 'Streamed octet-stream upload. '.repeat(10_000)
const stream = new Blob([content]).stream()
const info = await sandbox.files.write(filename, stream, {
useOctetStream: true,
})
assert.equal(info.path, `/home/user/${filename}`)
const readContent = await sandbox.files.read(filename)
assert.equal(readContent, content)
if (isDebug) {
await sandbox.files.remove(filename)
}
}
)
sandboxTest(
'write ReadableStream with octet stream upload and gzip',
async ({ sandbox }) => {
const filename = 'test_write_octet_stream_gzip.bin'
const content = 'Streamed gzipped octet-stream upload. '.repeat(10_000)
const stream = new Blob([content]).stream()
const info = await sandbox.files.write(filename, stream, {
useOctetStream: true,
gzip: true,
})
assert.equal(info.path, `/home/user/${filename}`)
const readContent = await sandbox.files.read(filename)
assert.equal(readContent, content)
if (isDebug) {
await sandbox.files.remove(filename)
}
}
)
@@ -0,0 +1,24 @@
import { expect } from 'vitest'
import { sandboxTest } from '../../setup.js'
import { cleanupBaseDir, createBaseDir, createRepo } from './helpers.js'
sandboxTest('git add stages files', async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepo(sandbox, baseDir)
await sandbox.files.write(`${repoPath}/README.md`, 'hello\n')
await sandbox.git.add(repoPath)
const status = await sandbox.git.status(repoPath)
const entry = status.fileStatus.find(
(file: any) => file.name === 'README.md'
)
expect(entry?.status).toBe('added')
expect(entry?.staged).toBe(true)
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
})
@@ -0,0 +1,82 @@
import { expect } from 'vitest'
import { sandboxTest } from '../../setup.js'
import {
cleanupBaseDir,
createBaseDir,
createRepoWithCommit,
} from './helpers.js'
sandboxTest('git branches lists current and feature', async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepoWithCommit(sandbox, baseDir)
await sandbox.commands.run(`git -C "${repoPath}" branch feature`)
const branches = await sandbox.git.branches(repoPath)
expect(branches.currentBranch).toBe('main')
expect(branches.branches).toContain('main')
expect(branches.branches).toContain('feature')
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
})
sandboxTest('git checkoutBranch switches branch', async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepoWithCommit(sandbox, baseDir)
await sandbox.commands.run(`git -C "${repoPath}" branch feature`)
await sandbox.git.checkoutBranch(repoPath, 'feature')
const head = (
await sandbox.commands.run(
`git -C "${repoPath}" rev-parse --abbrev-ref HEAD`
)
).stdout.trim()
expect(head).toBe('feature')
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
})
sandboxTest(
'git createBranch creates and checks out branch',
async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepoWithCommit(sandbox, baseDir)
await sandbox.git.createBranch(repoPath, 'feature')
const branches = await sandbox.git.branches(repoPath)
expect(branches.branches).toContain('feature')
expect(branches.currentBranch).toBe('feature')
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
}
)
sandboxTest('git deleteBranch removes branch', async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepoWithCommit(sandbox, baseDir)
await sandbox.commands.run(`git -C "${repoPath}" branch feature`)
await sandbox.git.deleteBranch(repoPath, 'feature')
const branch = (
await sandbox.commands.run(`git -C "${repoPath}" branch --list feature`)
).stdout.trim()
const branches = await sandbox.git.branches(repoPath)
expect(branch).toBe('')
expect(branches.branches).not.toContain('feature')
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
})
@@ -0,0 +1,35 @@
import { expect } from 'vitest'
import { sandboxTest } from '../../setup.js'
import {
cleanupBaseDir,
createBaseDir,
createRepoWithCommit,
startGitDaemon,
} from './helpers.js'
sandboxTest('git clone fetches repo', async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepoWithCommit(sandbox, baseDir)
const daemon = await startGitDaemon(sandbox, baseDir)
const clonePath = `${baseDir}/clone`
try {
await sandbox.git.remoteAdd(repoPath, 'origin', daemon.remoteUrl)
await sandbox.git.push(repoPath, {
remote: 'origin',
branch: 'main',
})
await sandbox.git.clone(daemon.remoteUrl, { path: clonePath })
const contents = await sandbox.files.read(`${clonePath}/README.md`)
expect(contents).toContain('hello')
} finally {
await daemon.handle.kill()
}
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
})
@@ -0,0 +1,66 @@
import { expect } from 'vitest'
import { sandboxTest } from '../../setup.js'
import {
AUTHOR_EMAIL,
AUTHOR_NAME,
cleanupBaseDir,
createBaseDir,
createRepo,
} from './helpers.js'
sandboxTest('git commit creates commit', async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepo(sandbox, baseDir)
await sandbox.files.write(`${repoPath}/README.md`, 'hello\n')
await sandbox.git.add(repoPath)
await sandbox.git.commit(repoPath, 'Initial commit', {
authorName: AUTHOR_NAME,
authorEmail: AUTHOR_EMAIL,
})
const message = (
await sandbox.commands.run(`git -C "${repoPath}" log -1 --pretty=%B`)
).stdout.trim()
expect(message).toBe('Initial commit')
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
})
sandboxTest(
'git commit uses config for missing author fields',
async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepo(sandbox, baseDir)
await sandbox.commands.run(
`git -C "${repoPath}" config --local user.email "${AUTHOR_EMAIL}"`
)
await sandbox.files.write(`${repoPath}/README.md`, 'hello\n')
await sandbox.git.add(repoPath)
const overrideName = 'Override Bot'
await sandbox.git.commit(repoPath, 'Partial author commit', {
authorName: overrideName,
})
const authorName = (
await sandbox.commands.run(`git -C "${repoPath}" log -1 --pretty=%an`)
).stdout.trim()
const authorEmail = (
await sandbox.commands.run(`git -C "${repoPath}" log -1 --pretty=%ae`)
).stdout.trim()
expect(authorName).toBe(overrideName)
expect(authorEmail).toBe(AUTHOR_EMAIL)
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
}
)
@@ -0,0 +1,87 @@
import { expect } from 'vitest'
import { sandboxTest } from '../../setup.js'
import {
AUTHOR_EMAIL,
AUTHOR_NAME,
cleanupBaseDir,
createBaseDir,
createRepo,
} from './helpers.js'
sandboxTest('git getConfig reads local config', async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepo(sandbox, baseDir)
await sandbox.commands.run(
`git -C "${repoPath}" config --local pull.rebase true`
)
const value = await sandbox.git.getConfig('pull.rebase', {
scope: 'local',
path: repoPath,
})
const commandValue = (
await sandbox.commands.run(
`git -C "${repoPath}" config --local --get pull.rebase`
)
).stdout.trim()
expect(value).toBe('true')
expect(commandValue).toBe('true')
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
})
sandboxTest('git setConfig updates local config', async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepo(sandbox, baseDir)
await sandbox.git.setConfig('pull.rebase', 'true', {
scope: 'local',
path: repoPath,
})
const value = (
await sandbox.commands.run(
`git -C "${repoPath}" config --local --get pull.rebase`
)
).stdout.trim()
const configuredValue = await sandbox.git.getConfig('pull.rebase', {
scope: 'local',
path: repoPath,
})
expect(value).toBe('true')
expect(configuredValue).toBe('true')
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
})
sandboxTest(
'git configureUser sets global user config',
async ({ sandbox }) => {
await sandbox.git.configureUser(AUTHOR_NAME, AUTHOR_EMAIL)
const name = (
await sandbox.commands.run('git config --global --get user.name')
).stdout.trim()
const email = (
await sandbox.commands.run('git config --global --get user.email')
).stdout.trim()
const configuredName = await sandbox.git.getConfig('user.name', {
scope: 'global',
})
const configuredEmail = await sandbox.git.getConfig('user.email', {
scope: 'global',
})
expect(name).toBe(AUTHOR_NAME)
expect(email).toBe(AUTHOR_EMAIL)
expect(configuredName).toBe(AUTHOR_NAME)
expect(configuredEmail).toBe(AUTHOR_EMAIL)
}
)
@@ -0,0 +1,22 @@
import { expect } from 'vitest'
import { sandboxTest } from '../../setup.js'
import { HOST, PASSWORD, PROTOCOL, USERNAME } from './helpers.js'
sandboxTest('git dangerouslyAuthenticate sets helper', async ({ sandbox }) => {
await sandbox.git.dangerouslyAuthenticate({
username: USERNAME,
password: PASSWORD,
host: HOST,
protocol: PROTOCOL,
})
const helper = (
await sandbox.commands.run('git config --global --get credential.helper')
).stdout.trim()
const configuredHelper = await sandbox.git.getConfig('credential.helper', {
scope: 'global',
})
expect(helper).toBe('store')
expect(configuredHelper).toBe('store')
})
@@ -0,0 +1,57 @@
import { randomUUID } from 'node:crypto'
export const AUTHOR_NAME = 'Sandbox Bot'
export const AUTHOR_EMAIL = 'sandbox@example.com'
export const USERNAME = 'git'
export const PASSWORD = 'token'
export const HOST = 'example.com'
export const PROTOCOL = 'https'
const BASE_DIR = '/tmp/test-git'
export async function createBaseDir(sandbox: any) {
const baseDir = `${BASE_DIR}/${randomUUID()}`
await sandbox.commands.run(`rm -rf "${baseDir}" && mkdir -p "${baseDir}"`)
return baseDir
}
export async function cleanupBaseDir(sandbox: any, baseDir: string) {
await sandbox.commands.run(`rm -rf "${baseDir}"`)
}
export async function createRepo(sandbox: any, baseDir: string) {
const repoPath = `${baseDir}/repo`
await sandbox.git.init(repoPath, { initialBranch: 'main' })
return repoPath
}
export async function createRepoWithCommit(sandbox: any, baseDir: string) {
const repoPath = await createRepo(sandbox, baseDir)
await sandbox.files.write(`${repoPath}/README.md`, 'hello\n')
await sandbox.git.add(repoPath)
await sandbox.git.commit(repoPath, 'Initial commit', {
authorName: AUTHOR_NAME,
authorEmail: AUTHOR_EMAIL,
})
return repoPath
}
export async function startGitDaemon(sandbox: any, baseDir: string) {
const remotePath = `${baseDir}/remote.git`
await sandbox.commands.run(
`git init --bare --initial-branch=main "${remotePath}"`
)
const port = 9418 + Math.floor(Math.random() * 1000)
const handle = await sandbox.commands.run(
`git daemon --reuseaddr --base-path="${baseDir}" --export-all ` +
`--enable=receive-pack --informative-errors --listen=127.0.0.1 --port=${port}`,
{ background: true }
)
await sandbox.commands.run('sleep 1')
return {
handle,
remotePath,
remoteUrl: `git://127.0.0.1:${port}/remote.git`,
port,
}
}
@@ -0,0 +1,24 @@
import { expect } from 'vitest'
import { sandboxTest } from '../../setup.js'
import { cleanupBaseDir, createBaseDir } from './helpers.js'
sandboxTest('git init', async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = `${baseDir}/repo`
await sandbox.git.init(repoPath, { initialBranch: 'main' })
expect(await sandbox.files.exists(`${repoPath}/.git`)).toBe(true)
const head = (
await sandbox.commands.run(
`git -C "${repoPath}" symbolic-ref --short HEAD`
)
).stdout.trim()
expect(head).toBe('main')
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
})
@@ -0,0 +1,82 @@
import { expect } from 'vitest'
import { sandboxTest } from '../../setup.js'
import {
cleanupBaseDir,
createBaseDir,
createRepo,
startGitDaemon,
} from './helpers.js'
sandboxTest(
'git remoteGet returns undefined for missing remote',
async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepo(sandbox, baseDir)
const missingUrl = await sandbox.git.remoteGet(repoPath, 'origin')
expect(missingUrl).toBeUndefined()
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
}
)
sandboxTest('git remoteAdd adds remote', async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepo(sandbox, baseDir)
const daemon = await startGitDaemon(sandbox, baseDir)
try {
await sandbox.git.remoteAdd(repoPath, 'origin', daemon.remoteUrl)
const remoteUrl = await sandbox.git.remoteGet(repoPath, 'origin')
expect(remoteUrl).toBe(daemon.remoteUrl)
} finally {
await daemon.handle.kill()
}
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
})
sandboxTest('git remoteAdd overwrites existing remote', async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepo(sandbox, baseDir)
const daemon = await startGitDaemon(sandbox, baseDir)
try {
await sandbox.git.remoteAdd(repoPath, 'origin', daemon.remoteUrl)
const currentUrl = (
await sandbox.commands.run(`git -C "${repoPath}" remote get-url origin`)
).stdout.trim()
const currentRemote = await sandbox.git.remoteGet(repoPath, 'origin')
expect(currentUrl).toBe(daemon.remoteUrl)
expect(currentRemote).toBe(daemon.remoteUrl)
const secondPath = `${baseDir}/remote-2.git`
await sandbox.commands.run(
`git init --bare --initial-branch=main "${secondPath}"`
)
const secondUrl = `git://127.0.0.1:${daemon.port}/remote-2.git`
await sandbox.git.remoteAdd(repoPath, 'origin', secondUrl, {
overwrite: true,
})
const updatedUrl = (
await sandbox.commands.run(`git -C "${repoPath}" remote get-url origin`)
).stdout.trim()
const updatedRemote = await sandbox.git.remoteGet(repoPath, 'origin')
expect(updatedUrl).toBe(secondUrl)
expect(updatedRemote).toBe(secondUrl)
} finally {
await daemon.handle.kill()
}
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
})
@@ -0,0 +1,30 @@
import { expect } from 'vitest'
import { sandboxTest } from '../../setup.js'
import {
cleanupBaseDir,
createBaseDir,
createRepoWithCommit,
} from './helpers.js'
sandboxTest('git reset --hard discards changes', async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepoWithCommit(sandbox, baseDir)
await sandbox.files.write(`${repoPath}/README.md`, 'changed\n')
const status = await sandbox.git.status(repoPath)
expect(status.isClean).toBe(false)
await sandbox.git.reset(repoPath, { mode: 'hard', target: 'HEAD' })
const statusAfter = await sandbox.git.status(repoPath)
expect(statusAfter.isClean).toBe(true)
const contents = await sandbox.files.read(`${repoPath}/README.md`)
expect(contents).toBe('hello\n')
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
})
@@ -0,0 +1,58 @@
import { expect } from 'vitest'
import { sandboxTest } from '../../setup.js'
import {
cleanupBaseDir,
createBaseDir,
createRepoWithCommit,
} from './helpers.js'
sandboxTest('git restore --staged unstages changes', async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepoWithCommit(sandbox, baseDir)
await sandbox.files.write(`${repoPath}/README.md`, 'changed\n')
await sandbox.git.add(repoPath, { files: ['README.md'] })
const status = await sandbox.git.status(repoPath)
expect(status.hasStaged).toBe(true)
await sandbox.git.restore(repoPath, {
paths: ['README.md'],
staged: true,
worktree: false,
})
const statusAfter = await sandbox.git.status(repoPath)
expect(statusAfter.hasStaged).toBe(false)
expect(statusAfter.hasChanges).toBe(true)
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
})
sandboxTest(
'git restore discards working tree changes',
async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepoWithCommit(sandbox, baseDir)
await sandbox.files.write(`${repoPath}/README.md`, 'changed\n')
const status = await sandbox.git.status(repoPath)
expect(status.isClean).toBe(false)
await sandbox.git.restore(repoPath, { paths: ['README.md'] })
const statusAfter = await sandbox.git.status(repoPath)
expect(statusAfter.isClean).toBe(true)
const contents = await sandbox.files.read(`${repoPath}/README.md`)
expect(contents).toBe('hello\n')
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
}
)
@@ -0,0 +1,99 @@
import { expect } from 'vitest'
import { sandboxTest } from '../../setup.js'
import {
AUTHOR_EMAIL,
AUTHOR_NAME,
cleanupBaseDir,
createBaseDir,
createRepo,
} from './helpers.js'
sandboxTest('git status reports untracked file', async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepo(sandbox, baseDir)
await sandbox.files.write(`${repoPath}/README.md`, 'hello\n')
const status = await sandbox.git.status(repoPath)
const entry = status.fileStatus.find(
(file: any) => file.name === 'README.md'
)
expect(entry?.status).toBe('untracked')
expect(status.isClean).toBe(false)
expect(status.hasChanges).toBe(true)
expect(status.hasUntracked).toBe(true)
expect(status.hasStaged).toBe(false)
expect(status.hasConflicts).toBe(false)
expect(status.totalCount).toBe(1)
expect(status.stagedCount).toBe(0)
expect(status.unstagedCount).toBe(1)
expect(status.untrackedCount).toBe(1)
expect(status.conflictCount).toBe(0)
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
})
sandboxTest(
'git status reports added modified deleted renamed',
async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepo(sandbox, baseDir)
await sandbox.files.write(`${repoPath}/README.md`, 'hello\n')
await sandbox.files.write(`${repoPath}/DELETE.md`, 'delete me\n')
await sandbox.files.write(`${repoPath}/RENAME.md`, 'rename me\n')
await sandbox.git.add(repoPath)
await sandbox.git.commit(repoPath, 'Initial commit', {
authorName: AUTHOR_NAME,
authorEmail: AUTHOR_EMAIL,
})
await sandbox.files.write(`${repoPath}/README.md`, 'hello again\n')
await sandbox.files.write(`${repoPath}/NEW.md`, 'new file\n')
await sandbox.git.add(repoPath, { files: ['NEW.md'] })
await sandbox.commands.run(`git -C "${repoPath}" rm DELETE.md`)
await sandbox.commands.run(`git -C "${repoPath}" mv RENAME.md RENAMED.md`)
const status = await sandbox.git.status(repoPath)
const modified = status.fileStatus.find(
(file: any) => file.name === 'README.md'
)
const added = status.fileStatus.find(
(file: any) => file.name === 'NEW.md'
)
const deleted = status.fileStatus.find(
(file: any) => file.name === 'DELETE.md'
)
const renamed = status.fileStatus.find(
(file: any) => file.name === 'RENAMED.md'
)
expect(modified?.status).toBe('modified')
expect(modified?.staged).toBe(false)
expect(added?.status).toBe('added')
expect(added?.staged).toBe(true)
expect(deleted?.status).toBe('deleted')
expect(deleted?.staged).toBe(true)
expect(renamed?.status).toBe('renamed')
expect(renamed?.staged).toBe(true)
expect(renamed?.renamedFrom).toBe('RENAME.md')
expect(status.hasChanges).toBe(true)
expect(status.hasStaged).toBe(true)
expect(status.hasUntracked).toBe(false)
expect(status.hasConflicts).toBe(false)
expect(status.totalCount).toBe(4)
expect(status.stagedCount).toBe(3)
expect(status.unstagedCount).toBe(1)
expect(status.untrackedCount).toBe(0)
expect(status.conflictCount).toBe(0)
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
}
)
@@ -0,0 +1,116 @@
import { expect } from 'vitest'
import { sandboxTest } from '../../setup.js'
import {
AUTHOR_EMAIL,
AUTHOR_NAME,
cleanupBaseDir,
createBaseDir,
createRepoWithCommit,
startGitDaemon,
} from './helpers.js'
sandboxTest('git push updates remote', async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepoWithCommit(sandbox, baseDir)
const daemon = await startGitDaemon(sandbox, baseDir)
try {
await sandbox.git.remoteAdd(repoPath, 'origin', daemon.remoteUrl)
await sandbox.git.push(repoPath, {
remote: 'origin',
branch: 'main',
})
const message = (
await sandbox.commands.run(
`git --git-dir="${daemon.remotePath}" log -1 --pretty=%B`
)
).stdout.trim()
expect(message).toBe('Initial commit')
} finally {
await daemon.handle.kill()
}
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
})
sandboxTest('git push warns when no upstream', async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepoWithCommit(sandbox, baseDir)
const daemon = await startGitDaemon(sandbox, baseDir)
try {
await sandbox.git.remoteAdd(repoPath, 'origin', daemon.remoteUrl)
await expect(
sandbox.git.push(repoPath, { setUpstream: false })
).rejects.toThrow(/no upstream branch is configured/i)
} finally {
await daemon.handle.kill()
}
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
})
sandboxTest('git pull updates clone', async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepoWithCommit(sandbox, baseDir)
const daemon = await startGitDaemon(sandbox, baseDir)
const clonePath = `${baseDir}/clone`
try {
await sandbox.git.remoteAdd(repoPath, 'origin', daemon.remoteUrl)
await sandbox.git.push(repoPath, {
remote: 'origin',
branch: 'main',
})
await sandbox.git.clone(daemon.remoteUrl, { path: clonePath })
await sandbox.files.write(`${repoPath}/README.md`, 'hello\nmore\n')
await sandbox.git.add(repoPath)
await sandbox.git.commit(repoPath, 'Update README', {
authorName: AUTHOR_NAME,
authorEmail: AUTHOR_EMAIL,
})
await sandbox.git.push(repoPath)
await sandbox.git.pull(clonePath)
const contents = await sandbox.files.read(`${clonePath}/README.md`)
expect(contents).toContain('more')
} finally {
await daemon.handle.kill()
}
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
})
sandboxTest('git pull warns when no upstream', async ({ sandbox }) => {
const baseDir = await createBaseDir(sandbox)
try {
const repoPath = await createRepoWithCommit(sandbox, baseDir)
const daemon = await startGitDaemon(sandbox, baseDir)
try {
await sandbox.git.remoteAdd(repoPath, 'origin', daemon.remoteUrl)
await expect(sandbox.git.pull(repoPath)).rejects.toThrow(
/no upstream branch is configured/i
)
} finally {
await daemon.handle.kill()
}
} finally {
await cleanupBaseDir(sandbox, baseDir)
}
})
@@ -0,0 +1,44 @@
import { test, expect } from 'vitest'
import { Git } from '../../../src/sandbox/git'
import type { Commands } from '../../../src/sandbox/commands'
import { InvalidArgumentError } from '../../../src/errors'
// Stub command runner that fails if a git command is actually executed —
// validation must throw before reaching it.
const failingCommands = {
run: () => {
throw new Error('commands.run should not be called')
},
} as unknown as Commands
test('git.reset throws InvalidArgumentError on an invalid mode', async () => {
const git = new Git(failingCommands)
await expect(
// @ts-expect-error - testing runtime validation with an invalid mode
git.reset('/repo', { mode: 'bogus' })
).rejects.toThrow(InvalidArgumentError)
})
test('git.reset accepts a valid mode', async () => {
const git = new Git(failingCommands)
// A valid mode must pass validation and reach the (stubbed) command runner.
await expect(git.reset('/repo', { mode: 'hard' })).rejects.toThrow(
'commands.run should not be called'
)
})
test('git.remoteAdd throws InvalidArgumentError when name or url is missing', async () => {
const git = new Git(failingCommands)
await expect(
git.remoteAdd('/repo', '', 'https://example.com')
).rejects.toThrow(InvalidArgumentError)
await expect(git.remoteAdd('/repo', 'origin', '')).rejects.toThrow(
InvalidArgumentError
)
})
test('git.remoteGet throws InvalidArgumentError when name is missing', async () => {
const git = new Git(failingCommands)
await expect(git.remoteGet('/repo', '')).rejects.toThrow(InvalidArgumentError)
})
@@ -0,0 +1,63 @@
import { assert } from 'vitest'
import { isDebug, sandboxTest, wait } from '../setup.js'
import { catchCmdExitErrorInBackground } from '../cmdHelper.js'
sandboxTest(
'ping server in running sandbox',
async ({ sandbox }) => {
const cmd = await sandbox.commands.run('python -m http.server 8000', {
background: true,
})
const disable = catchCmdExitErrorInBackground(cmd)
try {
await wait(1000)
const host = sandbox.getHost(8000)
let res = await fetch(`${isDebug ? 'http' : 'https'}://${host}`)
for (let i = 0; i < 20; i++) {
if (res.status === 200) {
break
}
res = await fetch(`${isDebug ? 'http' : 'https'}://${host}`)
await wait(500)
}
assert.equal(res.status, 200)
disable()
} finally {
try {
await cmd.kill()
} catch (e) {
console.error(e)
}
}
},
60_000
)
sandboxTest.skipIf(isDebug)(
'ping server in non-running sandbox',
async ({ sandbox }) => {
const host = sandbox.getHost(3000)
const url = `https://${host}`
await sandbox.kill()
const res = await fetch(url)
assert.equal(res.status, 502)
const text = await res.text()
const json = JSON.parse(text) as {
message: string
sandboxId: string
code: number
}
assert.equal(json.message, 'The sandbox was not found')
assert.isTrue(sandbox.sandboxId.startsWith(json.sandboxId))
assert.equal(json.code, 502)
}
)
@@ -0,0 +1,64 @@
import { assert, describe } from 'vitest'
import { CommandExitError } from '../../src'
import { sandboxTest, isDebug } from '../setup.js'
describe('internet access enabled', () => {
sandboxTest.scoped({
sandboxOpts: {
allowInternetAccess: true,
},
})
sandboxTest.skipIf(isDebug)(
'internet access enabled',
async ({ sandbox }) => {
// Test internet connectivity by making a curl request to a reliable external site
const result = await sandbox.commands.run(
"curl -s -o /dev/null -w '%{http_code}' https://connectivitycheck.gstatic.com/generate_204"
)
assert.equal(result.exitCode, 0)
assert.equal(result.stdout.trim(), '204')
}
)
})
describe('internet access disabled', () => {
sandboxTest.scoped({
sandboxOpts: {
allowInternetAccess: false,
},
})
sandboxTest.skipIf(isDebug)(
'internet access disabled',
async ({ sandbox }) => {
// Test that internet connectivity is blocked by making a curl request
try {
await sandbox.commands.run(
'curl --connect-timeout 3 --max-time 5 -Is https://connectivitycheck.gstatic.com/generate_204'
)
// If we reach here, the command succeeded, which means internet access is not properly disabled
assert.fail('Expected command to fail when internet access is disabled')
} catch (error) {
// The command should fail or timeout when internet access is disabled
assert.isTrue(error instanceof CommandExitError)
assert.notEqual(error.exitCode, 0)
}
}
)
})
describe('internet access default', () => {
sandboxTest.skipIf(isDebug)(
'internet access default',
async ({ sandbox }) => {
// Test internet connectivity by making a curl request to a reliable external site
const result = await sandbox.commands.run(
"curl -s -o /dev/null -w '%{http_code}' https://connectivitycheck.gstatic.com/generate_204"
)
assert.equal(result.exitCode, 0)
assert.equal(result.stdout.trim(), '204')
}
)
})
@@ -0,0 +1,16 @@
import { expect } from 'vitest'
import { Sandbox } from '../../src'
import { sandboxTest, isDebug } from '../setup.js'
sandboxTest.skipIf(isDebug)('kill', async ({ sandbox, sandboxTestId }) => {
const killed = await sandbox.kill()
expect(killed).toBe(true)
const paginator = Sandbox.list({
query: { state: ['running'], metadata: { sandboxTestId } },
})
const sandboxes = await paginator.nextItems()
expect(sandboxes.map((s) => s.sandboxId)).not.toContain(sandbox.sandboxId)
})
@@ -0,0 +1,139 @@
import { assert, expect, test } from 'vitest'
import { InvalidArgumentError, Sandbox } from '../../src'
import { isDebug, template, wait } from '../setup.js'
test.skipIf(isDebug)(
'filesystem-only auto-pause cannot be combined with auto-resume',
async () => {
// A filesystem-only auto-pause snapshot can only be resumed explicitly, so
// keepMemory:false with autoResume is rejected client-side.
await expect(
Sandbox.create(template, {
timeoutMs: 3_000,
lifecycle: {
onTimeout: { action: 'pause', keepMemory: false },
autoResume: true,
},
})
).rejects.toThrowError(InvalidArgumentError)
}
)
test.skipIf(isDebug)(
'keepMemory is not allowed when onTimeout action is kill',
async () => {
// The discriminated union forbids keepMemory on `action: 'kill'` at compile
// time (asserted by @ts-expect-error). The runtime guard below additionally
// rejects it for untyped (JS) callers that bypass the type.
await expect(
Sandbox.create(template, {
timeoutMs: 3_000,
lifecycle: {
// @ts-expect-error keepMemory is not allowed with action: 'kill'
onTimeout: { action: 'kill', keepMemory: false },
},
})
).rejects.toThrowError(InvalidArgumentError)
}
)
test.skipIf(isDebug)(
'auto-pause without auto-resume requires connect to wake',
async () => {
const sandbox = await Sandbox.create(template, {
timeoutMs: 3_000,
lifecycle: {
onTimeout: 'pause',
autoResume: false,
},
})
try {
await wait(5_000)
assert.equal((await sandbox.getInfo()).state, 'paused')
assert.isFalse(await sandbox.isRunning())
await sandbox.connect()
assert.equal((await sandbox.getInfo()).state, 'running')
assert.isTrue(await sandbox.isRunning())
} finally {
await sandbox.kill().catch(() => {})
}
},
60_000
)
test.skipIf(isDebug)(
'filesystem-only auto-pause reboots on connect',
async () => {
// keepMemory:false makes the timeout auto-pause filesystem-only, so resuming
// cold-boots the sandbox from disk.
const sandbox = await Sandbox.create(template, {
timeoutMs: 3_000,
lifecycle: { onTimeout: { action: 'pause', keepMemory: false } },
})
try {
const marker = 'auto-pause-fs-only'
await sandbox.files.write('/home/user/auto-pause-marker.txt', marker)
const bootBefore = (
await sandbox.files.read('/proc/sys/kernel/random/boot_id')
).trim()
await wait(5_000)
assert.equal((await sandbox.getInfo()).state, 'paused')
// A filesystem-only snapshot cannot auto-resume on traffic; connect
// resumes it by cold-booting.
await sandbox.connect()
const persisted = (
await sandbox.files.read('/home/user/auto-pause-marker.txt')
).trim()
assert.equal(persisted, marker)
const bootAfter = (
await sandbox.files.read('/proc/sys/kernel/random/boot_id')
).trim()
assert.notEqual(bootAfter, bootBefore)
} finally {
await sandbox.kill().catch(() => {})
}
},
60_000
)
test.skipIf(isDebug)(
'auto-resume wakes paused sandbox on http request',
async () => {
const sandbox = await Sandbox.create(template, {
timeoutMs: 3_000,
lifecycle: {
onTimeout: 'pause',
autoResume: true,
},
})
try {
await sandbox.commands.run('python3 -m http.server 8000', {
background: true,
})
await wait(5_000)
const url = `https://${sandbox.getHost(8000)}`
const res = await fetch(url, { signal: AbortSignal.timeout(15_000) })
assert.equal(res.status, 200)
assert.equal((await sandbox.getInfo()).state, 'running')
assert.isTrue(await sandbox.isRunning())
} finally {
await sandbox.kill().catch(() => {})
}
},
60_000
)
@@ -0,0 +1,69 @@
import { expect } from 'vitest'
import { SandboxMetrics } from '../../src'
import { sandboxTest, isDebug, wait } from '../setup.js'
sandboxTest.skipIf(isDebug)(
'sbx metrics',
{ timeout: 60_000 },
async ({ sandbox }) => {
// Wait for the sandbox to have some metrics
let metrics: SandboxMetrics[] = []
for (let i = 0; i < 60; i++) {
metrics = await sandbox.getMetrics()
if (metrics.length > 0) {
break
}
await wait(500)
}
expect(metrics.length).toBeGreaterThan(0)
const metric = metrics[0]
expect(metric.diskTotal).toBeDefined()
expect(metric.diskUsed).toBeDefined()
expect(metric.memTotal).toBeDefined()
expect(metric.memUsed).toBeDefined()
expect(metric.cpuUsedPct).toBeDefined()
expect(metric.cpuCount).toBeDefined()
}
)
sandboxTest.skipIf(isDebug)(
'sbx metrics time range',
{ timeout: 60_000 },
async ({ sandbox }) => {
const start = new Date()
// Wait for the sandbox to have some metrics within the test's time window
let metrics: SandboxMetrics[] = []
let end = new Date()
for (let i = 0; i < 60; i++) {
end = new Date()
metrics = await sandbox.getMetrics({ start, end })
if (metrics.length > 0) {
break
}
await wait(500)
}
expect(metrics.length).toBeGreaterThan(0)
// All returned metrics must fall within the requested time range
// (10s slack — metric timestamps are aligned to collection buckets,
// currently 5s, and the query params are second-precision)
const slackMs = 10_000
for (const m of metrics) {
expect(m.timestamp.getTime()).toBeGreaterThanOrEqual(
start.getTime() - slackMs
)
expect(m.timestamp.getTime()).toBeLessThanOrEqual(end.getTime() + slackMs)
}
// A time range from before the sandbox existed must return no metrics
const noMetrics = await sandbox.getMetrics({
start: new Date(start.getTime() - 60 * 60 * 1000),
end: new Date(start.getTime() - 30 * 60 * 1000),
})
expect(noMetrics).toHaveLength(0)
}
)
@@ -0,0 +1,359 @@
import { assert, expect, describe } from 'vitest'
import { CommandExitError } from '../../src'
import { sandboxTest, isDebug } from '../setup.js'
describe('allow only 1.1.1.1', () => {
sandboxTest.scoped({
sandboxOpts: {
network: {
denyOut: ({ allTraffic }) => [allTraffic],
allowOut: ['1.1.1.1'],
},
},
})
sandboxTest.skipIf(isDebug)(
'allow specific IP with deny all traffic',
async ({ sandbox }) => {
// Test that allowed IP works
const result = await sandbox.commands.run(
"curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1"
)
assert.equal(result.exitCode, 0)
assert.equal(result.stdout.trim(), '301')
// Test that other IPs are denied
await expect(
sandbox.commands.run(
'curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8'
)
).rejects.toBeInstanceOf(CommandExitError)
}
)
})
describe('deny specific IP address', () => {
sandboxTest.scoped({
sandboxOpts: {
network: {
denyOut: ['8.8.8.8'],
},
},
})
sandboxTest.skipIf(isDebug)(
'deny specific IP address',
async ({ sandbox }) => {
// Test that denied IP fails
await expect(
sandbox.commands.run(
'curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8'
)
).rejects.toBeInstanceOf(CommandExitError)
// Test that other IPs work
const result = await sandbox.commands.run(
"curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1"
)
assert.equal(result.exitCode, 0)
assert.equal(result.stdout.trim(), '301')
}
)
})
describe('deny all traffic using allTraffic selector', () => {
sandboxTest.scoped({
sandboxOpts: {
network: {
denyOut: ({ allTraffic }) => [allTraffic],
},
},
})
sandboxTest.skipIf(isDebug)(
'deny all traffic using allTraffic selector',
async ({ sandbox }) => {
// Test that all traffic is denied
await expect(
sandbox.commands.run(
'curl --connect-timeout 3 --max-time 5 -Is https://1.1.1.1'
)
).rejects.toBeInstanceOf(CommandExitError)
await expect(
sandbox.commands.run(
'curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8'
)
).rejects.toBeInstanceOf(CommandExitError)
}
)
})
describe('allow takes precedence over deny', () => {
sandboxTest.scoped({
sandboxOpts: {
network: {
denyOut: ({ allTraffic }) => [allTraffic],
allowOut: ['1.1.1.1', '8.8.8.8'],
},
},
})
sandboxTest.skipIf(isDebug)(
'allow takes precedence over deny',
async ({ sandbox }) => {
// Test that 1.1.1.1 works (explicitly allowed)
const result1 = await sandbox.commands.run(
"curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1"
)
assert.equal(result1.exitCode, 0)
assert.equal(result1.stdout.trim(), '301')
// Test that 8.8.8.8 also works (explicitly allowed, takes precedence over denyOut)
const result2 = await sandbox.commands.run(
"curl -s -o /dev/null -w '%{http_code}' https://8.8.8.8"
)
assert.equal(result2.exitCode, 0)
assert.equal(result2.stdout.trim(), '302')
}
)
})
describe('allowPublicTraffic=false', () => {
sandboxTest.scoped({
sandboxOpts: {
network: {
allowPublicTraffic: false,
},
},
})
sandboxTest.skipIf(isDebug)(
'sandbox requires traffic access token',
async ({ sandbox }) => {
// Verify the sandbox was created successfully and has a traffic access token
assert(sandbox.trafficAccessToken)
// Start a simple HTTP server in the sandbox
const port = 8080
sandbox.commands.run(`python3 -m http.server ${port}`, {
background: true,
})
// Wait for server to start
await new Promise((resolve) => setTimeout(resolve, 3000))
// Get the public URL for the sandbox
const sandboxUrl = `https://${sandbox.getHost(port)}`
// Test 1: Request without traffic access token should fail with 403
const response1 = await fetch(sandboxUrl)
assert.equal(response1.status, 403)
// Test 2: Request with valid traffic access token should succeed
const response2 = await fetch(sandboxUrl, {
headers: {
'e2b-traffic-access-token': sandbox.trafficAccessToken,
},
})
assert.equal(response2.status, 200)
}
)
})
describe('allowPublicTraffic=true', () => {
sandboxTest.scoped({
sandboxOpts: {
network: {
allowPublicTraffic: true,
},
},
})
sandboxTest.skipIf(isDebug)(
'sandbox works without token',
async ({ sandbox }) => {
// Start a simple HTTP server in the sandbox
const port = 8080
sandbox.commands.run(`python3 -m http.server ${port}`, {
background: true,
})
// Wait for server to start
await new Promise((resolve) => setTimeout(resolve, 3000))
// Get the public URL for the sandbox
const sandboxUrl = `https://${sandbox.getHost(port)}`
// Request without traffic access token should succeed (public access enabled)
const response = await fetch(sandboxUrl)
assert.equal(response.status, 200)
}
)
})
describe('firewall transform injects headers', () => {
const injectedHeader = 'X-E2B-Test-Token'
const injectedValue = 'e2b-transform-value-123'
sandboxTest.scoped({
sandboxOpts: {
network: {
rules: {
'httpbin.e2b.team': [
{
transform: {
headers: {
[injectedHeader]: injectedValue,
},
},
},
],
},
},
},
})
sandboxTest.skipIf(isDebug)(
'injected header is reflected by httpbin.e2b.team/headers',
async ({ sandbox }) => {
const result = await sandbox.commands.run(
'curl -sS --max-time 10 https://httpbin.e2b.team/headers'
)
assert.equal(result.exitCode, 0)
const parsed = JSON.parse(result.stdout) as {
headers: Record<string, string>
}
const reflected = parsed.headers[injectedHeader]
assert.equal(
reflected,
injectedValue,
`expected httpbin to reflect ${injectedHeader}=${injectedValue}, got headers: ${JSON.stringify(parsed.headers)}`
)
}
)
})
describe('updateNetwork applies new egress rules', () => {
sandboxTest.skipIf(isDebug)(
'denies a previously reachable IP after update',
async ({ sandbox }) => {
// Baseline: 8.8.8.8 is reachable.
const before = await sandbox.commands.run(
"curl -s -o /dev/null -w '%{http_code}' https://8.8.8.8"
)
assert.equal(before.exitCode, 0)
await sandbox.updateNetwork({ denyOut: ['8.8.8.8'] })
// 8.8.8.8 should now be denied.
await expect(
sandbox.commands.run(
'curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8'
)
).rejects.toBeInstanceOf(CommandExitError)
// Other destinations stay reachable.
const after = await sandbox.commands.run(
"curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1"
)
assert.equal(after.exitCode, 0)
}
)
})
describe('updateNetwork clears existing rules when fields are omitted', () => {
sandboxTest.scoped({
sandboxOpts: {
network: {
denyOut: ({ allTraffic }) => [allTraffic],
allowOut: ['1.1.1.1'],
},
},
})
sandboxTest.skipIf(isDebug)(
'omitting fields replaces all egress rules',
async ({ sandbox }) => {
// Baseline from create-time config: 8.8.8.8 denied.
await expect(
sandbox.commands.run(
'curl --connect-timeout 3 --max-time 5 -Is https://8.8.8.8'
)
).rejects.toBeInstanceOf(CommandExitError)
// Empty update clears allow_out / deny_out entirely.
await sandbox.updateNetwork({})
const r1 = await sandbox.commands.run(
"curl -s -o /dev/null -w '%{http_code}' https://1.1.1.1"
)
assert.equal(r1.exitCode, 0)
const r2 = await sandbox.commands.run(
"curl -s -o /dev/null -w '%{http_code}' https://8.8.8.8"
)
assert.equal(r2.exitCode, 0)
}
)
})
describe('maskRequestHost option', () => {
sandboxTest.scoped({
sandboxOpts: {
network: {
maskRequestHost: 'custom-host.example.com:${PORT}',
},
},
})
sandboxTest.skipIf(isDebug)(
'verify maskRequestHost modifies Host header correctly',
async ({ sandbox }) => {
const port = 8080
const outputFile = '/tmp/headers.txt'
// Start a Python HTTP server that captures request headers and writes them to a file
sandbox.commands.run(
`python3 -c "
import http.server
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
with open('${outputFile}', 'w') as f:
for k, v in self.headers.items():
f.write(k + ': ' + v + chr(10))
self.send_response(200)
self.end_headers()
def log_message(self, *a): pass
http.server.HTTPServer(('', ${port}), H).handle_request()
"`,
{ background: true }
)
await new Promise((resolve) => setTimeout(resolve, 2000))
// Get the public URL for the sandbox
const sandboxUrl = `https://${sandbox.getHost(port)}`
// Make a request from OUTSIDE the sandbox through the proxy
// The Host header should be modified according to maskRequestHost
try {
await fetch(sandboxUrl, { signal: AbortSignal.timeout(5000) })
} catch (error) {
// Request may timeout, but headers are captured by the server
}
await new Promise((resolve) => setTimeout(resolve, 1000))
// Read the captured headers from inside the sandbox
const result = await sandbox.commands.run(`cat ${outputFile}`)
// Verify the Host header was modified according to maskRequestHost
assert.include(result.stdout, 'Host:')
assert.include(result.stdout, 'custom-host.example.com')
assert.include(result.stdout, `${port}`)
}
)
})
@@ -0,0 +1,25 @@
import { sandboxTest } from '../../setup'
import { assert, expect } from 'vitest'
import { ProcessExitError } from '../../../src/index.js'
sandboxTest('kill PTY', async ({ sandbox }) => {
const terminal = await sandbox.pty.create({
cols: 80,
rows: 24,
onData: () => {},
})
const result = await sandbox.pty.kill(terminal.pid)
assert.isTrue(result)
// The PTY process should no longer be running.
await expect(
sandbox.commands.run(`kill -0 ${terminal.pid}`)
).rejects.toThrowError(ProcessExitError)
})
sandboxTest('kill non-existing PTY', async ({ sandbox }) => {
const nonExistingPid = 999999
await expect(sandbox.pty.kill(nonExistingPid)).resolves.toBe(false)
})
@@ -0,0 +1,48 @@
import { sandboxTest } from '../../setup'
import { assert } from 'vitest'
sandboxTest('pty connect/reconnect', async ({ sandbox }) => {
let output1 = ''
let output2 = ''
const decoder = new TextDecoder()
// First, create a terminal and disconnect the onData handler
const terminal = await sandbox.pty.create({
cols: 80,
rows: 24,
onData: (data: Uint8Array) => {
output1 += decoder.decode(data)
},
envs: { FOO: 'bar' },
})
await sandbox.pty.sendInput(
terminal.pid,
new Uint8Array(Buffer.from('echo $FOO\n'))
)
// Give time for the command output in the first connection
await new Promise((r) => setTimeout(r, 300))
await terminal.disconnect()
// Now connect again, with a new onData handler
const reconnectHandle = await sandbox.pty.connect(terminal.pid, {
onData: (data: Uint8Array) => {
output2 += decoder.decode(data)
},
})
await sandbox.pty.sendInput(
terminal.pid,
new Uint8Array(Buffer.from('echo $FOO\nexit\n'))
)
await reconnectHandle.wait()
assert.equal(terminal.pid, reconnectHandle.pid)
assert.equal(reconnectHandle.exitCode, 0)
assert.include(output1, 'bar')
assert.include(output2, 'bar')
})
@@ -0,0 +1,27 @@
import { sandboxTest } from '../../setup'
import { assert } from 'vitest'
sandboxTest('create PTY', async ({ sandbox }) => {
let output = ''
const decoder = new TextDecoder()
const appendData = (data: Uint8Array) => {
output += decoder.decode(data)
}
const terminal = await sandbox.pty.create({
cols: 80,
rows: 24,
onData: appendData,
envs: { ABC: '123' },
})
await sandbox.pty.sendInput(
terminal.pid,
new Uint8Array(Buffer.from('echo $ABC\nexit\n'))
)
await terminal.wait()
assert.equal(terminal.exitCode, 0)
assert.include(output, '123')
})
@@ -0,0 +1,42 @@
import { sandboxTest } from '../../setup'
import { assert } from 'vitest'
sandboxTest('resize', async ({ sandbox }) => {
let output = ''
const decoder = new TextDecoder()
const appendData = (data: Uint8Array) => {
output += decoder.decode(data)
}
const terminal = await sandbox.pty.create({
cols: 80,
rows: 24,
onData: appendData,
})
await sandbox.pty.sendInput(
terminal.pid,
new Uint8Array(Buffer.from('tput cols\nexit\n'))
)
await terminal.wait()
assert.equal(terminal.exitCode, 0)
assert.include(output, '80')
output = ''
const resizedTerminal = await sandbox.pty.create({
cols: 80,
rows: 24,
onData: appendData,
})
await sandbox.pty.resize(resizedTerminal.pid, { cols: 100, rows: 24 })
await sandbox.pty.sendInput(
resizedTerminal.pid,
new Uint8Array(Buffer.from('tput cols\nexit\n'))
)
await resizedTerminal.wait()
assert.equal(resizedTerminal.exitCode, 0)
assert.include(output, '100')
})
@@ -0,0 +1,18 @@
import { expect } from 'vitest'
import { sandboxTest } from '../../setup'
sandboxTest('send input', async ({ sandbox }) => {
const terminal = await sandbox.pty.create({
cols: 80,
rows: 24,
onData: () => null,
})
await sandbox.pty.sendInput(
terminal.pid,
new Uint8Array(Buffer.from('exit\n'))
)
await terminal.wait()
expect(terminal.exitCode).toBe(0)
})
@@ -0,0 +1,49 @@
import { assert, test, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
rpcFetch: vi.fn(async () => new Response(null, { status: 204 })),
transportFetch: undefined as typeof fetch | undefined,
}))
vi.mock('@connectrpc/connect-web', () => ({
createConnectTransport: vi.fn((opts: { fetch: typeof fetch }) => {
mocks.transportFetch = opts.fetch
return {}
}),
}))
vi.mock('../../src/envd/http2', () => ({
createEnvdFetch: vi.fn(() => vi.fn()),
createEnvdRpcFetch: vi.fn(() => mocks.rpcFetch),
}))
test('does not pass custom connection headers to envd RPC requests', async () => {
const { ConnectionConfig, Sandbox } = await import('../../src')
const config = new ConnectionConfig()
const sandbox = new Sandbox({
...config,
sandboxId: 'sbx-test',
sandboxDomain: 'sandbox.e2b.dev',
envdVersion: '0.2.4',
envdAccessToken: 'tok',
headers: {
Authorization: 'Bearer user-token',
'X-Custom': 'secret',
},
})
assert.equal(sandbox.sandboxId, 'sbx-test')
assert.ok(mocks.transportFetch)
await mocks.transportFetch('https://sandbox.e2b.dev/rpc', {
headers: { 'Connect-Protocol-Version': '1' },
})
const headers = new Headers(mocks.rpcFetch.mock.calls[0][1]?.headers)
assert.equal(headers.get('Authorization'), null)
assert.equal(headers.get('X-Custom'), null)
assert.equal(headers.get('User-Agent')?.startsWith('e2b-js-sdk/'), true)
assert.equal(headers.get('E2b-Sandbox-Id'), 'sbx-test')
assert.equal(headers.get('X-Access-Token'), 'tok')
assert.equal(headers.get('Connect-Protocol-Version'), '1')
})
@@ -0,0 +1,115 @@
import { assert, test, describe } from 'vitest'
import { getSignature, Sandbox } from '../../src'
import { sandboxTest, isDebug } from '../setup'
import { randomUUID, createHash } from 'node:crypto'
describe('secure sandbox', () => {
sandboxTest.scoped({
sandboxOpts: {
secure: true,
},
})
sandboxTest.skipIf(isDebug)(
'test access file with signing',
async ({ sandbox }) => {
await sandbox.files.write('hello.txt', 'hello world')
const fileUrlWithSigning = await sandbox.downloadUrl('hello.txt')
const res = await fetch(fileUrlWithSigning)
const resBody = await res.text()
const resStatus = res.status
assert.equal(resStatus, 200)
assert.equal(resBody, 'hello world')
}
)
sandboxTest.skipIf(isDebug)(
'try to re-connect to sandbox',
async ({ sandbox }) => {
const sbxReconnect = await Sandbox.connect(sandbox.sandboxId)
await sbxReconnect.files.write('hello.txt', 'hello world')
}
)
})
test.skipIf(isDebug)('signing generation', async () => {
const operation = 'read'
const path = '/home/user/hello.txt'
const user = 'root'
const envdAccessToken = randomUUID()
const signatureRaw = `${path}:${operation}:${user}:${envdAccessToken}`
const buff = Buffer.from(signatureRaw, 'utf8')
const hash = createHash('sha256').update(buff).digest()
const signature = 'v1_' + hash.toString('base64').replace(/=+$/, '')
const readSignatureExpected = {
signature: signature,
expiration: null,
}
const readSignatureReceived = await getSignature({
path,
operation,
user,
envdAccessToken,
})
assert.deepEqual(readSignatureExpected, readSignatureReceived)
})
test.skipIf(isDebug)('signing generation with expiration', async () => {
const operation = 'read'
const path = '/home/user/hello.txt'
const user = 'root'
const envdAccessToken = randomUUID()
const expirationInSeconds = 120
const signatureExpiration = expirationInSeconds
? Math.floor(Date.now() / 1000) + expirationInSeconds
: null
const signatureRaw = `${path}:${operation}:${user}:${envdAccessToken}:${signatureExpiration?.toString()}`
const buff = Buffer.from(signatureRaw, 'utf8')
const hash = createHash('sha256').update(buff).digest()
const signature = 'v1_' + hash.toString('base64').replace(/=+$/, '')
const readSignatureExpected = {
signature: signature,
expiration: signatureExpiration,
}
const readSignatureReceived = await getSignature({
path,
operation,
user,
envdAccessToken,
expirationInSeconds,
})
assert.deepEqual(readSignatureExpected, readSignatureReceived)
})
test.skipIf(isDebug)('static signing key comparison', async () => {
const operation = 'read'
const path = 'hello.txt'
const user = 'user'
const envdAccessToken = '0tQG31xiMp0IOQfaz9dcwi72L1CPo8e0'
const signatureReceived = await getSignature({
path,
operation,
user,
envdAccessToken,
})
assert.equal(
'v1_gUtH/s9YCJWgCizjfUxuWfhFE4QSydOWEIIvfLwDr6E',
signatureReceived.signature
)
})
@@ -0,0 +1,212 @@
import { assert } from 'vitest'
import { sandboxTest, isDebug } from '../setup.js'
import { Sandbox } from '../../src'
sandboxTest.skipIf(isDebug)(
'create a snapshot from sandbox',
async ({ sandbox }) => {
// Write a file to the sandbox
await sandbox.files.write('/home/user/test.txt', 'snapshot test content')
// Create a snapshot
const snapshot = await sandbox.createSnapshot()
assert.isString(snapshot.snapshotId)
assert.isTrue(snapshot.snapshotId.length > 0)
// Cleanup
await Sandbox.deleteSnapshot(snapshot.snapshotId)
}
)
sandboxTest.skipIf(isDebug)(
'create sandbox from snapshot',
async ({ sandbox, sandboxTestId }) => {
const testContent = 'content from original sandbox'
// Write a file to the sandbox
await sandbox.files.write('/home/user/test.txt', testContent)
// Create a snapshot
const snapshot = await sandbox.createSnapshot()
try {
// Create a new sandbox from the snapshot
const newSandbox = await Sandbox.create(snapshot.snapshotId, {
metadata: { sandboxTestId: `${sandboxTestId}-from-snapshot` },
})
try {
// Verify the file exists in the new sandbox
const content = await newSandbox.files.read('/home/user/test.txt')
assert.equal(content, testContent)
} finally {
await newSandbox.kill()
}
} finally {
await Sandbox.deleteSnapshot(snapshot.snapshotId)
}
}
)
sandboxTest.skipIf(isDebug)(
'create multiple sandboxes from same snapshot',
async ({ sandbox, sandboxTestId }) => {
const testContent = 'shared snapshot content'
await sandbox.files.write('/home/user/shared.txt', testContent)
const snapshot = await sandbox.createSnapshot()
try {
// Create two sandboxes from the same snapshot
const sandbox1 = await Sandbox.create(snapshot.snapshotId, {
metadata: { sandboxTestId: `${sandboxTestId}-branch-1` },
})
const sandbox2 = await Sandbox.create(snapshot.snapshotId, {
metadata: { sandboxTestId: `${sandboxTestId}-branch-2` },
})
try {
// Both should have the same initial content
const content1 = await sandbox1.files.read('/home/user/shared.txt')
const content2 = await sandbox2.files.read('/home/user/shared.txt')
assert.equal(content1, testContent)
assert.equal(content2, testContent)
// Modify one sandbox - should not affect the other
await sandbox1.files.write(
'/home/user/shared.txt',
'modified in sandbox1'
)
const modifiedContent = await sandbox1.files.read(
'/home/user/shared.txt'
)
const unchangedContent = await sandbox2.files.read(
'/home/user/shared.txt'
)
assert.equal(modifiedContent, 'modified in sandbox1')
assert.equal(unchangedContent, testContent)
} finally {
await sandbox1.kill()
await sandbox2.kill()
}
} finally {
await Sandbox.deleteSnapshot(snapshot.snapshotId)
}
}
)
sandboxTest.skipIf(isDebug)('list snapshots', async ({ sandbox }) => {
// Create a snapshot
const snapshot = await sandbox.createSnapshot()
try {
// List all snapshots
const paginator = Sandbox.listSnapshots()
assert.isTrue(paginator.hasNext)
const snapshots = await paginator.nextItems()
assert.isArray(snapshots)
// Find our snapshot in the list
const found = snapshots.find((s) => s.snapshotId === snapshot.snapshotId)
assert.isDefined(found)
} finally {
await Sandbox.deleteSnapshot(snapshot.snapshotId)
}
})
sandboxTest.skipIf(isDebug)(
'list snapshots for specific sandbox',
async ({ sandbox }) => {
// Create a snapshot
const snapshot = await sandbox.createSnapshot()
try {
// List snapshots for this sandbox using instance method
const paginator = sandbox.listSnapshots()
const snapshots = await paginator.nextItems()
// Should find our snapshot
const found = snapshots.find((s) => s.snapshotId === snapshot.snapshotId)
assert.isDefined(found)
} finally {
await Sandbox.deleteSnapshot(snapshot.snapshotId)
}
}
)
sandboxTest.skipIf(isDebug)(
'create a named snapshot',
async ({ sandbox, sandboxTestId }) => {
const snapshotName = `snap-${sandboxTestId}`
const snapshot = await sandbox.createSnapshot({ name: snapshotName })
try {
assert.isString(snapshot.snapshotId)
assert.isArray(snapshot.names)
assert.isTrue(snapshot.names.length > 0)
assert.isTrue(snapshot.names.some((n) => n.includes(snapshotName)))
} finally {
await Sandbox.deleteSnapshot(snapshot.snapshotId)
}
}
)
sandboxTest.skipIf(isDebug)('delete snapshot', async ({ sandbox }) => {
const snapshot = await sandbox.createSnapshot()
// Delete should succeed
const deleted = await Sandbox.deleteSnapshot(snapshot.snapshotId)
assert.isTrue(deleted)
// Second delete should return false (not found)
const deletedAgain = await Sandbox.deleteSnapshot(snapshot.snapshotId)
assert.isFalse(deletedAgain)
})
sandboxTest.skipIf(isDebug)(
'snapshot preserves file system state',
async ({ sandbox, sandboxTestId }) => {
const appDir = '/home/user/app'
const configPath = `${appDir}/config.json`
const configContent = '{"env": "test"}'
const dataPath = `${appDir}/data.txt`
const dataContent = 'important data'
await sandbox.files.makeDir(appDir)
await sandbox.files.write(configPath, configContent)
await sandbox.files.write(dataPath, dataContent)
const snapshot = await sandbox.createSnapshot()
try {
const newSandbox = await Sandbox.create(snapshot.snapshotId, {
metadata: { sandboxTestId: `${sandboxTestId}-fs-test` },
})
try {
// Verify directory exists
const dirExists = await newSandbox.files.exists(appDir)
assert.isTrue(dirExists)
// Verify files exist with correct content
const config = await newSandbox.files.read(configPath)
const data = await newSandbox.files.read(dataPath)
assert.equal(config, configContent)
assert.equal(data, dataContent)
} finally {
await newSandbox.kill()
}
} finally {
await Sandbox.deleteSnapshot(snapshot.snapshotId)
}
}
)
@@ -0,0 +1,203 @@
import { assert, describe } from 'vitest'
import { sandboxTest, isDebug } from '../setup.js'
sandboxTest.skipIf(isDebug)(
'pause and resume a sandbox',
async ({ sandbox }) => {
assert.isTrue(await sandbox.isRunning())
await sandbox.pause()
assert.isFalse(await sandbox.isRunning())
const resumedSandbox = await sandbox.connect()
assert.equal(resumedSandbox.sandboxId, sandbox.sandboxId)
assert.isTrue(await sandbox.isRunning())
}
)
describe('pause and resume with env vars', () => {
sandboxTest.scoped({
sandboxOpts: {
envs: { TEST_VAR: 'sfisback' },
},
})
sandboxTest.skipIf(isDebug)(
'pause and resume a sandbox with env vars',
async ({ sandbox }) => {
// Environment variables of a process exist at runtime, and are not stored in some file or so.
// They are stored in the process's own memory
const cmd = await sandbox.commands.run('echo "$TEST_VAR"')
assert.equal(cmd.exitCode, 0)
assert.equal(cmd.stdout.trim(), 'sfisback')
await sandbox.pause()
assert.isFalse(await sandbox.isRunning())
const resumedSandbox = await sandbox.connect()
assert.isTrue(await sandbox.isRunning())
assert.isTrue(await resumedSandbox.isRunning())
assert.equal(resumedSandbox.sandboxId, sandbox.sandboxId)
const cmd2 = await sandbox.commands.run('echo "$TEST_VAR"')
assert.equal(cmd2.exitCode, 0)
assert.equal(cmd2.stdout.trim(), 'sfisback')
}
)
})
sandboxTest.skipIf(isDebug)(
'pause and resume a sandbox with file',
async ({ sandbox }) => {
const filename = 'test_snapshot.txt'
const content = 'This is a snapshot test file.'
const info = await sandbox.files.write(filename, content)
assert.equal(info.name, filename)
assert.equal(info.type, 'file')
assert.equal(info.path, `/home/user/${filename}`)
const exists = await sandbox.files.exists(filename)
assert.isTrue(exists)
const readContent = await sandbox.files.read(filename)
assert.equal(readContent, content)
await sandbox.pause()
assert.isFalse(await sandbox.isRunning())
await sandbox.connect()
assert.isTrue(await sandbox.isRunning())
const exists2 = await sandbox.files.exists(filename)
assert.isTrue(exists2)
const readContent2 = await sandbox.files.read(filename)
assert.equal(readContent2, content)
}
)
sandboxTest.skipIf(isDebug)(
'pause and resume a sandbox with ongoing long running process',
async ({ sandbox }) => {
const cmd = await sandbox.commands.run('sleep 3600', { background: true })
const expectedPid = cmd.pid
await sandbox.pause()
assert.isFalse(await sandbox.isRunning())
await sandbox.connect()
assert.isTrue(await sandbox.isRunning())
// First check that the command is in list
const list = await sandbox.commands.list()
assert.isTrue(list.some((c) => c.pid === expectedPid))
// Make sure we can connect to it
const processInfo = await sandbox.commands.connect(expectedPid)
assert.isObject(processInfo)
assert.equal(processInfo.pid, expectedPid)
}
)
sandboxTest.skipIf(isDebug)(
'pause and resume a sandbox with completed long running process',
async ({ sandbox }) => {
const filename = 'test_long_running.txt'
await sandbox.commands.run(
`sleep 2 && echo "done" > /home/user/${filename}`,
{
background: true,
}
)
// the file should not exist before 2 seconds have elapsed
const exists = await sandbox.files.exists(filename)
assert.isFalse(exists)
await sandbox.pause()
assert.isFalse(await sandbox.isRunning())
await sandbox.connect()
assert.isTrue(await sandbox.isRunning())
// the file should be created after more than 2 seconds have elapsed
await new Promise((resolve) => setTimeout(resolve, 2000))
const exists2 = await sandbox.files.exists(filename)
assert.isTrue(exists2)
const readContent2 = await sandbox.files.read(filename)
assert.equal(readContent2.trim(), 'done')
}
)
sandboxTest.skipIf(isDebug)(
'pause and resume a sandbox with http server',
async ({ sandbox }) => {
await sandbox.commands.run('python3 -m http.server 8000', {
background: true,
})
let url = await sandbox.getHost(8000)
await new Promise((resolve) => setTimeout(resolve, 5000))
const response1 = await fetch(`https://${url}`)
assert.equal(response1.status, 200)
await sandbox.pause()
assert.isFalse(await sandbox.isRunning())
await sandbox.connect()
assert.isTrue(await sandbox.isRunning())
url = await sandbox.getHost(8000)
const response2 = await fetch(`https://${url}`)
assert.equal(response2.status, 200)
}
)
sandboxTest.skipIf(isDebug)(
'filesystem-only pause reboots on resume but keeps the filesystem',
async ({ sandbox }) => {
// Absolute path: a cold boot may not restore the template's default
// user/cwd, so a relative path could resolve differently after resume.
const filename = '/home/user/fs_only_snapshot.txt'
const content = 'This is a filesystem-only snapshot test file.'
await sandbox.files.write(filename, content)
// Kernel boot id before the pause; it changes only across a real (cold) boot.
const bootBefore = (
await sandbox.files.read('/proc/sys/kernel/random/boot_id')
).trim()
// Filesystem-only snapshot: no memory is captured, so resuming cold-boots.
await sandbox.pause({ keepMemory: false })
assert.isFalse(await sandbox.isRunning())
// Resume the paused sandbox; a filesystem-only pause keeps no memory, so
// connect() cold-boots (reboots) it. connect() returns the same handle, and
// its credentials stay valid across the resume (the backend re-binds the
// same envd access token on the cold boot).
const resumedSandbox = await sandbox.connect()
assert.equal(resumedSandbox.sandboxId, sandbox.sandboxId)
assert.isTrue(await resumedSandbox.isRunning())
// The filesystem survives the cold boot.
assert.isTrue(await resumedSandbox.files.exists(filename))
assert.equal(await resumedSandbox.files.read(filename), content)
// A fresh boot id proves the guest rebooted rather than restoring memory.
const bootAfter = (
await resumedSandbox.files.read('/proc/sys/kernel/random/boot_id')
).trim()
assert.notEqual(bootAfter, bootBefore)
}
)
@@ -0,0 +1,31 @@
import { expect } from 'vitest'
import { sandboxTest, isDebug, wait } from '../setup.js'
sandboxTest.skipIf(isDebug)('shorten timeout', async ({ sandbox }) => {
await sandbox.setTimeout(5000)
await wait(6000)
expect(await sandbox.isRunning()).toBeFalsy()
})
sandboxTest.skipIf(isDebug)(
'shorten then lengthen timeout',
async ({ sandbox }) => {
await sandbox.setTimeout(5000)
await wait(1000)
await sandbox.setTimeout(10000)
await wait(6000)
expect(await sandbox.isRunning()).toBeTruthy()
}
)
sandboxTest.skipIf(isDebug)('get sandbox timeout', async ({ sandbox }) => {
const { endAt } = await sandbox.getInfo()
expect(endAt).toBeInstanceOf(Date)
})
@@ -0,0 +1,90 @@
import { assert, describe, test } from 'vitest'
import { getSignature, InvalidArgumentError, Sandbox } from '../../src'
import { TEST_API_KEY } from '../setup'
function createSandbox(envdAccessToken?: string) {
return new Sandbox({
sandboxId: 'sandbox-id',
sandboxDomain: 'e2b.app',
envdVersion: '0.4.0',
envdAccessToken,
apiKey: TEST_API_KEY,
domain: 'e2b.app',
debug: false,
})
}
describe('sandbox file URLs', () => {
test('file URLs use direct sandbox host when envd API uses stable host', async () => {
const sandbox = createSandbox()
assert.equal(sandbox['envdApiUrl'], 'https://sandbox.e2b.app')
assert.equal(sandbox['envdDirectUrl'], 'https://49983-sandbox-id.e2b.app')
assert.equal(
await sandbox.downloadUrl('/tmp/a.txt'),
'https://49983-sandbox-id.e2b.app/files?path=%2Ftmp%2Fa.txt'
)
assert.equal(
await sandbox.uploadUrl('/tmp/a.txt'),
'https://49983-sandbox-id.e2b.app/files?path=%2Ftmp%2Fa.txt'
)
})
test('throws when signature expiration is used on unsecured sandbox', async () => {
const sandbox = createSandbox()
await Promise.all(
[
sandbox.downloadUrl('/tmp/a.txt', { useSignatureExpiration: 120 }),
sandbox.uploadUrl('/tmp/a.txt', { useSignatureExpiration: 120 }),
].map((promise) =>
promise.then(
() => assert.fail('expected InvalidArgumentError'),
(err) => {
assert.instanceOf(err, InvalidArgumentError)
assert.equal(
err.message,
'Signature expiration can be used only when sandbox is created as secured.'
)
}
)
)
)
})
test('zero signature expiration expires immediately', async () => {
const before = Math.floor(Date.now() / 1000)
const signature = await getSignature({
path: '/tmp/a.txt',
operation: 'read',
user: 'user',
envdAccessToken: 'access-token',
expirationInSeconds: 0,
})
const after = Math.floor(Date.now() / 1000)
assert.isNotNull(signature.expiration)
assert.isAtLeast(signature.expiration!, before)
assert.isAtMost(signature.expiration!, after)
})
test('zero signature expiration is included in URL', async () => {
const sandbox = createSandbox('access-token')
for (const url of [
await sandbox.downloadUrl('/tmp/a.txt', { useSignatureExpiration: 0 }),
await sandbox.uploadUrl('/tmp/a.txt', { useSignatureExpiration: 0 }),
]) {
const expiration = new URL(url).searchParams.get('signature_expiration')
assert.isNotNull(expiration)
assert.approximately(
parseInt(expiration!),
Math.floor(Date.now() / 1000),
5
)
}
})
})
+167
View File
@@ -0,0 +1,167 @@
import { test as base, onTestFailed } from 'vitest'
import {
BuildInfo,
LogEntry,
Sandbox,
SandboxOpts,
Template,
TemplateClass,
Volume,
} from '../src'
import { template } from './template'
interface SandboxFixture {
sandbox: Sandbox
template: string
sandboxTestId: string
sandboxOpts: Partial<SandboxOpts>
}
interface VolumeFixture {
volume: Volume
}
interface BuildTemplateFixture {
buildTemplate: (
template: TemplateClass,
options?: { name?: string; skipCache?: boolean },
onBuildLogs?: (logEntry: LogEntry) => void
) => Promise<BuildInfo>
}
async function buildTemplate(
template: TemplateClass,
options?: { name?: string; skipCache?: boolean },
onBuildLogs?: (logEntry: LogEntry) => void
): Promise<BuildInfo> {
const buildName = options?.name || `e2b-test-${generateRandomString()}`
const buildInfo: { templateId?: string; buildId?: string } = {}
const captureLogs = (log: LogEntry) => {
if (log.message.includes('Template created with ID:')) {
const match = log.message.match(
/Template created with ID: ([^,]+), Build ID: (.+)/
)
if (match) {
buildInfo.templateId = match[1]
buildInfo.buildId = match[2]
}
}
onBuildLogs?.(log)
}
try {
return await Template.build(template, buildName, {
cpuCount: 1,
memoryMB: 1024,
skipCache: options?.skipCache,
onBuildLogs: captureLogs,
})
} catch (e) {
console.error(
`\n[BUILD FAILED] name=${buildName}, ` +
`template_id=${buildInfo.templateId}, ` +
`build_id=${buildInfo.buildId}, error=${e}`
)
throw e
}
}
export const sandboxTest = base.extend<SandboxFixture>({
template,
sandboxTestId: [
// eslint-disable-next-line no-empty-pattern
async ({}, use) => {
const id = `test-${generateRandomString()}`
await use(id)
},
{ auto: true },
],
sandboxOpts: {},
sandbox: [
async ({ sandboxTestId, sandboxOpts }, use) => {
const sandbox = await Sandbox.create(template, {
metadata: { sandboxTestId },
...sandboxOpts,
})
onTestFailed(() => {
console.error(`\n[TEST FAILED] Sandbox ID: ${sandbox.sandboxId}`)
})
try {
await use(sandbox)
} finally {
try {
await sandbox.kill()
} catch (err) {
if (!isDebug) {
console.warn(
'Failed to kill sandbox — this is expected if the test runs with local envd.'
)
}
}
}
},
{ auto: false },
],
})
export const buildTemplateTest = base.extend<BuildTemplateFixture>({
buildTemplate: [
// eslint-disable-next-line no-empty-pattern
async ({}, use) => {
await use(buildTemplate)
},
{ auto: true },
],
})
export const volumeTest = base
.extend<VolumeFixture>({
volume: [
// eslint-disable-next-line no-empty-pattern
async ({}, use) => {
const volume = await Volume.create(`test-vol-${generateRandomString()}`)
onTestFailed(() => {
console.error(`\n[TEST FAILED] Volume ID: ${volume.volumeId}`)
})
try {
await use(volume)
} finally {
try {
await Volume.destroy(volume.volumeId)
} catch {
// Ignore cleanup errors
}
}
},
{ auto: false },
],
})
.skipIf(process.env.ENABLE_VOLUME_TESTS === undefined)
export const isDebug = process.env.E2B_DEBUG !== undefined
export const isIntegrationTest = process.env.E2B_INTEGRATION_TEST !== undefined
/** Placeholder API key with a valid format for tests that don't hit the API. */
export const TEST_API_KEY = `e2b_${'0'.repeat(40)}`
function generateRandomString(length: number = 8): string {
return Math.random()
.toString(36)
.substring(2, length + 2)
}
export async function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* Returns the API URL for the given path, using E2B_DOMAIN env var.
* Supports msw path parameters like :templateID
*/
export function apiUrl(path: string): string {
const domain = process.env.E2B_DOMAIN || 'e2b.app'
return `https://api.${domain}${path}`
}
export { template }
+1
View File
@@ -0,0 +1 @@
export const template = 'base'
@@ -0,0 +1,197 @@
import { afterAll, afterEach, beforeAll, expect, test } from 'vitest'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { Template } from '../../src'
import { TEST_API_KEY, apiUrl } from '../setup'
// Hold the request open until the caller aborts. If the signal is already
// aborted by the time the handler runs, `addEventListener('abort', …)` would
// never fire — so check `aborted` first to avoid hanging.
function holdUntilAborted(signal: AbortSignal): Promise<never> {
return new Promise<never>((_, reject) => {
const abort = () => reject(new DOMException('aborted', 'AbortError'))
if (signal.aborted) {
abort()
return
}
signal.addEventListener('abort', abort, { once: true })
})
}
const restHandlers = [
http.post(apiUrl('/v3/templates'), async ({ request }) => {
await holdUntilAborted(request.signal)
return HttpResponse.json({})
}),
http.get(apiUrl('/templates/aliases/:alias'), async ({ request }) => {
await holdUntilAborted(request.signal)
return HttpResponse.json({})
}),
http.get(
apiUrl('/templates/:templateID/builds/:buildID/status'),
async ({ request }) => {
await holdUntilAborted(request.signal)
return HttpResponse.json({})
}
),
http.post(apiUrl('/templates/tags'), async ({ request }) => {
await holdUntilAborted(request.signal)
return HttpResponse.json({})
}),
http.delete(apiUrl('/templates/tags'), async ({ request }) => {
await holdUntilAborted(request.signal)
return HttpResponse.json({})
}),
http.get(apiUrl('/templates/:templateID/tags'), async ({ request }) => {
await holdUntilAborted(request.signal)
return HttpResponse.json([])
}),
]
const server = setupServer(...restHandlers)
beforeAll(() =>
server.listen({
onUnhandledRequest: 'bypass',
})
)
afterAll(() => server.close())
afterEach(() => server.resetHandlers())
// Resolves once MSW has dispatched the next request, so tests can abort
// deterministically instead of guessing with `setTimeout`.
function nextRequestStart(): Promise<void> {
return new Promise<void>((resolve) => {
const listener = () => {
server.events.removeListener('request:start', listener)
resolve()
}
server.events.on('request:start', listener)
})
}
test('Template.build rejects when AbortSignal is aborted', async () => {
const controller = new AbortController()
const requestStarted = nextRequestStart()
const template = Template().fromBaseImage()
const promise = Template.build(template, 'test-template', {
apiKey: TEST_API_KEY,
signal: controller.signal,
})
await requestStarted
controller.abort()
await expect(promise).rejects.toThrow()
})
test('Template.build rejects immediately when signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
const template = Template().fromBaseImage()
await expect(
Template.build(template, 'test-template', {
apiKey: TEST_API_KEY,
signal: controller.signal,
})
).rejects.toThrow()
})
test('Template.buildInBackground rejects when AbortSignal is aborted', async () => {
const controller = new AbortController()
const requestStarted = nextRequestStart()
const template = Template().fromBaseImage()
const promise = Template.buildInBackground(template, 'test-template', {
apiKey: TEST_API_KEY,
signal: controller.signal,
})
await requestStarted
controller.abort()
await expect(promise).rejects.toThrow()
})
test('Template.exists rejects when AbortSignal is aborted', async () => {
const controller = new AbortController()
const requestStarted = nextRequestStart()
const promise = Template.exists('some-template', {
apiKey: TEST_API_KEY,
signal: controller.signal,
})
await requestStarted
controller.abort()
await expect(promise).rejects.toThrow()
})
test('Template.getBuildStatus rejects when AbortSignal is aborted', async () => {
const controller = new AbortController()
const requestStarted = nextRequestStart()
const promise = Template.getBuildStatus(
{ templateId: 'tpl-1', buildId: 'build-1' },
{
apiKey: TEST_API_KEY,
signal: controller.signal,
}
)
await requestStarted
controller.abort()
await expect(promise).rejects.toThrow()
})
test('Template.assignTags rejects when AbortSignal is aborted', async () => {
const controller = new AbortController()
const requestStarted = nextRequestStart()
const promise = Template.assignTags('some-template:v1', 'stable', {
apiKey: TEST_API_KEY,
signal: controller.signal,
})
await requestStarted
controller.abort()
await expect(promise).rejects.toThrow()
})
test('Template.removeTags rejects when AbortSignal is aborted', async () => {
const controller = new AbortController()
const requestStarted = nextRequestStart()
const promise = Template.removeTags('some-template', 'stable', {
apiKey: TEST_API_KEY,
signal: controller.signal,
})
await requestStarted
controller.abort()
await expect(promise).rejects.toThrow()
})
test('Template.getTags rejects when AbortSignal is aborted', async () => {
const controller = new AbortController()
const requestStarted = nextRequestStart()
const promise = Template.getTags('some-template', {
apiKey: TEST_API_KEY,
signal: controller.signal,
})
await requestStarted
controller.abort()
await expect(promise).rejects.toThrow()
})
@@ -0,0 +1,25 @@
import { randomUUID } from 'node:crypto'
import { expect, test } from 'vitest'
import { Template, waitForTimeout } from '../../src'
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))
const name = `e2b-test:v1-${randomUUID()}`
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()
// Verify the build is actually running
const status = await Template.getBuildStatus(buildInfo)
expect(status.status).toEqual('building')
}, 10_000)
@@ -0,0 +1,74 @@
import fs from 'node:fs'
import path from 'node:path'
import { afterAll, beforeAll } from 'vitest'
import { defaultBuildLogger, Template, waitForTimeout } from '../../src'
import { buildTemplateTest } from '../setup'
const folderPath = path.join(__dirname, 'folder')
beforeAll(async () => {
fs.mkdirSync(folderPath, { recursive: true })
fs.writeFileSync(path.join(folderPath, 'test.txt'), 'This is a test file.')
// Create relative symlink
fs.symlinkSync('test.txt', path.join(folderPath, 'symlink.txt'))
// Create absolute symlink
fs.symlinkSync(
path.join(folderPath, 'test.txt'),
path.join(folderPath, 'symlink2.txt')
)
// Create a symlink to a file that does not exist
fs.symlinkSync('12345test.txt', path.join(folderPath, 'symlink3.txt'))
})
afterAll(() => {
fs.rmSync(folderPath, { recursive: true })
})
buildTemplateTest('build template', async ({ buildTemplate }) => {
const template = Template()
// using base image to avoid re-building ubuntu:22.04 image
.fromBaseImage()
.copy('folder/*', 'folder', { forceUpload: true })
.runCmd('cat folder/test.txt')
.setWorkdir('/app')
.setStartCmd('echo "Hello, world!"', waitForTimeout(10_000))
await buildTemplate(template, { skipCache: true }, defaultBuildLogger())
})
buildTemplateTest(
'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()
.fromImage('ubuntu:22.04')
.skipCache()
.copy('folder/*', 'folder', { forceUpload: true })
.runCmd('cat folder/symlink.txt')
await buildTemplate(template)
})
buildTemplateTest(
'build template with resolveSymlinks',
async ({ buildTemplate }) => {
const template = Template()
.fromImage('ubuntu:22.04')
.skipCache()
.copy('folder/symlink.txt', 'folder/symlink.txt', {
forceUpload: true,
resolveSymlinks: true,
})
.runCmd('cat folder/symlink.txt')
await buildTemplate(template)
}
)
@@ -0,0 +1,14 @@
import { randomUUID } from 'node:crypto'
import { expect, test } from 'vitest'
import { Template } from '../../src'
test('check if base template name exists', async () => {
const exists = await Template.exists('base')
expect(exists).toBe(true)
})
test('check non existing name', async () => {
const nonExistingName = `nonexistent-${randomUUID()}`
const exists = await Template.exists(nonExistingName)
expect(exists).toBe(false)
})
@@ -0,0 +1,198 @@
import { buildTemplateTest } from '../../setup'
import { Template } from '../../../src'
import { InstructionType } from '../../../src/template/types'
import { assert } from 'vitest'
buildTemplateTest('fromDockerfile', async () => {
const dockerfile = `FROM node:24
WORKDIR /app
COPY package.json .
RUN npm install
ENTRYPOINT ["sleep", "20"]`
const template = Template().fromDockerfile(dockerfile)
assert.equal(
// @ts-expect-error - baseImage is not a property of TemplateBuilder
template.baseImage,
'node:24'
)
assert.equal(
// @ts-expect-error - instructions is not a property of TemplateBuilder
template.instructions[1].type,
InstructionType.WORKDIR
)
assert.equal(
// @ts-expect-error - instructions is not a property of TemplateBuilder
template.instructions[1].args[0],
'/'
)
assert.equal(
// @ts-expect-error - instructions is not a property of TemplateBuilder
template.instructions[2].type,
InstructionType.WORKDIR
)
assert.equal(
// @ts-expect-error - instructions is not a property of TemplateBuilder
template.instructions[2].args[0],
'/app'
)
assert.equal(
// @ts-expect-error - instructions is not a property of TemplateBuilder
template.instructions[3].type,
InstructionType.COPY
)
assert.equal(
// @ts-expect-error - instructions is not a property of TemplateBuilder
template.instructions[3].args[0],
'package.json'
)
assert.equal(
// @ts-expect-error - instructions is not a property of TemplateBuilder
template.instructions[3].args[1],
'.'
)
assert.equal(
// @ts-expect-error - instructions is not a property of TemplateBuilder
template.instructions[4].type,
InstructionType.RUN
)
assert.equal(
// @ts-expect-error - instructions is not a property of TemplateBuilder
template.instructions[4].args[0],
'npm install'
)
assert.equal(
// @ts-expect-error - instructions is not a property of TemplateBuilder
template.instructions[5].type,
InstructionType.USER
)
assert.equal(
// @ts-expect-error - instructions is not a property of TemplateBuilder
template.instructions[5].args[0],
'user'
)
assert.equal(
// @ts-expect-error - startCmd is not a property of TemplateBuilder
template.startCmd,
'sleep 20'
)
})
buildTemplateTest('fromDockerfile with default user and workdir', () => {
const dockerfile = 'FROM node:24'
const template = Template().fromDockerfile(dockerfile)
assert.equal(
// @ts-expect-error - instructions is not a property of TemplateBuilder
template.instructions[template.instructions.length - 2].type,
InstructionType.USER
)
assert.equal(
// @ts-expect-error - instructions is not a property of TemplateBuilder
template.instructions[template.instructions.length - 2].args[0],
'user'
)
assert.equal(
// @ts-expect-error - instructions is not a property of TemplateBuilder
template.instructions[template.instructions.length - 1].type,
InstructionType.WORKDIR
)
assert.equal(
// @ts-expect-error - instructions is not a property of TemplateBuilder
template.instructions[template.instructions.length - 1].args[0],
'/home/user'
)
})
buildTemplateTest('fromDockerfile with custom user and workdir', () => {
const dockerfile = 'FROM node:24\nUSER mish\nWORKDIR /home/mish'
const template = Template().fromDockerfile(dockerfile)
assert.equal(
// @ts-expect-error - instructions is not a property of TemplateBuilder
template.instructions[template.instructions.length - 2].type,
InstructionType.USER
)
assert.equal(
// @ts-expect-error - instructions is not a property of TemplateBuilder
template.instructions[template.instructions.length - 2].args[0],
'mish'
)
assert.equal(
// @ts-expect-error - instructions is not a property of TemplateBuilder
template.instructions[template.instructions.length - 1].type,
InstructionType.WORKDIR
)
assert.equal(
// @ts-expect-error - instructions is not a property of TemplateBuilder
template.instructions[template.instructions.length - 1].args[0],
'/home/mish'
)
})
buildTemplateTest('fromDockerfile with multi-source COPY', () => {
const dockerfile = `FROM node:24
COPY file1.txt file2.txt file3.txt /dest/`
const template = Template().fromDockerfile(dockerfile)
// After initial USER root and WORKDIR /, the multi-source COPY should
// expand into one COPY instruction per source.
// @ts-expect-error - instructions is not a property of TemplateBuilder
const copyInstructions = template.instructions.filter(
(i: { type: InstructionType }) => i.type === InstructionType.COPY
)
assert.equal(copyInstructions.length, 3)
assert.equal(copyInstructions[0].args[0], 'file1.txt')
assert.equal(copyInstructions[0].args[1], '/dest/')
assert.equal(copyInstructions[1].args[0], 'file2.txt')
assert.equal(copyInstructions[1].args[1], '/dest/')
assert.equal(copyInstructions[2].args[0], 'file3.txt')
assert.equal(copyInstructions[2].args[1], '/dest/')
})
buildTemplateTest('fromDockerfile with multi-source COPY --chown', () => {
const dockerfile = `FROM node:24
COPY --chown=myuser:mygroup pkg.json pkg-lock.json /app/`
const template = Template().fromDockerfile(dockerfile)
// @ts-expect-error - instructions is not a property of TemplateBuilder
const copyInstructions = template.instructions.filter(
(i: { type: InstructionType }) => i.type === InstructionType.COPY
)
assert.equal(copyInstructions.length, 2)
assert.equal(copyInstructions[0].args[0], 'pkg.json')
assert.equal(copyInstructions[0].args[1], '/app/')
assert.equal(copyInstructions[0].args[2], 'myuser:mygroup')
assert.equal(copyInstructions[1].args[0], 'pkg-lock.json')
assert.equal(copyInstructions[1].args[1], '/app/')
assert.equal(copyInstructions[1].args[2], 'myuser:mygroup')
})
buildTemplateTest('fromDockerfile with COPY --chown', () => {
const dockerfile = `FROM node:24
COPY --chown=myuser:mygroup app.js /app/
COPY --chown=anotheruser config.json /config/`
const template = Template().fromDockerfile(dockerfile)
// First COPY instruction (after initial USER root and WORKDIR /)
// @ts-expect-error - instructions is not a property of TemplateBuilder
const copyInstruction1 = template.instructions[2]
assert.equal(copyInstruction1.type, InstructionType.COPY)
assert.equal(copyInstruction1.args[0], 'app.js')
assert.equal(copyInstruction1.args[1], '/app/')
assert.equal(copyInstruction1.args[2], 'myuser:mygroup') // user from --chown
// Second COPY instruction
// @ts-expect-error - instructions is not a property of TemplateBuilder
const copyInstruction2 = template.instructions[3]
assert.equal(copyInstruction2.type, InstructionType.COPY)
assert.equal(copyInstruction2.args[0], 'config.json')
assert.equal(copyInstruction2.args[1], '/config/')
assert.equal(copyInstruction2.args[2], 'anotheruser') // user from --chown (without group)
})
@@ -0,0 +1,23 @@
import { Template } from '../../../src'
import { buildTemplateTest } from '../../setup'
buildTemplateTest('make symlink', async ({ buildTemplate }) => {
const template = Template()
.fromImage('ubuntu:22.04')
.skipCache()
.makeSymlink('.bashrc', '.bashrc.local')
.runCmd('test "$(readlink .bashrc.local)" = ".bashrc"')
await buildTemplate(template)
})
buildTemplateTest('make symlink (force)', async ({ buildTemplate }) => {
const template = Template()
.fromImage('ubuntu:22.04')
.makeSymlink('.bashrc', '.bashrc.local')
.skipCache()
.makeSymlink('.bashrc', '.bashrc.local', { force: true }) // Overwrite existing symlink
.runCmd('test "$(readlink .bashrc.local)" = ".bashrc"')
await buildTemplate(template)
})
@@ -0,0 +1,38 @@
import { expect } from 'vitest'
import { Template } from '../../../src'
import { buildTemplateTest } from '../../setup'
buildTemplateTest('run command', async ({ buildTemplate }) => {
const template = Template()
.fromImage('ubuntu:22.04')
.skipCache()
.runCmd('ls -l')
await buildTemplate(template)
})
buildTemplateTest(
'run command as a different user',
async ({ buildTemplate }) => {
const template = Template()
.fromImage('ubuntu:22.04')
.skipCache()
.runCmd('test "$(whoami)" = "root"', { user: 'root' })
await buildTemplate(template)
}
)
buildTemplateTest(
'run command as user that does not exist',
async ({ buildTemplate }) => {
const template = Template()
.fromImage('ubuntu:22.04')
.skipCache()
.runCmd('whoami', { user: 'root123' })
await expect(buildTemplate(template)).rejects.toThrow(
"failed to run command 'whoami': command failed: unauthenticated: invalid username: 'root123'"
)
}
)
@@ -0,0 +1,47 @@
import { expect, test } from 'vitest'
import { Template } from '../../../src'
test('toDockerfile', { timeout: 180000 }, async () => {
const template = Template()
.fromUbuntuImage('24.04')
.copy('README.md', '/app/README.md')
.runCmd('echo "Hello, World!"')
const dockerfile = Template.toDockerfile(template)
const expectedDockerfile = `FROM ubuntu:24.04
COPY README.md /app/README.md
RUN echo "Hello, World!"
`
expect(dockerfile).toBe(expectedDockerfile)
})
test('toDockerfile with options', { timeout: 180000 }, async () => {
const template = Template()
.fromUbuntuImage('24.04')
.copy('README.md', '/app/README.md', { user: 'root' })
.runCmd('echo "Hello, World!"', { user: 'root' })
const dockerfile = Template.toDockerfile(template)
const expectedDockerfile = `FROM ubuntu:24.04
COPY README.md /app/README.md
RUN echo "Hello, World!"
`
expect(dockerfile).toBe(expectedDockerfile)
})
test('toDockerfile with ENV instructions', { timeout: 180000 }, async () => {
const template = Template()
.fromUbuntuImage('24.04')
.setEnvs({ NODE_ENV: 'production', PORT: '8080' })
.setEnvs({ DEBUG: 'false' })
const dockerfile = Template.toDockerfile(template)
const expectedDockerfile = `FROM ubuntu:24.04
ENV NODE_ENV=production PORT=8080
ENV DEBUG=false
`
expect(dockerfile).toBe(expectedDockerfile)
})
@@ -0,0 +1,434 @@
import fs from 'node:fs'
import { assert, afterAll, afterEach, beforeAll } from 'vitest'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { Template, waitForTimeout } from '../../src'
import { apiUrl, buildTemplateTest } from '../setup'
import { randomUUID } from 'node:crypto'
const __fileContent = fs.readFileSync(__filename, 'utf8') // read current file content
const nonExistentPath = 'nonexistent/path'
// map template alias -> failed step index
const failureMap: Record<string, number | undefined> = {
fromImage: 0,
fromTemplate: 0,
fromDockerfile: 0,
fromImageRegistry: 0,
fromAWSRegistry: 0,
fromGCPRegistry: 0,
copy: undefined,
copyItems: undefined,
// multi-source copy produces two COPY instructions (steps 1 and 2),
// the runCmd after it is step 3
multiSourceCopySecondSource: 2,
multiSourceCopyNextStep: 3,
copyItemsSecondItem: 2,
copyItemsNextStep: 3,
remove: 1,
rename: 1,
makeDir: 1,
makeSymlink: 1,
runCmd: 1,
setWorkdir: 1,
setUser: 1,
pipInstall: 1,
npmInstall: 1,
aptInstall: 1,
gitClone: 1,
setStartCmd: 1,
addMcpServer: undefined,
betaDevContainerPrebuild: 1,
betaSetDevContainerStart: 1,
}
export const restHandlers = [
http.post(apiUrl('/v3/templates'), async ({ request }) => {
const { name } = (await request.clone().json()) as { name: string }
return HttpResponse.json({
buildID: randomUUID(),
templateID: name,
tags: [],
})
}),
http.post(apiUrl('/v2/templates/:templateID/builds/:buildID'), () => {
return HttpResponse.json({})
}),
http.get(apiUrl('/templates/:templateID/files/:hash'), () => {
return HttpResponse.json({ present: true })
}),
http.get<{ templateID: string; buildID: string }>(
apiUrl('/templates/:templateID/builds/:buildID/status'),
({ params }) => {
const { templateID } = params
return HttpResponse.json({
status: 'error',
reason: {
message: 'Mocked API build error',
step: failureMap[templateID],
},
logEntries: [],
})
}
),
]
const server = setupServer(...restHandlers)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterAll(() => server.close())
afterEach(() => server.resetHandlers())
function getStackTraceCallerMethod(
fileContent: string,
stackTrace: string | undefined
) {
if (!stackTrace) {
return null
}
const stackTraceLines = stackTrace.split('\n')
if (stackTraceLines.length === 0) {
return null
}
const callerTrace = stackTraceLines[0]
// Match line and column numbers at the end of the stack trace line
// Format: ...file.ts:123:45) or ...file.ts:123:45
// This handles Windows paths (C:\Users\...) and Unix paths
const lineColumnMatch = callerTrace.match(/:(\d+):(\d+)\)?$/)
if (!lineColumnMatch) {
return null
}
const lineNumber = parseInt(lineColumnMatch[1])
const columnNumber = parseInt(lineColumnMatch[2])
const lines = fileContent.split('\n')
const parsedLine = lines[lineNumber - 1]
if (!parsedLine) {
return null
}
// Extract the method name from the line
const methodNameMatch = parsedLine
.slice(columnNumber - 1)
.match(/^(\w+)\s*\(/)
if (methodNameMatch) {
return methodNameMatch[1]
}
return null
}
async function expectToThrowAndCheckTrace(
func: (...args: any[]) => Promise<void>,
expectedMethod: string
) {
try {
await func()
assert.fail('Expected Template.build to throw an error')
} catch (error) {
const callerMethod = getStackTraceCallerMethod(__fileContent, error.stack)
if (!callerMethod) {
throw error
}
assert.include(callerMethod, expectedMethod)
}
}
buildTemplateTest('traces on fromImage', async ({ buildTemplate }) => {
const template = Template().fromImage('e2b.dev/this-image-does-not-exist')
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'fromImage', skipCache: true })
}, 'fromImage')
})
buildTemplateTest('traces on fromTemplate', async ({ buildTemplate }) => {
const template = Template().fromTemplate('this-template-does-not-exist')
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'fromTemplate', skipCache: true })
}, 'fromTemplate')
})
buildTemplateTest('traces on fromDockerfile', async ({ buildTemplate }) => {
const template = Template().fromDockerfile(
'FROM ubuntu:22.04\nRUN nonexistent'
)
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'fromDockerfile', skipCache: true })
}, 'fromDockerfile')
})
buildTemplateTest('traces on fromImage registry', async ({ buildTemplate }) => {
const template = Template().fromImage(
'registry.example.com/nonexistent:latest',
{
username: 'test',
password: 'test',
}
)
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, {
name: 'fromImageRegistry',
})
}, 'fromImage')
})
buildTemplateTest('traces on fromAWSRegistry', async ({ buildTemplate }) => {
const template = Template().fromAWSRegistry(
'123456789.dkr.ecr.us-east-1.amazonaws.com/nonexistent:latest',
{
accessKeyId: 'test',
secretAccessKey: 'test',
region: 'us-east-1',
}
)
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'fromAWSRegistry' })
}, 'fromAWSRegistry')
})
buildTemplateTest('traces on fromGCPRegistry', async ({ buildTemplate }) => {
const template = Template().fromGCPRegistry(
'gcr.io/nonexistent-project/nonexistent:latest',
{
serviceAccountJSON: { type: 'service_account' },
}
)
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'fromGCPRegistry' })
}, 'fromGCPRegistry')
})
buildTemplateTest('traces on fromImage credentials', async () => {
await expectToThrowAndCheckTrace(async () => {
// @ts-expect-error - testing runtime validation with partial credentials
Template().fromImage('ubuntu:22.04', { username: 'user' })
}, 'fromImage')
})
buildTemplateTest('traces on copy', async ({ buildTemplate }) => {
let template = Template().fromBaseImage()
template = template.skipCache().copy(nonExistentPath, nonExistentPath)
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'copy' })
}, 'copy')
})
buildTemplateTest('traces on copyItems', async ({ buildTemplate }) => {
let template = Template().fromBaseImage()
template = template
.skipCache()
.copyItems([{ src: nonExistentPath, dest: nonExistentPath }])
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'copyItems' })
}, 'copyItems')
})
buildTemplateTest(
'traces on second source of multi-source copy',
async ({ buildTemplate }) => {
let template = Template().fromBaseImage()
template = template.copy(['stacktrace.test.ts', 'tags.test.ts'], '.')
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'multiSourceCopySecondSource' })
}, 'copy')
}
)
buildTemplateTest(
'traces on step after multi-source copy',
async ({ buildTemplate }) => {
let template = Template().fromBaseImage()
template = template
.copy(['stacktrace.test.ts', 'tags.test.ts'], '.')
.runCmd(`./${nonExistentPath}`)
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'multiSourceCopyNextStep' })
}, 'runCmd')
}
)
buildTemplateTest(
'traces on second item of copyItems',
async ({ buildTemplate }) => {
let template = Template().fromBaseImage()
template = template.copyItems([
{ src: 'stacktrace.test.ts', dest: '.' },
{ src: 'tags.test.ts', dest: '.' },
])
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'copyItemsSecondItem' })
}, 'copyItems')
}
)
buildTemplateTest(
'traces on step after copyItems',
async ({ buildTemplate }) => {
let template = Template().fromBaseImage()
template = template
.copyItems([
{ src: 'stacktrace.test.ts', dest: '.' },
{ src: 'tags.test.ts', dest: '.' },
])
.runCmd(`./${nonExistentPath}`)
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'copyItemsNextStep' })
}, 'runCmd')
}
)
buildTemplateTest('traces on copy absolute path', async () => {
await expectToThrowAndCheckTrace(async () => {
Template().fromBaseImage().copy('/absolute/path', '/absolute/path')
}, 'copy')
})
buildTemplateTest('traces on copyItems absolute path', async () => {
await expectToThrowAndCheckTrace(async () => {
Template()
.fromBaseImage()
.copyItems([{ src: '/absolute/path', dest: '/absolute/path' }])
}, 'copyItems')
})
buildTemplateTest('traces on remove', async ({ buildTemplate }) => {
let template = Template().fromBaseImage()
template = template.skipCache().remove(nonExistentPath)
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'remove' })
}, 'remove')
})
buildTemplateTest('traces on rename', async ({ buildTemplate }) => {
let template = Template().fromBaseImage()
template = template.skipCache().rename(nonExistentPath, '/tmp/dest.txt')
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'rename' })
}, 'rename')
})
buildTemplateTest('traces on makeDir', async ({ buildTemplate }) => {
let template = Template().fromBaseImage()
template = template.skipCache().makeDir('.bashrc')
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'makeDir' })
}, 'makeDir')
})
buildTemplateTest('traces on makeSymlink', async ({ buildTemplate }) => {
let template = Template().fromBaseImage()
template = template.skipCache().makeSymlink('.bashrc', '.bashrc')
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'makeSymlink' })
}, 'makeSymlink')
})
buildTemplateTest('traces on runCmd', async ({ buildTemplate }) => {
let template = Template().fromBaseImage()
template = template.skipCache().runCmd(`./${nonExistentPath}`)
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'runCmd' })
}, 'runCmd')
})
buildTemplateTest('traces on setWorkdir', async ({ buildTemplate }) => {
let template = Template().fromBaseImage()
template = template.skipCache().setWorkdir('/root/.bashrc')
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'setWorkdir' })
}, 'setWorkdir')
})
buildTemplateTest('traces on setUser', async ({ buildTemplate }) => {
let template = Template().fromBaseImage()
template = template.skipCache().setUser('; exit 1')
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'setUser' })
}, 'setUser')
})
buildTemplateTest('traces on pipInstall', async ({ buildTemplate }) => {
let template = Template().fromBaseImage()
template = template.skipCache().pipInstall('nonexistent-package')
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'pipInstall' })
}, 'pipInstall')
})
buildTemplateTest('traces on npmInstall', async ({ buildTemplate }) => {
let template = Template().fromBaseImage()
template = template.skipCache().npmInstall('nonexistent-package')
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'npmInstall' })
}, 'npmInstall')
})
buildTemplateTest('traces on aptInstall', async ({ buildTemplate }) => {
let template = Template().fromBaseImage()
template = template.skipCache().aptInstall('nonexistent-package')
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'aptInstall' })
}, 'aptInstall')
})
buildTemplateTest('traces on gitClone', async ({ buildTemplate }) => {
let template = Template().fromBaseImage()
template = template
.skipCache()
.gitClone('https://github.com/nonexistent/repo.git')
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'gitClone' })
}, 'gitClone')
})
buildTemplateTest('traces on setStartCmd', async ({ buildTemplate }) => {
let template: any = Template().fromBaseImage()
template = template.setStartCmd(
`./${nonExistentPath}`,
waitForTimeout(10_000)
)
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, { name: 'setStartCmd' })
}, 'setStartCmd')
})
buildTemplateTest('traces on addMcpServer', async () => {
// needs mcp-gateway as base template, without it no mcp servers can be added
await expectToThrowAndCheckTrace(async () => {
Template().fromBaseImage().skipCache().addMcpServer('exa')
}, 'addMcpServer')
})
buildTemplateTest(
'traces on betaDevContainerPrebuild',
async ({ buildTemplate }) => {
const template = Template()
.fromTemplate('devcontainer')
.skipCache()
.betaDevContainerPrebuild(nonExistentPath)
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, {
name: 'betaDevContainerPrebuild',
})
}, 'betaDevContainerPrebuild')
}
)
buildTemplateTest(
'traces on betaSetDevContainerStart',
async ({ buildTemplate }) => {
const template = Template()
.fromTemplate('devcontainer')
.betaSetDevContainerStart(nonExistentPath)
await expectToThrowAndCheckTrace(async () => {
await buildTemplate(template, {
name: 'betaSetDevContainerStart',
})
}, 'betaSetDevContainerStart')
}
)
+202
View File
@@ -0,0 +1,202 @@
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'
// Mock handlers for tag API endpoints
const mockHandlers = [
http.post(apiUrl('/templates/tags'), async ({ request }) => {
const { tags } = (await request.clone().json()) as {
tags: string[]
}
return HttpResponse.json({
buildID: '00000000-0000-0000-0000-000000000000',
tags: tags,
})
}),
// Get template tags endpoint
http.get(apiUrl('/templates/:templateID/tags'), ({ params }) => {
const { templateID } = params
if (templateID === 'nonexistent') {
return HttpResponse.json(
{ message: 'Template not found' },
{ status: 404 }
)
}
return HttpResponse.json([
{
tag: 'v1.0',
buildID: '00000000-0000-0000-0000-000000000000',
createdAt: '2024-01-15T10:30:00Z',
},
{
tag: 'latest',
buildID: '11111111-1111-1111-1111-111111111111',
createdAt: '2024-01-16T12:00:00Z',
},
])
}),
// Bulk delete endpoint
http.delete(apiUrl('/templates/tags'), async ({ request }) => {
const { name } = (await request.clone().json()) as {
name: string
tags: string[]
}
if (name === 'nonexistent') {
return HttpResponse.json(
{ message: 'Template not found' },
{ status: 404 }
)
}
return new HttpResponse(null, { status: 204 })
}),
]
const server = setupServer(...mockHandlers)
// Unit tests with mock server
describe('Template tags unit tests', () => {
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterAll(() => server.close())
afterEach(() => server.resetHandlers())
describe('Template.assignTags', () => {
test('assigns a single tag', async () => {
const result = await Template.assignTags('my-template:v1.0', 'production')
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',
])
expect(result.buildId).toBe('00000000-0000-0000-0000-000000000000')
expect(result.tags).toContain('production')
expect(result.tags).toContain('stable')
})
})
describe('Template.removeTags', () => {
test('deletes a single tag', async () => {
// Should not throw
await expect(
Template.removeTags('my-template', 'production')
).resolves.toBeUndefined()
})
test('deletes multiple tags', async () => {
// Should not throw
await expect(
Template.removeTags('my-template', ['production', 'staging'])
).resolves.toBeUndefined()
})
test('handles 404 error for nonexistent template', async () => {
await expect(
Template.removeTags('nonexistent', ['tag'])
).rejects.toThrow()
})
})
describe('Template.getTags', () => {
test('returns tags for a template', async () => {
const tags = await Template.getTags('my-template-id')
expect(tags).toHaveLength(2)
expect(tags[0].tag).toBe('v1.0')
expect(tags[0].buildId).toBe('00000000-0000-0000-0000-000000000000')
expect(tags[0].createdAt).toBeInstanceOf(Date)
expect(tags[1].tag).toBe('latest')
expect(tags[1].buildId).toBe('11111111-1111-1111-1111-111111111111')
expect(tags[1].createdAt).toBeInstanceOf(Date)
})
test('handles 404 for nonexistent template', async () => {
await expect(Template.getTags('nonexistent')).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',
])
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()
}
)
@@ -0,0 +1,70 @@
import { describe, test, expect, beforeAll, afterAll } from 'vitest'
import { writeFile, mkdtemp, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { createServer, type IncomingMessage, type Server } from 'http'
import { AddressInfo } from 'net'
import { uploadFile } from '../../src/template/buildApi'
// Regression test for e2b-dev/e2b#1243 — uploadFile used to pass a Node
// Readable directly to fetch, which made undici fall back to
// Transfer-Encoding: chunked. S3 presigned PUT URLs reject that with 501
// NotImplemented. The fix spools the archive to a temporary file and streams
// it from disk with an explicit Content-Length.
describe('uploadFile transfer encoding', () => {
let testDir: string
let server: Server
let baseUrl: string
let capturedHeaders: IncomingMessage['headers'] = {}
let capturedBodyLength = 0
beforeAll(async () => {
testDir = await mkdtemp(join(tmpdir(), 'uploadFile-test-'))
await writeFile(join(testDir, 'hello.txt'), 'hello world')
server = createServer((req, res) => {
capturedHeaders = req.headers
let bytes = 0
req.on('data', (chunk: Buffer) => {
bytes += chunk.length
})
req.on('end', () => {
capturedBodyLength = bytes
res.writeHead(200)
res.end()
})
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const { port } = server.address() as AddressInfo
baseUrl = `http://127.0.0.1:${port}/upload`
})
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()))
await rm(testDir, { recursive: true, force: true })
})
test('sets Content-Length and does not use chunked transfer encoding', async () => {
await uploadFile(
{
fileName: '*.txt',
fileContextPath: testDir,
url: baseUrl,
ignorePatterns: [],
resolveSymlinks: false,
gzip: true,
},
undefined
)
expect(capturedHeaders['content-length']).toBeDefined()
const contentLength = Number(capturedHeaders['content-length'])
expect(contentLength).toBeGreaterThan(0)
expect(contentLength).toBe(capturedBodyLength)
const transferEncoding = capturedHeaders['transfer-encoding']
if (transferEncoding !== undefined) {
expect(transferEncoding.toLowerCase()).not.toContain('chunked')
}
})
})
@@ -0,0 +1,328 @@
import { expect, test, describe, beforeAll, afterAll, beforeEach } from 'vitest'
import { writeFile, mkdir, rm } from 'fs/promises'
import { join, basename } from 'path'
import { getAllFilesInPath } from '../../../src/template/utils'
describe('getAllFilesInPath', () => {
const testDir = join(__dirname, 'folder')
beforeAll(async () => {
await rm(testDir, { recursive: true, force: true })
await mkdir(testDir, { recursive: true })
})
afterAll(async () => {
await rm(testDir, { recursive: true, force: true })
})
beforeEach(async () => {
await rm(testDir, { recursive: true, force: true })
await mkdir(testDir, { recursive: true })
})
test('should return files matching a simple pattern', async () => {
// Create test files
await writeFile(join(testDir, 'file1.txt'), 'content1')
await writeFile(join(testDir, 'file2.txt'), 'content2')
await writeFile(join(testDir, 'file3.js'), 'content3')
const files = await getAllFilesInPath('*.txt', testDir, [])
expect(files).toHaveLength(2)
expect(files.some((f) => f.fullpath().endsWith('file1.txt'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('file2.txt'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('file3.js'))).toBe(false)
})
test('should handle directory patterns recursively', async () => {
// Create nested directory structure
await mkdir(join(testDir, 'src'), { recursive: true })
await mkdir(join(testDir, 'src', 'components'), { recursive: true })
await mkdir(join(testDir, 'src', 'utils'), { recursive: true })
await writeFile(join(testDir, 'src', 'index.ts'), 'index content')
await writeFile(
join(testDir, 'src', 'components', 'Button.tsx'),
'button content'
)
await writeFile(
join(testDir, 'src', 'utils', 'helper.ts'),
'helper content'
)
await writeFile(join(testDir, 'README.md'), 'readme content')
const files = await getAllFilesInPath('src', testDir, [])
expect(files).toHaveLength(6) // 3 files + 3 directories (src, components, utils)
expect(files.some((f) => f.fullpath().endsWith('index.ts'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('Button.tsx'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('helper.ts'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('README.md'))).toBe(false)
})
test('should respect ignore patterns', async () => {
// Create test files
await writeFile(join(testDir, 'file1.txt'), 'content1')
await writeFile(join(testDir, 'file2.txt'), 'content2')
await writeFile(join(testDir, 'temp.txt'), 'temp content')
await writeFile(join(testDir, 'backup.txt'), 'backup content')
const files = await getAllFilesInPath('*.txt', testDir, [
'temp*',
'backup*',
])
expect(files).toHaveLength(2)
expect(files.some((f) => f.fullpath().endsWith('file1.txt'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('file2.txt'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('temp.txt'))).toBe(false)
expect(files.some((f) => f.fullpath().endsWith('backup.txt'))).toBe(false)
})
test('should handle complex ignore patterns', async () => {
// Create nested structure with various file types
await mkdir(join(testDir, 'src'), { recursive: true })
await mkdir(join(testDir, 'src', 'components'), { recursive: true })
await mkdir(join(testDir, 'src', 'utils'), { recursive: true })
await mkdir(join(testDir, 'tests'), { recursive: true })
await writeFile(join(testDir, 'src', 'index.ts'), 'index content')
await writeFile(
join(testDir, 'src', 'components', 'Button.tsx'),
'button content'
)
await writeFile(
join(testDir, 'src', 'utils', 'helper.ts'),
'helper content'
)
await writeFile(join(testDir, 'tests', 'test.spec.ts'), 'test content')
await writeFile(
join(testDir, 'src', 'components', 'Button.test.tsx'),
'test content'
)
await writeFile(
join(testDir, 'src', 'utils', 'helper.spec.ts'),
'spec content'
)
const files = await getAllFilesInPath('src', testDir, [
'**/*.test.*',
'**/*.spec.*',
])
expect(files).toHaveLength(6) // 3 files + 3 directories (src, components, utils)
expect(files.some((f) => f.fullpath().endsWith('index.ts'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('Button.tsx'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('helper.ts'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('Button.test.tsx'))).toBe(
false
)
expect(files.some((f) => f.fullpath().endsWith('helper.spec.ts'))).toBe(
false
)
})
test('should handle empty directories', async () => {
await mkdir(join(testDir, 'empty'), { recursive: true })
await writeFile(join(testDir, 'file.txt'), 'content')
const files = await getAllFilesInPath('empty', testDir, [])
expect(files).toHaveLength(1) // The empty directory itself
})
test('should handle mixed files and directories', async () => {
// Create a mix of files and directories
await writeFile(join(testDir, 'file1.txt'), 'content1')
await mkdir(join(testDir, 'dir1'), { recursive: true })
await writeFile(join(testDir, 'dir1', 'file2.txt'), 'content2')
await writeFile(join(testDir, 'file3.txt'), 'content3')
const files = await getAllFilesInPath('*', testDir, [])
expect(files).toHaveLength(4) // 3 files + 1 directory
expect(files.some((f) => f.fullpath().endsWith('file1.txt'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('file2.txt'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('file3.txt'))).toBe(true)
})
test('should handle glob patterns with subdirectories', async () => {
// Create nested structure
await mkdir(join(testDir, 'src'), { recursive: true })
await mkdir(join(testDir, 'src', 'components'), { recursive: true })
await mkdir(join(testDir, 'src', 'utils'), { recursive: true })
await writeFile(join(testDir, 'src', 'index.ts'), 'index content')
await writeFile(
join(testDir, 'src', 'components', 'Button.tsx'),
'button content'
)
await writeFile(
join(testDir, 'src', 'utils', 'helper.ts'),
'helper content'
)
await writeFile(
join(testDir, 'src', 'components', 'Button.css'),
'css content'
)
const files = await getAllFilesInPath('src/**/*', testDir, [])
expect(files).toHaveLength(6) // 4 files + 2 directories (components, utils)
expect(files.some((f) => f.fullpath().endsWith('index.ts'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('Button.tsx'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('helper.ts'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('Button.css'))).toBe(true)
})
test('should handle specific file extensions', async () => {
await writeFile(join(testDir, 'file1.ts'), 'ts content')
await writeFile(join(testDir, 'file2.js'), 'js content')
await writeFile(join(testDir, 'file3.tsx'), 'tsx content')
await writeFile(join(testDir, 'file4.css'), 'css content')
const files = await getAllFilesInPath('*.ts', testDir, [])
expect(files).toHaveLength(1)
expect(files.some((f) => f.fullpath().endsWith('file1.ts'))).toBe(true)
})
test('should return sorted files', async () => {
await writeFile(join(testDir, 'zebra.txt'), 'z content')
await writeFile(join(testDir, 'apple.txt'), 'a content')
await writeFile(join(testDir, 'banana.txt'), 'b content')
const files = await getAllFilesInPath('*.txt', testDir, [])
expect(files).toHaveLength(3)
// Files must be returned sorted by full path so the files hash is
// independent of filesystem traversal order
const fileNames = files.map((f) => basename(f.fullpath()))
expect(fileNames).toEqual(['apple.txt', 'banana.txt', 'zebra.txt'])
})
test('should return nested files sorted by full path', async () => {
await mkdir(join(testDir, 'b'), { recursive: true })
await mkdir(join(testDir, 'a'), { recursive: true })
await writeFile(join(testDir, 'zebra.txt'), 'z content')
await writeFile(join(testDir, 'b', 'file.txt'), 'b content')
await writeFile(join(testDir, 'a', 'file.txt'), 'a content')
const files = await getAllFilesInPath('*', testDir, [])
const paths = files.map((f) => f.fullpath())
expect(paths).toEqual([...paths].sort())
})
test('should handle no matching files', async () => {
await writeFile(join(testDir, 'file.txt'), 'content')
const files = await getAllFilesInPath('*.js', testDir, [])
expect(files).toHaveLength(0)
})
test('should include dotfiles', async () => {
// Create regular and dotfiles
await writeFile(join(testDir, 'file.txt'), 'content')
await writeFile(join(testDir, '.env'), 'SECRET=123')
await writeFile(join(testDir, '.gitignore'), 'node_modules')
const files = await getAllFilesInPath('*', testDir, [])
expect(files).toHaveLength(3)
expect(files.some((f) => f.fullpath().endsWith('file.txt'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('.env'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('.gitignore'))).toBe(true)
})
test('should include dotfiles in subdirectories', async () => {
await mkdir(join(testDir, 'src'), { recursive: true })
await writeFile(join(testDir, 'src', 'index.ts'), 'content')
await writeFile(join(testDir, 'src', '.env.local'), 'SECRET=123')
const files = await getAllFilesInPath('src', testDir, [])
expect(files.some((f) => f.fullpath().endsWith('index.ts'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('.env.local'))).toBe(true)
})
test('should include dotdirectories and their contents', async () => {
await mkdir(join(testDir, '.hidden'), { recursive: true })
await writeFile(join(testDir, '.hidden', 'config.json'), '{}')
await writeFile(join(testDir, 'visible.txt'), 'content')
const files = await getAllFilesInPath('*', testDir, [])
expect(files.some((f) => f.fullpath().endsWith('.hidden'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('config.json'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('visible.txt'))).toBe(true)
})
test('should respect ignore patterns for dotfiles', async () => {
await writeFile(join(testDir, '.env'), 'SECRET=123')
await writeFile(join(testDir, '.gitignore'), 'node_modules')
await writeFile(join(testDir, 'file.txt'), 'content')
const files = await getAllFilesInPath('*', testDir, ['.env'])
expect(files).toHaveLength(2)
expect(files.some((f) => f.fullpath().endsWith('.env'))).toBe(false)
expect(files.some((f) => f.fullpath().endsWith('.gitignore'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('file.txt'))).toBe(true)
})
test('should handle listing all files in current directory with dot pattern', async () => {
// Create a small directory tree inside the test directory
await writeFile(join(testDir, 'root.txt'), 'root')
await mkdir(join(testDir, 'subdir'), { recursive: true })
await writeFile(join(testDir, 'subdir', 'nested.txt'), 'nested')
const files = await getAllFilesInPath('.', testDir, [])
// We should get at least the testDir itself (.) plus its children
expect(files.length).toBeGreaterThanOrEqual(3)
// All returned paths must stay within the testDir - we must not traverse the whole filesystem
const allWithinTestDir = files.every((f) =>
f.fullpath().startsWith(testDir)
)
expect(allWithinTestDir).toBe(true)
})
test('should handle complex ignore patterns with directories', async () => {
// Create a complex structure
await mkdir(join(testDir, 'src'), { recursive: true })
await mkdir(join(testDir, 'src', 'components'), { recursive: true })
await mkdir(join(testDir, 'src', 'utils'), { recursive: true })
await mkdir(join(testDir, 'src', 'tests'), { recursive: true })
await mkdir(join(testDir, 'dist'), { recursive: true })
await writeFile(join(testDir, 'src', 'index.ts'), 'index content')
await writeFile(
join(testDir, 'src', 'components', 'Button.tsx'),
'button content'
)
await writeFile(
join(testDir, 'src', 'utils', 'helper.ts'),
'helper content'
)
await writeFile(
join(testDir, 'src', 'tests', 'test.spec.ts'),
'test content'
)
await writeFile(join(testDir, 'dist', 'bundle.js'), 'bundle content')
await writeFile(join(testDir, 'README.md'), 'readme content')
const files = await getAllFilesInPath('src', testDir, [
'**/tests/**',
'**/*.spec.*',
])
expect(files).toHaveLength(6) // 3 files + 3 directories (src, components, utils)
expect(files.some((f) => f.fullpath().endsWith('index.ts'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('Button.tsx'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('helper.ts'))).toBe(true)
expect(files.some((f) => f.fullpath().endsWith('test.spec.ts'))).toBe(false)
})
})
@@ -0,0 +1,28 @@
import { expect, test } from 'vitest'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { getCallerDirectory } from '../../../src/template/utils'
test('getCallerDirectory', () => {
// getCallerDirectory(1) should return the directory of the current file
// __dirname is the directory of the current file.
// Normalize before comparing: on Windows the stack-trace file name reported
// by vite/vitest uses forward slashes (e.g. "D:/a/...") whereas __dirname
// uses native backslashes — the directory is the same, only the separators
// differ.
expect(path.normalize(getCallerDirectory(1)!)).toBe(path.normalize(__dirname))
})
test('getCallerDirectory handles file:// URLs from ESM modules', () => {
// In ESM modules, CallSite.getFileName() returns file:// URLs
// This test verifies that the conversion works correctly
const testDirectoryPath = path.join(os.tmpdir(), 'test', 'project', 'src')
const testFilePath = path.join(testDirectoryPath, 'template.ts')
const fileUrl = pathToFileURL(testFilePath)
// Verify that fileURLToPath correctly converts the URL
// This is the same conversion used in getCallerDirectory
expect(fileURLToPath(fileUrl)).toBe(testFilePath)
expect(path.dirname(fileURLToPath(fileUrl))).toBe(testDirectoryPath)
})
@@ -0,0 +1,39 @@
import { describe, expect, test } from 'vitest'
import { normalizeBuildArguments } from '../../../src/template/utils'
import { TemplateError } from '../../../src/errors'
describe('normalizeBuildArguments', () => {
test('handles string name', () => {
const result = normalizeBuildArguments('my-template:v1.0')
expect(result.name).toBe('my-template:v1.0')
})
test('handles string name with options', () => {
const result = normalizeBuildArguments('my-template:v1.0', { cpuCount: 4 })
expect(result.name).toBe('my-template:v1.0')
expect(result.buildOptions).toEqual({ cpuCount: 4 })
})
test('handles legacy options with alias', () => {
const result = normalizeBuildArguments({ alias: 'my-template' })
expect(result.name).toBe('my-template')
})
test('removes alias from build options', () => {
const result = normalizeBuildArguments({
alias: 'my-template',
cpuCount: 4,
})
expect(result.name).toBe('my-template')
expect(result.buildOptions).toEqual({ cpuCount: 4 })
expect(result.buildOptions).not.toHaveProperty('alias')
})
test('throws for empty name', () => {
expect(() => normalizeBuildArguments({ alias: '' })).toThrow(TemplateError)
})
test('throws for missing name', () => {
expect(() => normalizeBuildArguments({} as any)).toThrow(TemplateError)
})
})
@@ -0,0 +1,49 @@
import { expect, test, describe } from 'vitest'
import {
waitForFile,
waitForPort,
waitForProcess,
waitForTimeout,
waitForURL,
} from '../../../src/template/readycmd'
describe('readycmd', () => {
test('waitForPort matches the exact listening port', () => {
const cmd = waitForPort(80).getCmd()
expect(cmd).toBe('[ -n "$(ss -Htuln sport = :80)" ]')
})
test('waitForURL quotes the url', () => {
const cmd = waitForURL('http://localhost:3000/health?ready=1&x=y').getCmd()
expect(cmd).toBe(
'curl -s -o /dev/null -w "%{http_code}" \'http://localhost:3000/health?ready=1&x=y\' | grep -q "200"'
)
})
test('waitForURL keeps simple urls unquoted', () => {
const cmd = waitForURL('http://localhost:3000/health').getCmd()
expect(cmd).toBe(
'curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/health | grep -q "200"'
)
})
test('waitForProcess quotes the process name', () => {
const cmd = waitForProcess('my daemon').getCmd()
expect(cmd).toBe("pgrep 'my daemon' > /dev/null")
})
test('waitForFile quotes the filename', () => {
const cmd = waitForFile('/tmp/ready file').getCmd()
expect(cmd).toBe("[ -f '/tmp/ready file' ]")
})
test('waitForFile keeps simple paths unquoted', () => {
const cmd = waitForFile('/tmp/ready').getCmd()
expect(cmd).toBe('[ -f /tmp/ready ]')
})
test('waitForTimeout converts milliseconds to seconds', () => {
expect(waitForTimeout(5000).getCmd()).toBe('sleep 5')
expect(waitForTimeout(100).getCmd()).toBe('sleep 1')
})
})
@@ -0,0 +1,314 @@
import {
expect,
test,
describe,
beforeAll,
afterAll,
beforeEach,
vi,
} from 'vitest'
import { writeFile, mkdir, rm, symlink, readFile, access } from 'fs/promises'
import fs from 'node:fs'
import { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { tarFileStream } from '../../../src/template/utils'
import * as tar from 'tar'
import { ReadEntry } from 'tar'
describe('tarFileStream', () => {
const testDir = join(__dirname, 'tar-test-folder')
beforeAll(async () => {
await rm(testDir, { recursive: true, force: true })
await mkdir(testDir, { recursive: true })
})
afterAll(async () => {
await rm(testDir, { recursive: true, force: true })
})
beforeEach(async () => {
await rm(testDir, { recursive: true, force: true })
await mkdir(testDir, { recursive: true })
})
/**
* Wait until a path no longer exists. Cleanup runs asynchronously from the
* stream's `close` handler, so it may not complete in the same tick.
*/
async function waitUntilGone(target: string): Promise<void> {
for (let i = 0; i < 100; i++) {
try {
await access(target)
} catch {
return
}
await new Promise((resolve) => setTimeout(resolve, 10))
}
throw new Error(`path was not removed: ${target}`)
}
/**
* Pipe an archive stream into tar's extractor and collect its entries.
*/
async function extractTarStream(
stream: Readable
): Promise<{ extractDir: string; members: ReadEntry[] }> {
const extractDir = join(
tmpdir(),
`tar-extract-${Date.now()}-${Math.random()}`
)
await mkdir(extractDir, { recursive: true })
const members: ReadEntry[] = []
await pipeline(
stream,
tar.extract({
cwd: extractDir,
gzip: true,
onentry: (entry: ReadEntry) => {
members.push(entry)
},
})
)
return { extractDir, members }
}
/**
* Extract archive contents into a map of path -> file contents.
*/
async function extractTarContents(
stream: Readable
): Promise<Map<string, Buffer | null>> {
const { extractDir, members } = await extractTarStream(stream)
const contents = new Map<string, Buffer | null>()
for (const member of members) {
if (member.type === 'File') {
try {
contents.set(
member.path,
await readFile(join(extractDir, member.path))
)
} catch {
// File might not exist
}
} else if (member.type === 'Directory') {
contents.set(member.path, null)
} else if (member.type === 'SymbolicLink') {
contents.set(member.path, Buffer.from(member.linkpath || ''))
}
}
await rm(extractDir, { recursive: true, force: true })
return contents
}
/**
* Extract archive members into a map of path -> entry.
*/
async function getTarMembers(
stream: Readable
): Promise<Map<string, ReadEntry>> {
const { extractDir, members } = await extractTarStream(stream)
const map = new Map<string, ReadEntry>()
for (const member of members) {
map.set(member.path, member)
}
await rm(extractDir, { recursive: true, force: true })
return map
}
test('should create tar with simple files', async () => {
await writeFile(join(testDir, 'file1.txt'), 'content1')
await writeFile(join(testDir, 'file2.txt'), 'content2')
const { stream, size } = await tarFileStream(
'*.txt',
testDir,
[],
false,
true
)
expect(size).toBeGreaterThan(0)
const contents = await extractTarContents(stream)
expect(contents.size).toBe(2)
expect(contents.get('file1.txt')?.toString()).toBe('content1')
expect(contents.get('file2.txt')?.toString()).toBe('content2')
})
test('should create an uncompressed tar when gzip is disabled', async () => {
await writeFile(join(testDir, 'file1.txt'), 'content1')
await writeFile(join(testDir, 'file2.txt'), 'content2')
const { stream } = await tarFileStream('*.txt', testDir, [], false, false)
// Buffer the archive so we can both assert it is not gzipped and extract it
const chunks: Buffer[] = []
for await (const chunk of stream as unknown as AsyncIterable<Buffer>) {
chunks.push(Buffer.from(chunk))
}
const archive = Buffer.concat(chunks)
// gzip streams start with the magic bytes 0x1f 0x8b — an uncompressed tar must not
expect(archive[0]).not.toBe(0x1f)
expect(archive.subarray(0, 2).equals(Buffer.from([0x1f, 0x8b]))).toBe(false)
// And it should still extract to the original files
const tarFile = join(tmpdir(), `tar-uncompressed-${Date.now()}.tar`)
await writeFile(tarFile, archive)
const extractDir = join(tmpdir(), `tar-uncompressed-extract-${Date.now()}`)
await mkdir(extractDir, { recursive: true })
await tar.extract({ file: tarFile, cwd: extractDir })
expect((await readFile(join(extractDir, 'file1.txt'))).toString()).toBe(
'content1'
)
expect((await readFile(join(extractDir, 'file2.txt'))).toString()).toBe(
'content2'
)
await rm(tarFile, { force: true })
await rm(extractDir, { recursive: true, force: true })
})
test('should respect ignore patterns', async () => {
await writeFile(join(testDir, 'file1.txt'), 'content1')
await writeFile(join(testDir, 'file2.txt'), 'content2')
await writeFile(join(testDir, 'temp.txt'), 'temp content')
await writeFile(join(testDir, 'backup.txt'), 'backup content')
const { stream } = await tarFileStream(
'*.txt',
testDir,
['temp*', 'backup*'],
false,
true
)
const contents = await extractTarContents(stream)
expect(contents.size).toBe(2)
expect(contents.get('file1.txt')?.toString()).toBe('content1')
expect(contents.get('file2.txt')?.toString()).toBe('content2')
expect(contents.has('temp.txt')).toBe(false)
expect(contents.has('backup.txt')).toBe(false)
})
test('should handle nested files', async () => {
const nestedDir = join(testDir, 'src', 'components')
await mkdir(nestedDir, { recursive: true })
await writeFile(join(testDir, 'src', 'index.ts'), 'index content')
await writeFile(join(nestedDir, 'Button.tsx'), 'button content')
const { stream } = await tarFileStream('src', testDir, [], false, true)
const contents = await extractTarContents(stream)
const paths = Array.from(contents.keys())
expect(paths.some((p) => p.includes('src'))).toBe(true)
expect(paths.some((p) => p.includes('index.ts'))).toBe(true)
expect(paths.some((p) => p.includes('Button.tsx'))).toBe(true)
})
test('should resolve symlinks when enabled', async () => {
await writeFile(join(testDir, 'original.txt'), 'original content')
try {
await symlink('original.txt', join(testDir, 'link.txt'))
} catch (error: any) {
// Skip test if symlinks are not supported on this platform
if (error.code === 'ENOSYS' || error.code === 'EPERM') {
return
}
throw error
}
// Test with resolveSymlinks=true
const { stream } = await tarFileStream('*.txt', testDir, [], true, true)
const contents = await extractTarContents(stream)
expect(contents.get('original.txt')?.toString()).toBe('original content')
// Symlink should be resolved (contain actual content, not link)
expect(contents.get('link.txt')?.toString()).toBe('original content')
})
test('should preserve symlinks when disabled', async () => {
await writeFile(join(testDir, 'original.txt'), 'original content')
try {
await symlink('original.txt', join(testDir, 'link.txt'))
} catch (error: any) {
// Skip test if symlinks are not supported on this platform
if (error.code === 'ENOSYS' || error.code === 'EPERM') {
return
}
throw error
}
// Test with resolveSymlinks=false
const { stream } = await tarFileStream('*.txt', testDir, [], false, true)
const members = await getTarMembers(stream)
expect(members.get('original.txt')?.type).toBe('File')
const link = members.get('link.txt')!
expect(link.type).toBe('SymbolicLink')
expect(link.linkpath).toBe('original.txt')
})
test('removes the spooled archive once the stream is consumed', async () => {
await writeFile(join(testDir, 'file1.txt'), 'content1')
// Capture the temp dir the helper creates so we can assert it is gone.
let tmpDir: string | undefined
const mkdtemp = fs.promises.mkdtemp.bind(fs.promises)
const spy = vi
.spyOn(fs.promises, 'mkdtemp')
.mockImplementation(async (prefix: any, ...rest: any[]) => {
const dir = await mkdtemp(prefix, ...rest)
tmpDir = dir
return dir
})
try {
const { stream } = await tarFileStream('*.txt', testDir, [], false)
expect(tmpDir).toBeDefined()
await access(tmpDir!)
// Drain the stream to completion; the `close` handler then removes the
// spooled archive.
stream.resume()
await waitUntilGone(tmpDir!)
} finally {
spy.mockRestore()
}
})
test('cleans up the spooled archive if it is never consumed', async () => {
await writeFile(join(testDir, 'file1.txt'), 'content1')
let tmpDir: string | undefined
const mkdtemp = fs.promises.mkdtemp.bind(fs.promises)
const spy = vi
.spyOn(fs.promises, 'mkdtemp')
.mockImplementation(async (prefix: any, ...rest: any[]) => {
const dir = await mkdtemp(prefix, ...rest)
tmpDir = dir
return dir
})
try {
const { stream } = await tarFileStream('*.txt', testDir, [], false)
await access(tmpDir!)
// Destroying without reading still fires `close` and removes the file.
stream.destroy()
await waitUntilGone(tmpDir!)
} finally {
spy.mockRestore()
}
})
})
@@ -0,0 +1,168 @@
import { describe, expect, test } from 'vitest'
import { validateRelativePath } from '../../../src/template/utils'
import { TemplateError } from '../../../src/errors'
const isWindows = process.platform === 'win32'
describe('validateRelativePath', () => {
describe('valid paths', () => {
test('accepts simple relative path', () => {
expect(() => validateRelativePath('foo', undefined)).not.toThrow()
})
test('accepts nested relative path', () => {
expect(() => validateRelativePath('foo/bar', undefined)).not.toThrow()
})
test('accepts path with ./ prefix', () => {
expect(() => validateRelativePath('./foo', undefined)).not.toThrow()
})
test('accepts nested path with ./ prefix', () => {
expect(() => validateRelativePath('./foo/bar', undefined)).not.toThrow()
})
test('accepts path with internal parent ref that stays within context', () => {
expect(() => validateRelativePath('foo/../bar', undefined)).not.toThrow()
})
test('accepts current directory', () => {
expect(() => validateRelativePath('.', undefined)).not.toThrow()
})
test('accepts glob patterns', () => {
expect(() => validateRelativePath('*.txt', undefined)).not.toThrow()
expect(() => validateRelativePath('**/*.ts', undefined)).not.toThrow()
expect(() => validateRelativePath('src/**/*', undefined)).not.toThrow()
})
test('accepts hidden files and directories', () => {
expect(() => validateRelativePath('.hidden', undefined)).not.toThrow()
expect(() =>
validateRelativePath('.config/settings', undefined)
).not.toThrow()
})
test('accepts filenames starting with double dots', () => {
expect(() => validateRelativePath('..myconfig', undefined)).not.toThrow()
expect(() => validateRelativePath('..cache', undefined)).not.toThrow()
expect(() =>
validateRelativePath('...something', undefined)
).not.toThrow()
expect(() =>
validateRelativePath('foo/..myconfig', undefined)
).not.toThrow()
})
})
describe('invalid paths - absolute', () => {
test('rejects Unix absolute path', () => {
expect(() => validateRelativePath('/absolute/path', undefined)).toThrow(
TemplateError
)
expect(() => validateRelativePath('/absolute/path', undefined)).toThrow(
'absolute paths are not allowed'
)
})
test('rejects root path', () => {
expect(() => validateRelativePath('/', undefined)).toThrow(TemplateError)
})
// Windows path tests - only run on Windows where path.isAbsolute detects them
test.skipIf(!isWindows)('rejects Windows drive letter path', () => {
expect(() =>
validateRelativePath('C:\\Windows\\System32', undefined)
).toThrow(TemplateError)
expect(() =>
validateRelativePath('C:\\Windows\\System32', undefined)
).toThrow('absolute paths are not allowed')
})
test.skipIf(!isWindows)('rejects Windows UNC path', () => {
expect(() =>
validateRelativePath('\\\\server\\share', undefined)
).toThrow(TemplateError)
})
})
describe('invalid paths - parent directory escape', () => {
test('rejects simple parent directory escape', () => {
expect(() => validateRelativePath('../foo', undefined)).toThrow(
TemplateError
)
expect(() => validateRelativePath('../foo', undefined)).toThrow(
'path escapes the context directory'
)
})
test('rejects parent directory escape with forward slash', () => {
expect(() => validateRelativePath('../file.txt', undefined)).toThrow(
TemplateError
)
})
test.skipIf(!isWindows)(
'rejects parent directory escape with backslash',
() => {
expect(() => validateRelativePath('..\\file.txt', undefined)).toThrow(
TemplateError
)
}
)
test('rejects double parent directory escape', () => {
expect(() => validateRelativePath('../../foo', undefined)).toThrow(
TemplateError
)
})
test('rejects path that escapes via nested parent refs', () => {
expect(() => validateRelativePath('foo/../../bar', undefined)).toThrow(
TemplateError
)
})
test('rejects path with ./ prefix that escapes', () => {
expect(() =>
validateRelativePath('./foo/../../../bar', undefined)
).toThrow(TemplateError)
})
test('rejects just parent directory', () => {
expect(() => validateRelativePath('..', undefined)).toThrow(TemplateError)
})
test('rejects current directory followed by parent', () => {
expect(() => validateRelativePath('./..', undefined)).toThrow(
TemplateError
)
})
test('rejects deeply nested escape', () => {
expect(() =>
validateRelativePath('a/b/c/../../../../escape', undefined)
).toThrow(TemplateError)
})
})
describe('error messages include path', () => {
test('absolute path error includes the path', () => {
try {
validateRelativePath('/etc/passwd', undefined)
expect.fail('Should have thrown')
} catch (e) {
expect(e.message).toContain('/etc/passwd')
}
})
test('escape path error includes the path', () => {
try {
validateRelativePath('../secret', undefined)
expect.fail('Should have thrown')
} catch (e) {
expect(e.message).toContain('../secret')
}
})
})
})
+398
View File
@@ -0,0 +1,398 @@
import { describe, expect } from 'vitest'
import { NotFoundError, VolumeError, VolumeFileType } from '../../src'
import { volumeTest } from '../setup'
describe('Volume File Operations', () => {
describe('writeFile and readFile', () => {
volumeTest('should write and read a text file', async ({ volume }) => {
const path = '/test.txt'
const content = 'Hello, World!'
const stat = await volume.writeFile(path, content)
expect(stat.name).toBe('test.txt')
expect(stat.type).toBe(VolumeFileType.FILE)
expect(stat.path).toBe(path)
expect(stat.atime).toBeInstanceOf(Date)
expect(stat.mtime).toBeInstanceOf(Date)
expect(stat.ctime).toBeInstanceOf(Date)
const readContent = await volume.readFile(path, { format: 'text' })
expect(readContent).toBe(content)
})
volumeTest(
'should write and read a file with bytes format',
async ({ volume }) => {
const path = '/test-bytes.txt'
const content = 'Test bytes content'
const contentBytes = new TextEncoder().encode(content)
await volume.writeFile(path, contentBytes.buffer)
const readBytes = await volume.readFile(path, { format: 'bytes' })
expect(readBytes).toEqual(contentBytes)
}
)
volumeTest('should write and read binary data', async ({ volume }) => {
const path = '/binary.bin'
const binaryData = new Uint8Array([0, 1, 2, 3, 4])
await volume.writeFile(path, binaryData.buffer)
const readBytes = await volume.readFile(path, { format: 'bytes' })
expect(readBytes).toEqual(binaryData)
})
volumeTest(
'should write and read a file with blob format',
async ({ volume }) => {
const path = '/test-blob.txt'
const content = 'Test blob content'
const blob = new Blob([content], { type: 'text/plain' })
await volume.writeFile(path, blob)
const readBlob = await volume.readFile(path, { format: 'blob' })
expect(await readBlob.text()).toBe(content)
}
)
volumeTest(
'should write and read a file from a ReadableStream',
async ({ volume }) => {
const path = '/test-stream.txt'
const content = 'Test stream content'
const stream = new Blob([content]).stream()
await volume.writeFile(path, stream)
const readContent = await volume.readFile(path, { format: 'text' })
expect(readContent).toBe(content)
}
)
volumeTest('should write and read an empty file', async ({ volume }) => {
const path = '/empty.txt'
const content = ''
await volume.writeFile(path, content)
const readContent = await volume.readFile(path, { format: 'text' })
expect(readContent).toBe(content)
})
volumeTest(
'should read an empty file in all formats',
async ({ volume }) => {
const path = '/empty-formats.txt'
await volume.writeFile(path, '')
const bytes = await volume.readFile(path, { format: 'bytes' })
expect(bytes).toBeInstanceOf(Uint8Array)
expect(bytes.length).toBe(0)
const blob = await volume.readFile(path, { format: 'blob' })
expect(blob).toBeInstanceOf(Blob)
expect(blob.size).toBe(0)
const stream = await volume.readFile(path, { format: 'stream' })
expect(stream).toBeInstanceOf(ReadableStream)
const chunks: Uint8Array[] = []
for await (const chunk of stream as unknown as AsyncIterable<Uint8Array>) {
chunks.push(chunk)
}
expect(chunks.reduce((n, c) => n + c.length, 0)).toBe(0)
}
)
volumeTest(
'should overwrite an existing file with force option',
async ({ volume }) => {
const path = '/overwrite.txt'
const initialContent = 'Initial content'
const newContent = 'New content'
await volume.writeFile(path, initialContent)
await volume.writeFile(path, newContent, { force: true })
const readContent = await volume.readFile(path, { format: 'text' })
expect(readContent).toBe(newContent)
}
)
volumeTest(
'should throw VolumeError when writing to existing file with force: false',
async ({ volume }) => {
const path = '/no-overwrite.txt'
const initialContent = 'Initial content'
const newContent = 'New content'
await volume.writeFile(path, initialContent)
await expect(
volume.writeFile(path, newContent, { force: false })
).rejects.toThrow(VolumeError)
}
)
volumeTest(
'should write file with metadata (uid, gid, mode)',
async ({ volume }) => {
const path = '/metadata.txt'
const content = 'File with metadata'
const stat = await volume.writeFile(path, content, {
uid: 1000,
gid: 1000,
mode: 0o644,
})
expect(stat.type).toBe(VolumeFileType.FILE)
expect(stat.uid).toBe(1000)
expect(stat.gid).toBe(1000)
expect(stat.mode).toBe(0o644)
}
)
volumeTest('should write file in nested directory', async ({ volume }) => {
const dirPath = '/nested/deep/path'
const filePath = `${dirPath}/file.txt`
const content = 'Nested file content'
await volume.makeDir(dirPath, { force: true })
await volume.writeFile(filePath, content)
const readContent = await volume.readFile(filePath, { format: 'text' })
expect(readContent).toBe(content)
})
})
describe('getInfo', () => {
volumeTest('should get info for a file', async ({ volume }) => {
const path = '/info-file.txt'
const content = 'File for info test'
await volume.writeFile(path, content)
const info = await volume.getInfo(path)
expect(info.name).toBe('info-file.txt')
expect(info.type).toBe(VolumeFileType.FILE)
expect(info.path).toBe(path)
expect(info.atime).toBeInstanceOf(Date)
expect(info.mtime).toBeInstanceOf(Date)
expect(info.ctime).toBeInstanceOf(Date)
})
volumeTest('should get info for a directory', async ({ volume }) => {
const path = '/info-dir'
await volume.makeDir(path)
const info = await volume.getInfo(path)
expect(info.name).toBe('info-dir')
expect(info.type).toBe(VolumeFileType.DIRECTORY)
expect(info.path).toBe(path)
})
volumeTest(
'should return false from exists for non-existent file',
async ({ volume }) => {
expect(await volume.exists('/non-existent.txt')).toBe(false)
}
)
})
describe('updateMetadata', () => {
volumeTest('should update file metadata', async ({ volume }) => {
const path = '/metadata-update.txt'
await volume.writeFile(path, 'Content')
const updated = await volume.updateMetadata(path, {
uid: 1001,
gid: 1001,
mode: 0o755,
})
expect(updated.path).toBe(path)
expect(updated.type).toBe(VolumeFileType.FILE)
expect(updated.uid).toBe(1001)
expect(updated.gid).toBe(1001)
expect(updated.mode).toBe(0o755)
})
volumeTest(
'should throw NotFoundError when updating non-existent file',
async ({ volume }) => {
await expect(
volume.updateMetadata('/non-existent.txt', { mode: 0o644 })
).rejects.toThrow(NotFoundError)
}
)
})
describe('makeDir', () => {
volumeTest('should create a directory', async ({ volume }) => {
const path = '/test-dir'
const stat = await volume.makeDir(path)
expect(stat.type).toBe(VolumeFileType.DIRECTORY)
expect(stat.path).toBe(path)
expect(stat.atime).toBeInstanceOf(Date)
expect(stat.mtime).toBeInstanceOf(Date)
expect(stat.ctime).toBeInstanceOf(Date)
})
volumeTest(
'should create nested directories with force option',
async ({ volume }) => {
const path = '/nested/deep/directory'
const stat = await volume.makeDir(path, { force: true })
expect(stat.type).toBe(VolumeFileType.DIRECTORY)
}
)
volumeTest(
'should throw VolumeError when creating existing directory with force: false',
async ({ volume }) => {
const path = '/existing-dir'
await volume.makeDir(path)
await expect(volume.makeDir(path, { force: false })).rejects.toThrow(
VolumeError
)
}
)
volumeTest('should create directory with metadata', async ({ volume }) => {
const path = '/dir-with-metadata'
const stat = await volume.makeDir(path, {
uid: 1000,
gid: 1000,
mode: 0o755,
})
expect(stat.type).toBe(VolumeFileType.DIRECTORY)
expect(stat.uid).toBe(1000)
expect(stat.gid).toBe(1000)
expect(stat.mode & 0o777).toBe(0o755)
})
})
describe('list', () => {
volumeTest('should list directory contents', async ({ volume }) => {
await volume.writeFile('/file1.txt', 'Content 1')
await volume.writeFile('/file2.txt', 'Content 2')
await volume.makeDir('/dir1')
const entries = await volume.list('/')
expect(entries.length).toBeGreaterThanOrEqual(3)
const fileNames = entries.map((e) => e.name).sort()
expect(fileNames).toContain('file1.txt')
expect(fileNames).toContain('file2.txt')
expect(fileNames).toContain('dir1')
})
volumeTest('should list nested directory contents', async ({ volume }) => {
await volume.makeDir('/nested', { force: true })
await volume.writeFile('/nested/file.txt', 'Content')
const entries = await volume.list('/nested')
expect(entries.length).toBeGreaterThanOrEqual(1)
expect(entries.some((e) => e.name === 'file.txt')).toBe(true)
})
volumeTest.skip('should list with depth option', async ({ volume }) => {
await volume.makeDir('/deep/nested/structure', { force: true })
await volume.writeFile('/deep/nested/structure/file.txt', 'Content')
const entries = await volume.list('/deep', { depth: 2 })
expect(entries.length).toBeGreaterThan(0)
})
volumeTest(
'should throw NotFoundError for non-existent directory',
async ({ volume }) => {
await expect(volume.list('/non-existent')).rejects.toThrow(
NotFoundError
)
}
)
})
describe('remove', () => {
volumeTest('should remove a file', async ({ volume }) => {
const path = '/to-remove.txt'
await volume.writeFile(path, 'Content')
await volume.remove(path)
expect(await volume.exists(path)).toBe(false)
})
volumeTest('should remove a directory', async ({ volume }) => {
const path = '/to-remove-dir'
await volume.makeDir(path)
await volume.remove(path)
expect(await volume.exists(path)).toBe(false)
})
volumeTest('should remove a directory recursively', async ({ volume }) => {
const dirPath = '/recursive-dir'
await volume.makeDir(`${dirPath}/nested`, { force: true })
await volume.writeFile(`${dirPath}/nested/file.txt`, 'Content')
await volume.remove(dirPath)
expect(await volume.exists(dirPath)).toBe(false)
})
volumeTest(
'should throw NotFoundError when removing non-existent file',
async ({ volume }) => {
await expect(volume.remove('/non-existent.txt')).rejects.toThrow(
NotFoundError
)
}
)
})
describe('file operations lifecycle', () => {
volumeTest(
'should handle directory with multiple files',
async ({ volume }) => {
const dirPath = '/multi-file-dir'
await volume.makeDir(dirPath)
const files = ['file1.txt', 'file2.txt', 'file3.txt']
for (const fileName of files) {
await volume.writeFile(
`${dirPath}/${fileName}`,
`Content of ${fileName}`
)
}
const entries = await volume.list(dirPath)
expect(entries.length).toBeGreaterThanOrEqual(files.length)
for (const fileName of files) {
const content = await volume.readFile(`${dirPath}/${fileName}`, {
format: 'text',
})
expect(content).toBe(`Content of ${fileName}`)
}
await volume.remove(dirPath)
expect(await volume.exists(dirPath)).toBe(false)
}
)
})
})

Some files were not shown because too many files have changed in this diff Show More