chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user