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,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
)
}
})
})