chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
|
||||
import { Sandbox } from 'e2b'
|
||||
import { getUserConfig } from 'src/user'
|
||||
import {
|
||||
bufferToText,
|
||||
isDebug,
|
||||
parseEnvInt,
|
||||
runCli,
|
||||
runCliWithPipedStdin,
|
||||
} from '../../setup'
|
||||
|
||||
type UserConfigWithDomain = NonNullable<ReturnType<typeof getUserConfig>> & {
|
||||
domain?: string
|
||||
E2B_DOMAIN?: string
|
||||
}
|
||||
|
||||
const userConfig = safeGetUserConfig() as UserConfigWithDomain | null
|
||||
const domain =
|
||||
process.env.E2B_DOMAIN ||
|
||||
userConfig?.E2B_DOMAIN ||
|
||||
userConfig?.domain ||
|
||||
'e2b.app'
|
||||
const apiKey = process.env.E2B_API_KEY || userConfig?.teamApiKey
|
||||
const shouldSkip = !apiKey || isDebug
|
||||
const integrationTest = test.skipIf(shouldSkip)
|
||||
const templateId =
|
||||
process.env.E2B_CLI_BACKEND_TEMPLATE_ID ||
|
||||
process.env.E2B_TEMPLATE_ID ||
|
||||
'base'
|
||||
const sandboxTimeoutMs = parseEnvInt(
|
||||
'E2B_CLI_BACKEND_SANDBOX_TIMEOUT_MS',
|
||||
20_000
|
||||
)
|
||||
const perTestTimeoutMs = parseEnvInt('E2B_CLI_BACKEND_TEST_TIMEOUT_MS', 30_000)
|
||||
const spawnTimeoutMs = perTestTimeoutMs
|
||||
const cliEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
E2B_DOMAIN: domain,
|
||||
E2B_API_KEY: apiKey,
|
||||
}
|
||||
|
||||
delete cliEnv.E2B_DEBUG
|
||||
|
||||
const runCliInSandbox = (args: string[]) =>
|
||||
runCli(args, { timeoutMs: spawnTimeoutMs, env: cliEnv })
|
||||
const runCliWithPipeInSandbox = (args: string[], input: Buffer) =>
|
||||
runCliWithPipedStdin(args, input, { timeoutMs: spawnTimeoutMs, env: cliEnv })
|
||||
|
||||
describe('sandbox cli backend integration', () => {
|
||||
let sandbox: Sandbox
|
||||
|
||||
beforeAll(async () => {
|
||||
if (shouldSkip) return
|
||||
|
||||
sandbox = await Sandbox.create(templateId, {
|
||||
apiKey,
|
||||
domain,
|
||||
timeoutMs: sandboxTimeoutMs,
|
||||
})
|
||||
}, 30_000)
|
||||
|
||||
afterAll(async () => {
|
||||
if (!sandbox) return
|
||||
|
||||
try {
|
||||
await sandbox.kill()
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Failed to kill sandbox ${sandbox.sandboxId} in cleanup: ${String(err)}`
|
||||
)
|
||||
}
|
||||
}, 15_000)
|
||||
|
||||
integrationTest(
|
||||
'list shows the sandbox',
|
||||
{ timeout: perTestTimeoutMs },
|
||||
async () => {
|
||||
const listResult = runCliInSandbox(['sandbox', 'list', '--format', 'json'])
|
||||
expect(listResult.status).toBe(0)
|
||||
expect(sandboxExistsInList(listResult.stdout, sandbox.sandboxId)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
integrationTest(
|
||||
'info shows the sandbox details',
|
||||
{ timeout: perTestTimeoutMs },
|
||||
async () => {
|
||||
const infoResult = runCliInSandbox([
|
||||
'sandbox',
|
||||
'info',
|
||||
sandbox.sandboxId,
|
||||
'--format',
|
||||
'json',
|
||||
])
|
||||
expect(infoResult.status).toBe(0)
|
||||
|
||||
const info = JSON.parse(bufferToText(infoResult.stdout)) as {
|
||||
sandboxId?: string
|
||||
state?: string
|
||||
}
|
||||
|
||||
expect(info.sandboxId).toBe(sandbox.sandboxId)
|
||||
expect(info.state).toBe('running')
|
||||
}
|
||||
)
|
||||
|
||||
integrationTest(
|
||||
'exec runs a command without piped stdin',
|
||||
{ timeout: perTestTimeoutMs },
|
||||
async () => {
|
||||
const execResult = runCliInSandbox([
|
||||
'sandbox',
|
||||
'exec',
|
||||
sandbox.sandboxId,
|
||||
'--',
|
||||
'sh',
|
||||
'-lc',
|
||||
'echo backend-non-pipe',
|
||||
])
|
||||
expect(execResult.status).toBe(0)
|
||||
expect(bufferToText(execResult.stdout)).toContain('backend-non-pipe')
|
||||
}
|
||||
)
|
||||
|
||||
integrationTest(
|
||||
'exec runs a command with piped stdin',
|
||||
{ timeout: perTestTimeoutMs },
|
||||
async () => {
|
||||
const pipedExecResult = await runCliWithPipeInSandbox(
|
||||
['sandbox', 'exec', sandbox.sandboxId, '--', 'sh', '-lc', 'wc -c'],
|
||||
Buffer.from('hello\n', 'utf8')
|
||||
)
|
||||
expect(pipedExecResult.status).toBe(0)
|
||||
const pipedStdout = bufferToText(pipedExecResult.stdout).trim()
|
||||
if (pipedStdout !== '6') {
|
||||
expect(pipedStdout).toBe('0')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
integrationTest(
|
||||
'metrics returns successfully',
|
||||
{ timeout: perTestTimeoutMs },
|
||||
async () => {
|
||||
const metricsResult = runCliInSandbox([
|
||||
'sandbox',
|
||||
'metrics',
|
||||
sandbox.sandboxId,
|
||||
'--format',
|
||||
'json',
|
||||
])
|
||||
expect(metricsResult.status).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
integrationTest(
|
||||
'kill removes the sandbox',
|
||||
{ timeout: perTestTimeoutMs },
|
||||
async () => {
|
||||
const killResult = runCliInSandbox(['sandbox', 'kill', sandbox.sandboxId])
|
||||
expect(killResult.status).toBe(0)
|
||||
|
||||
await assertSandboxNotListed(sandbox.sandboxId)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
async function assertSandboxNotListed(sandboxId: string): Promise<void> {
|
||||
const retries = 10
|
||||
const delayMs = 500
|
||||
|
||||
for (let i = 0; i < retries; i++) {
|
||||
const listResult = runCliInSandbox(['sandbox', 'list', '--format', 'json'])
|
||||
if (listResult.status === 0) {
|
||||
const exists = sandboxExistsInList(listResult.stdout, sandboxId)
|
||||
if (!exists) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
}
|
||||
|
||||
throw new Error(`Sandbox ${sandboxId} still appears in sandbox list`)
|
||||
}
|
||||
|
||||
function sandboxExistsInList(
|
||||
output: string | Buffer | null | undefined,
|
||||
sandboxId: string
|
||||
): boolean {
|
||||
const text = bufferToText(output).trim()
|
||||
if (!text) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(text) as Array<{ sandboxId?: string }>
|
||||
return parsed.some((item) => item.sandboxId === sandboxId)
|
||||
}
|
||||
|
||||
function safeGetUserConfig(): ReturnType<typeof getUserConfig> | null {
|
||||
try {
|
||||
return getUserConfig()
|
||||
} catch (err) {
|
||||
console.warn(`Failed to read ~/.e2b/config.json: ${String(err)}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const create = vi.fn()
|
||||
const ensureAPIKey = vi.fn(() => 'test-api-key')
|
||||
const spawnConnectedTerminal = vi.fn()
|
||||
|
||||
return {
|
||||
create,
|
||||
ensureAPIKey,
|
||||
spawnConnectedTerminal,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('e2b', () => ({
|
||||
Sandbox: {
|
||||
create: mocks.create,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/api', () => ({
|
||||
ensureAPIKey: mocks.ensureAPIKey,
|
||||
}))
|
||||
|
||||
vi.mock('src/utils/urls', () => ({
|
||||
printDashboardSandboxInspectUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('src/terminal', () => ({
|
||||
spawnConnectedTerminal: mocks.spawnConnectedTerminal,
|
||||
}))
|
||||
|
||||
describe('sandbox create lifecycle options', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
|
||||
mocks.create.mockResolvedValue({
|
||||
sandboxId: 'sandbox-id',
|
||||
setTimeout: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
mocks.spawnConnectedTerminal.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
test('passes ontimeout and autoresume to Sandbox.create', async () => {
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined) as never)
|
||||
|
||||
const { createCommand } = await import(
|
||||
'../../../src/commands/sandbox/create'
|
||||
)
|
||||
await createCommand('create', 'cr', false).parseAsync(
|
||||
[
|
||||
'base',
|
||||
'--detach',
|
||||
'--lifecycle.ontimeout',
|
||||
'pause',
|
||||
'--lifecycle.autoresume',
|
||||
],
|
||||
{ from: 'user' }
|
||||
)
|
||||
|
||||
expect(mocks.create).toHaveBeenCalledWith('base', {
|
||||
apiKey: 'test-api-key',
|
||||
lifecycle: {
|
||||
onTimeout: 'pause',
|
||||
autoResume: true,
|
||||
},
|
||||
})
|
||||
expect(exitSpy).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
test('passes ontimeout without autoresume to Sandbox.create', async () => {
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined) as never)
|
||||
|
||||
const { createCommand } = await import(
|
||||
'../../../src/commands/sandbox/create'
|
||||
)
|
||||
await createCommand('create', 'cr', false).parseAsync(
|
||||
['base', '--detach', '--lifecycle.ontimeout', 'kill'],
|
||||
{ from: 'user' }
|
||||
)
|
||||
|
||||
expect(mocks.create).toHaveBeenCalledWith('base', {
|
||||
apiKey: 'test-api-key',
|
||||
lifecycle: {
|
||||
onTimeout: 'kill',
|
||||
},
|
||||
})
|
||||
expect(exitSpy).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
test('passes timeout seconds to Sandbox.create as milliseconds', async () => {
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined) as never)
|
||||
|
||||
const { createCommand } = await import(
|
||||
'../../../src/commands/sandbox/create'
|
||||
)
|
||||
await createCommand('create', 'cr', false).parseAsync(
|
||||
['base', '--detach', '--timeout', '120'],
|
||||
{ from: 'user' }
|
||||
)
|
||||
|
||||
expect(mocks.create).toHaveBeenCalledWith('base', {
|
||||
apiKey: 'test-api-key',
|
||||
timeoutMs: 120_000,
|
||||
})
|
||||
expect(exitSpy).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
test('preserves explicit timeout after attached terminal closes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined) as never)
|
||||
const sandbox = {
|
||||
sandboxId: 'sandbox-id',
|
||||
setTimeout: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
mocks.create.mockResolvedValue(sandbox)
|
||||
|
||||
const { createCommand } = await import(
|
||||
'../../../src/commands/sandbox/create'
|
||||
)
|
||||
await createCommand('create', 'cr', false).parseAsync(
|
||||
['base', '--timeout', '120'],
|
||||
{ from: 'user' }
|
||||
)
|
||||
|
||||
expect(mocks.spawnConnectedTerminal).toHaveBeenCalledWith(sandbox)
|
||||
expect(sandbox.setTimeout).toHaveBeenCalledWith(120_000)
|
||||
expect(sandbox.setTimeout).not.toHaveBeenCalledWith(1_000)
|
||||
expect(exitSpy).toHaveBeenCalledWith(0)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
test('rejects autoresume without pause on timeout', async () => {
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined) as never)
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { createCommand } = await import(
|
||||
'../../../src/commands/sandbox/create'
|
||||
)
|
||||
await createCommand('create', 'cr', false).parseAsync(
|
||||
[
|
||||
'base',
|
||||
'--detach',
|
||||
'--lifecycle.ontimeout',
|
||||
'kill',
|
||||
'--lifecycle.autoresume',
|
||||
],
|
||||
{ from: 'user' }
|
||||
)
|
||||
|
||||
expect(mocks.create).not.toHaveBeenCalled()
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: '--lifecycle.autoresume requires --lifecycle.ontimeout pause',
|
||||
})
|
||||
)
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
test('rejects invalid ontimeout option', async () => {
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined) as never)
|
||||
|
||||
const { createCommand } = await import(
|
||||
'../../../src/commands/sandbox/create'
|
||||
)
|
||||
await expect(
|
||||
createCommand('create', 'cr', false).parseAsync(
|
||||
['base', '--detach', '--lifecycle.ontimeout', 'hibernate'],
|
||||
{ from: 'user' }
|
||||
)
|
||||
).rejects.toThrow('--lifecycle.ontimeout must be "pause" or "kill"')
|
||||
|
||||
expect(mocks.create).not.toHaveBeenCalled()
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
test('rejects zero timeout before creating sandbox', async () => {
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined) as never)
|
||||
|
||||
const { createCommand } = await import(
|
||||
'../../../src/commands/sandbox/create'
|
||||
)
|
||||
await expect(
|
||||
createCommand('create', 'cr', false).parseAsync(
|
||||
['base', '--detach', '--timeout', '0'],
|
||||
{ from: 'user' }
|
||||
)
|
||||
).rejects.toThrow('--timeout must be at least 30 seconds')
|
||||
|
||||
expect(mocks.create).not.toHaveBeenCalled()
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
test('rejects timeout values shorter than the keep-alive interval', async () => {
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined) as never)
|
||||
|
||||
const { createCommand } = await import(
|
||||
'../../../src/commands/sandbox/create'
|
||||
)
|
||||
await expect(
|
||||
createCommand('create', 'cr', false).parseAsync(
|
||||
['base', '--detach', '--timeout', '29.999'],
|
||||
{ from: 'user' }
|
||||
)
|
||||
).rejects.toThrow('--timeout must be at least 30 seconds')
|
||||
|
||||
expect(mocks.create).not.toHaveBeenCalled()
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
test('passes lifecycle and timeout together to Sandbox.create', async () => {
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined) as never)
|
||||
|
||||
const { createCommand } = await import(
|
||||
'../../../src/commands/sandbox/create'
|
||||
)
|
||||
await createCommand('create', 'cr', false).parseAsync(
|
||||
[
|
||||
'base',
|
||||
'--detach',
|
||||
'--timeout',
|
||||
'120',
|
||||
'--lifecycle.ontimeout',
|
||||
'pause',
|
||||
'--lifecycle.autoresume',
|
||||
],
|
||||
{ from: 'user' }
|
||||
)
|
||||
|
||||
expect(mocks.create).toHaveBeenCalledWith('base', {
|
||||
apiKey: 'test-api-key',
|
||||
lifecycle: {
|
||||
onTimeout: 'pause',
|
||||
autoResume: true,
|
||||
},
|
||||
timeoutMs: 120_000,
|
||||
})
|
||||
expect(exitSpy).toHaveBeenCalledWith(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const connect = vi.fn()
|
||||
const run = vi.fn()
|
||||
const wait = vi.fn()
|
||||
const sendStdin = vi.fn()
|
||||
const closeStdin = vi.fn()
|
||||
const kill = vi.fn()
|
||||
const ensureAPIKey = vi.fn(() => 'test-api-key')
|
||||
const isPipedStdin = vi.fn()
|
||||
const streamStdinChunks = vi.fn()
|
||||
const setupSignalHandlers = vi.fn(() => () => {})
|
||||
|
||||
return {
|
||||
connect,
|
||||
run,
|
||||
wait,
|
||||
sendStdin,
|
||||
closeStdin,
|
||||
kill,
|
||||
ensureAPIKey,
|
||||
isPipedStdin,
|
||||
streamStdinChunks,
|
||||
setupSignalHandlers,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('e2b', () => {
|
||||
class CommandExitError extends Error {
|
||||
exitCode: number
|
||||
constructor(exitCode: number) {
|
||||
super(`Command exited with ${exitCode}`)
|
||||
this.exitCode = exitCode
|
||||
}
|
||||
}
|
||||
|
||||
class NotFoundError extends Error {}
|
||||
|
||||
return {
|
||||
Sandbox: {
|
||||
connect: mocks.connect,
|
||||
},
|
||||
CommandExitError,
|
||||
NotFoundError,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../../src/api', () => ({
|
||||
ensureAPIKey: mocks.ensureAPIKey,
|
||||
}))
|
||||
|
||||
vi.mock('src/utils/signal', () => ({
|
||||
setupSignalHandlers: mocks.setupSignalHandlers,
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'../../../src/commands/sandbox/exec_helpers',
|
||||
async (importOriginal: <T>() => Promise<T>) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import('../../../src/commands/sandbox/exec_helpers')
|
||||
>()
|
||||
return {
|
||||
...actual,
|
||||
isPipedStdin: mocks.isPipedStdin,
|
||||
streamStdinChunks: mocks.streamStdinChunks,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
describe('sandbox exec closeStdin handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
|
||||
mocks.wait.mockResolvedValue({ exitCode: 0 })
|
||||
const handle = {
|
||||
pid: 1234,
|
||||
error: undefined,
|
||||
wait: mocks.wait,
|
||||
kill: vi.fn().mockResolvedValue(undefined),
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
mocks.run.mockResolvedValue(handle)
|
||||
mocks.sendStdin.mockResolvedValue(undefined)
|
||||
mocks.closeStdin.mockResolvedValue(undefined)
|
||||
mocks.kill.mockResolvedValue(true)
|
||||
mocks.isPipedStdin.mockReturnValue(true)
|
||||
mocks.streamStdinChunks.mockImplementation(
|
||||
async (
|
||||
_stream: NodeJS.ReadableStream,
|
||||
onChunk: (chunk: Uint8Array) => Promise<void>,
|
||||
_maxBytes: number
|
||||
) => {
|
||||
await onChunk(Buffer.from('hello'))
|
||||
}
|
||||
)
|
||||
mocks.connect.mockResolvedValue({
|
||||
commands: {
|
||||
run: mocks.run,
|
||||
sendStdin: mocks.sendStdin,
|
||||
closeStdin: mocks.closeStdin,
|
||||
kill: mocks.kill,
|
||||
supportsStdinClose: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
test('fails fast and kills remote process when closeStdin throws non-NotFoundError', async () => {
|
||||
mocks.closeStdin.mockRejectedValue(new Error('close failed'))
|
||||
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined) as never)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { execCommand } = await import('../../../src/commands/sandbox/exec')
|
||||
await execCommand.parseAsync(['sandbox-id', 'cat'], {
|
||||
from: 'user',
|
||||
})
|
||||
|
||||
expect(mocks.closeStdin).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.kill).toHaveBeenCalledWith(1234)
|
||||
expect(mocks.wait).not.toHaveBeenCalled()
|
||||
expect(exitSpy).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
test('keeps NotFoundError from closeStdin non-fatal', async () => {
|
||||
const { NotFoundError } = await import('e2b')
|
||||
mocks.closeStdin.mockRejectedValue(new NotFoundError('already exited'))
|
||||
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined) as never)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { execCommand } = await import('../../../src/commands/sandbox/exec')
|
||||
await execCommand.parseAsync(['sandbox-id', 'cat'], {
|
||||
from: 'user',
|
||||
})
|
||||
|
||||
expect(mocks.closeStdin).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.kill).not.toHaveBeenCalled()
|
||||
expect(mocks.wait).toHaveBeenCalledTimes(1)
|
||||
expect(exitSpy).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
test('stops stdin streaming after NotFoundError from sendStdin', async () => {
|
||||
const { NotFoundError } = await import('e2b')
|
||||
mocks.sendStdin.mockRejectedValueOnce(new NotFoundError('already exited'))
|
||||
mocks.streamStdinChunks.mockImplementation(
|
||||
async (
|
||||
_stream: NodeJS.ReadableStream,
|
||||
onChunk: (chunk: Uint8Array) => Promise<void | boolean>,
|
||||
_maxBytes: number
|
||||
) => {
|
||||
const shouldContinue = await onChunk(Buffer.from('first'))
|
||||
if (shouldContinue === false) {
|
||||
return
|
||||
}
|
||||
await onChunk(Buffer.from('second'))
|
||||
}
|
||||
)
|
||||
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined) as never)
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { execCommand } = await import('../../../src/commands/sandbox/exec')
|
||||
await execCommand.parseAsync(['sandbox-id', 'cat'], {
|
||||
from: 'user',
|
||||
})
|
||||
|
||||
expect(mocks.sendStdin).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.closeStdin).not.toHaveBeenCalled()
|
||||
expect(mocks.wait).toHaveBeenCalledTimes(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'e2b: Remote command exited before stdin could be delivered.'
|
||||
)
|
||||
expect(exitSpy).toHaveBeenCalledWith(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,226 @@
|
||||
import { PassThrough, Readable } from 'node:stream'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildCommand,
|
||||
chunkBytesBySize,
|
||||
isPipedStdin,
|
||||
readStdinIfPiped,
|
||||
readStdinFrom,
|
||||
streamStdinChunks,
|
||||
shellQuote,
|
||||
} from '../../../src/commands/sandbox/exec_helpers'
|
||||
|
||||
describe('exec helpers', () => {
|
||||
test('shellQuote leaves safe args untouched', () => {
|
||||
expect(shellQuote('python3')).toBe('python3')
|
||||
expect(shellQuote('-c')).toBe('-c')
|
||||
expect(shellQuote('path/to/file.txt')).toBe('path/to/file.txt')
|
||||
})
|
||||
|
||||
test('shellQuote wraps special chars and spaces', () => {
|
||||
expect(shellQuote('print(input())')).toBe("'print(input())'")
|
||||
expect(shellQuote('hello world')).toBe("'hello world'")
|
||||
expect(shellQuote("it's ok")).toBe("'it'\"'\"'s ok'")
|
||||
expect(shellQuote('')).toBe("''")
|
||||
})
|
||||
|
||||
test('buildCommand returns a single command as-is', () => {
|
||||
const cmd = 'python3 -c "print(input())"'
|
||||
expect(buildCommand([cmd])).toBe(cmd)
|
||||
})
|
||||
|
||||
test('buildCommand quotes args that need shell escaping', () => {
|
||||
expect(buildCommand(['python3', '-c', 'print(input())'])).toBe(
|
||||
"python3 -c 'print(input())'"
|
||||
)
|
||||
expect(buildCommand(['echo', 'hello world'])).toBe("echo 'hello world'")
|
||||
expect(buildCommand(['echo', "it's ok"])).toBe("echo 'it'\"'\"'s ok'")
|
||||
})
|
||||
|
||||
test('readStdinFrom reads full input and resolves on EOF', async () => {
|
||||
const stream = Readable.from(['foo', 'bar'])
|
||||
await expect(readStdinFrom(stream)).resolves.toEqual(Buffer.from('foobar'))
|
||||
})
|
||||
|
||||
test('readStdinFrom handles EOF without trailing newline', async () => {
|
||||
const stream = Readable.from(['no-newline'])
|
||||
await expect(readStdinFrom(stream)).resolves.toEqual(
|
||||
Buffer.from('no-newline')
|
||||
)
|
||||
})
|
||||
|
||||
test('readStdinIfPiped returns undefined when stdin is not a pipe', async () => {
|
||||
const fsMock = {
|
||||
fstatSync: () => ({ isFIFO: () => false }),
|
||||
}
|
||||
const stream = new PassThrough()
|
||||
stream.end('data')
|
||||
await expect(readStdinIfPiped({ fsModule: fsMock, stream })).resolves.toBe(
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
test('readStdinIfPiped reads from provided stream when piped', async () => {
|
||||
const fsMock = {
|
||||
fstatSync: () => ({ isFIFO: () => true }),
|
||||
}
|
||||
const stream = new PassThrough()
|
||||
const promise = readStdinIfPiped({ fsModule: fsMock, stream })
|
||||
stream.write(Buffer.from([0xe2, 0x98]))
|
||||
stream.write(Buffer.from([0x83]))
|
||||
stream.end(Buffer.from([0x21]))
|
||||
await expect(promise).resolves.toEqual(Buffer.from([0xe2, 0x98, 0x83, 0x21]))
|
||||
})
|
||||
|
||||
test('isPipedStdin returns true for FIFO', () => {
|
||||
const fsMock = {
|
||||
fstatSync: () => ({ isFIFO: () => true, isCharacterDevice: () => false }),
|
||||
}
|
||||
expect(isPipedStdin(0, fsMock)).toBe(true)
|
||||
})
|
||||
|
||||
test('isPipedStdin returns true for file redirection', () => {
|
||||
const fsMock = {
|
||||
fstatSync: () => ({ isFile: () => true, isCharacterDevice: () => false }),
|
||||
}
|
||||
expect(isPipedStdin(0, fsMock)).toBe(true)
|
||||
})
|
||||
|
||||
test('isPipedStdin returns false for interactive terminal', () => {
|
||||
const fsMock = {
|
||||
fstatSync: () => ({
|
||||
isCharacterDevice: () => true,
|
||||
isFIFO: () => false,
|
||||
isFile: () => false,
|
||||
}),
|
||||
}
|
||||
expect(isPipedStdin(0, fsMock)).toBe(false)
|
||||
})
|
||||
|
||||
test('isPipedStdin returns false for non-FIFO or errors', () => {
|
||||
const fsMockFalse = {
|
||||
fstatSync: () => ({ isFIFO: () => false, isCharacterDevice: () => false }),
|
||||
}
|
||||
expect(isPipedStdin(0, fsMockFalse)).toBe(false)
|
||||
|
||||
const fsMockThrow = {
|
||||
fstatSync: () => {
|
||||
throw new Error('fail')
|
||||
},
|
||||
}
|
||||
expect(isPipedStdin(0, fsMockThrow)).toBe(false)
|
||||
})
|
||||
|
||||
test('chunkBytesBySize splits large input into byte-sized chunks', () => {
|
||||
const maxBytes = 64 * 1024
|
||||
const data = Buffer.from('a'.repeat(maxBytes * 2 + 1))
|
||||
const chunks = chunkBytesBySize(data, maxBytes)
|
||||
|
||||
expect(chunks).toHaveLength(3)
|
||||
expect(chunks[0].byteLength).toBe(maxBytes)
|
||||
expect(chunks[1].byteLength).toBe(maxBytes)
|
||||
expect(chunks[2].byteLength).toBe(1)
|
||||
expect(Buffer.concat(chunks.map((c) => Buffer.from(c)))).toEqual(data)
|
||||
})
|
||||
|
||||
test('chunkBytesBySize keeps byte content intact', () => {
|
||||
const maxBytes = 64 * 1024
|
||||
const data = Buffer.from('\u{1F600}'.repeat(20000)) // 😀 (4 bytes each)
|
||||
const chunks = chunkBytesBySize(data, maxBytes)
|
||||
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.byteLength).toBeLessThanOrEqual(maxBytes)
|
||||
}
|
||||
expect(Buffer.concat(chunks.map((c) => Buffer.from(c)))).toEqual(data)
|
||||
})
|
||||
|
||||
test('chunkBytesBySize returns empty array for empty input', () => {
|
||||
const chunks = chunkBytesBySize(Buffer.alloc(0), 64 * 1024)
|
||||
expect(chunks).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('chunkBytesBySize returns single chunk for small input', () => {
|
||||
const data = Buffer.from('hello')
|
||||
const chunks = chunkBytesBySize(data, 64 * 1024)
|
||||
expect(chunks).toHaveLength(1)
|
||||
expect(Buffer.from(chunks[0])).toEqual(data)
|
||||
})
|
||||
|
||||
test('chunkBytesBySize throws on invalid maxBytes', () => {
|
||||
expect(() => chunkBytesBySize(Buffer.from('data'), 0)).toThrow()
|
||||
expect(() => chunkBytesBySize(Buffer.from('data'), -1)).toThrow()
|
||||
})
|
||||
|
||||
test('readStdinFrom resolves with empty buffer on immediate EOF', async () => {
|
||||
const stream = Readable.from([])
|
||||
await expect(readStdinFrom(stream)).resolves.toEqual(Buffer.alloc(0))
|
||||
})
|
||||
|
||||
test('streamStdinChunks delivers chunks incrementally before EOF', async () => {
|
||||
const stream = new PassThrough()
|
||||
const seen: Buffer[] = []
|
||||
let resolveFirstChunk: (() => void) | undefined
|
||||
const firstChunkSeen = new Promise<void>((resolve) => {
|
||||
resolveFirstChunk = resolve
|
||||
})
|
||||
const done = streamStdinChunks(
|
||||
stream,
|
||||
async (chunk) => {
|
||||
seen.push(Buffer.from(chunk))
|
||||
if (resolveFirstChunk) {
|
||||
resolveFirstChunk()
|
||||
resolveFirstChunk = undefined
|
||||
}
|
||||
},
|
||||
64 * 1024
|
||||
)
|
||||
|
||||
stream.write('first')
|
||||
await firstChunkSeen
|
||||
|
||||
expect(seen).toEqual([Buffer.from('first')])
|
||||
|
||||
stream.end('second')
|
||||
await done
|
||||
|
||||
expect(seen).toEqual([Buffer.from('first'), Buffer.from('second')])
|
||||
})
|
||||
|
||||
test('streamStdinChunks splits oversized stream chunks by max bytes', async () => {
|
||||
const stream = Readable.from([Buffer.from('a'.repeat(64 * 1024 + 3))])
|
||||
const chunks: Uint8Array[] = []
|
||||
|
||||
await streamStdinChunks(
|
||||
stream,
|
||||
async (chunk) => {
|
||||
chunks.push(chunk)
|
||||
},
|
||||
64 * 1024
|
||||
)
|
||||
|
||||
expect(chunks).toHaveLength(2)
|
||||
expect(chunks[0].byteLength).toBe(64 * 1024)
|
||||
expect(chunks[1].byteLength).toBe(3)
|
||||
expect(Buffer.concat(chunks.map((c) => Buffer.from(c)))).toEqual(
|
||||
Buffer.from('a'.repeat(64 * 1024 + 3))
|
||||
)
|
||||
})
|
||||
|
||||
test('streamStdinChunks stops early when onChunk returns false', async () => {
|
||||
const stream = Readable.from([Buffer.from('first'), Buffer.from('second')])
|
||||
const chunks: Uint8Array[] = []
|
||||
|
||||
await streamStdinChunks(
|
||||
stream,
|
||||
async (chunk) => {
|
||||
chunks.push(chunk)
|
||||
return false
|
||||
},
|
||||
64 * 1024
|
||||
)
|
||||
|
||||
expect(chunks).toHaveLength(1)
|
||||
expect(Buffer.from(chunks[0])).toEqual(Buffer.from('first'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,188 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { Sandbox } from 'e2b'
|
||||
import { getUserConfig } from 'src/user'
|
||||
import {
|
||||
type CliRunResult,
|
||||
bufferToText,
|
||||
isDebug,
|
||||
parseEnvInt,
|
||||
runCliWithPipedStdin,
|
||||
} from '../../setup'
|
||||
|
||||
type PipeCase = {
|
||||
name: string
|
||||
data: Buffer
|
||||
expectedBytes: number
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
type UserConfigWithDomain = NonNullable<ReturnType<typeof getUserConfig>> & {
|
||||
domain?: string
|
||||
E2B_DOMAIN?: string
|
||||
}
|
||||
|
||||
const userConfig = safeGetUserConfig() as UserConfigWithDomain | null
|
||||
const domain =
|
||||
process.env.E2B_DOMAIN ||
|
||||
userConfig?.E2B_DOMAIN ||
|
||||
userConfig?.domain ||
|
||||
'e2b.app'
|
||||
const apiKey = process.env.E2B_API_KEY || userConfig?.teamApiKey
|
||||
const shouldSkip = !apiKey || isDebug
|
||||
const integrationTest = test.skipIf(shouldSkip)
|
||||
const templateId =
|
||||
process.env.E2B_PIPE_TEMPLATE_ID ||
|
||||
process.env.E2B_TEMPLATE_ID ||
|
||||
'base'
|
||||
const includeLargeBinary =
|
||||
process.env.E2B_PIPE_INTEGRATION_STRICT === '1' ||
|
||||
process.env.E2B_PIPE_INTEGRATION_BINARY === '1' ||
|
||||
process.env.STRICT === '1'
|
||||
const sandboxTimeoutMs = parseEnvInt('E2B_PIPE_SANDBOX_TIMEOUT_MS', 10_000)
|
||||
const testTimeoutMs = parseEnvInt('E2B_PIPE_TEST_TIMEOUT_MS', 60_000)
|
||||
const defaultCmdTimeoutMs = parseEnvInt(
|
||||
'E2B_PIPE_CMD_TIMEOUT_MS',
|
||||
Math.min(8_000, testTimeoutMs)
|
||||
)
|
||||
const cliEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
E2B_DOMAIN: domain,
|
||||
E2B_API_KEY: apiKey,
|
||||
}
|
||||
|
||||
delete cliEnv.E2B_DEBUG
|
||||
|
||||
const defaultCases: PipeCase[] = [
|
||||
{
|
||||
name: 'empty_eof',
|
||||
data: Buffer.alloc(0),
|
||||
expectedBytes: 0,
|
||||
},
|
||||
{
|
||||
name: 'ascii_newline',
|
||||
data: Buffer.from('hello\n'),
|
||||
expectedBytes: 6,
|
||||
},
|
||||
{
|
||||
name: 'ascii_no_newline',
|
||||
data: Buffer.from('hello'),
|
||||
expectedBytes: 5,
|
||||
},
|
||||
{
|
||||
name: 'utf8_multibyte',
|
||||
data: Buffer.from([0x68, 0x69, 0x2d, 0xe2, 0x98, 0x83]), // "hi-☃"
|
||||
expectedBytes: 6,
|
||||
},
|
||||
{
|
||||
name: 'binary_nul_ff_hex',
|
||||
data: Buffer.from([0x00, 0x01, 0x02, 0xff, 0x00, 0x41]),
|
||||
expectedBytes: 6,
|
||||
},
|
||||
{
|
||||
name: 'chunk_64k',
|
||||
data: Buffer.from('a'.repeat(64 * 1024)),
|
||||
expectedBytes: 64 * 1024,
|
||||
},
|
||||
{
|
||||
name: 'chunk_64k_plus_1',
|
||||
data: Buffer.from('a'.repeat(64 * 1024 + 1)),
|
||||
expectedBytes: 64 * 1024 + 1,
|
||||
},
|
||||
]
|
||||
|
||||
const largeBinaryCases: PipeCase[] = [
|
||||
{
|
||||
name: 'binary_random_sha256',
|
||||
data: randomBytes(1024),
|
||||
expectedBytes: 1024,
|
||||
},
|
||||
]
|
||||
|
||||
describe('sandbox exec stdin piping (integration)', () => {
|
||||
integrationTest(
|
||||
'pipes stdin to remote command',
|
||||
{ timeout: testTimeoutMs },
|
||||
async () => {
|
||||
const sandbox = await Sandbox.create(templateId, {
|
||||
apiKey,
|
||||
domain,
|
||||
timeoutMs: sandboxTimeoutMs,
|
||||
})
|
||||
|
||||
try {
|
||||
const cases = includeLargeBinary
|
||||
? [...defaultCases, ...largeBinaryCases]
|
||||
: defaultCases
|
||||
|
||||
// Probe with a simple case first — some environments (notably Windows
|
||||
// CI) don't expose piped stdin so the remote byte count is 0.
|
||||
const probe = cases[1] // ascii_newline
|
||||
const probeResult = await runExecPipe(sandbox.sandboxId, probe)
|
||||
assertExecSucceeded(probe.name, probeResult)
|
||||
|
||||
const probeStdout = bufferToText(probeResult.stdout).trim()
|
||||
if (probeStdout === '0') {
|
||||
return
|
||||
}
|
||||
|
||||
expect(probeStdout).toBe(String(probe.expectedBytes))
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = await runExecPipe(sandbox.sandboxId, testCase)
|
||||
assertExecSucceeded(testCase.name, result)
|
||||
const stdout = bufferToText(result.stdout).trim()
|
||||
expect(stdout, testCase.name).toBe(String(testCase.expectedBytes))
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await sandbox.kill()
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Failed to kill sandbox ${sandbox.sandboxId}: ${String(err)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
function runExecPipe(
|
||||
sandboxId: string,
|
||||
testCase: PipeCase
|
||||
): Promise<CliRunResult> {
|
||||
return runCliWithPipedStdin(
|
||||
['sandbox', 'exec', sandboxId, '--', 'sh', '-lc', 'wc -c'],
|
||||
testCase.data,
|
||||
{
|
||||
env: cliEnv,
|
||||
timeoutMs: testCase.timeoutMs ?? defaultCmdTimeoutMs,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function assertExecSucceeded(
|
||||
name: string,
|
||||
result: CliRunResult
|
||||
): void {
|
||||
if (result.error) {
|
||||
const timedOut = (result.error as NodeJS.ErrnoException).code === 'ETIMEDOUT'
|
||||
throw new Error(
|
||||
`${name} ${timedOut ? 'timed out' : 'failed'}: ${result.error.message}`
|
||||
)
|
||||
}
|
||||
|
||||
const stderr = bufferToText(result.stderr).trim()
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${name} failed with rc=${result.status} stderr=${stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
function safeGetUserConfig(): ReturnType<typeof getUserConfig> | null {
|
||||
try {
|
||||
return getUserConfig()
|
||||
} catch (err) {
|
||||
console.warn(`Failed to read ~/.e2b/config.json: ${String(err)}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const connect = vi.fn()
|
||||
const run = vi.fn()
|
||||
const sendStdin = vi.fn()
|
||||
const closeStdin = vi.fn()
|
||||
const ensureAPIKey = vi.fn(() => 'test-api-key')
|
||||
const isPipedStdin = vi.fn()
|
||||
const streamStdinChunks = vi.fn()
|
||||
const setupSignalHandlers = vi.fn(() => () => {})
|
||||
|
||||
return {
|
||||
connect,
|
||||
run,
|
||||
sendStdin,
|
||||
closeStdin,
|
||||
ensureAPIKey,
|
||||
isPipedStdin,
|
||||
streamStdinChunks,
|
||||
setupSignalHandlers,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('e2b', () => {
|
||||
class CommandExitError extends Error {
|
||||
exitCode: number
|
||||
constructor(exitCode: number) {
|
||||
super(`Command exited with ${exitCode}`)
|
||||
this.exitCode = exitCode
|
||||
}
|
||||
}
|
||||
|
||||
class NotFoundError extends Error {}
|
||||
|
||||
return {
|
||||
Sandbox: {
|
||||
connect: mocks.connect,
|
||||
},
|
||||
CommandExitError,
|
||||
NotFoundError,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../../src/api', () => ({
|
||||
ensureAPIKey: mocks.ensureAPIKey,
|
||||
}))
|
||||
|
||||
vi.mock('src/utils/signal', () => ({
|
||||
setupSignalHandlers: mocks.setupSignalHandlers,
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'../../../src/commands/sandbox/exec_helpers',
|
||||
async (importOriginal: <T>() => Promise<T>) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import('../../../src/commands/sandbox/exec_helpers')
|
||||
>()
|
||||
return {
|
||||
...actual,
|
||||
isPipedStdin: mocks.isPipedStdin,
|
||||
streamStdinChunks: mocks.streamStdinChunks,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
describe('sandbox exec stdin run flag', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
|
||||
const handle = {
|
||||
pid: 1234,
|
||||
error: undefined,
|
||||
wait: vi.fn().mockResolvedValue({ exitCode: 0 }),
|
||||
kill: vi.fn().mockResolvedValue(undefined),
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
mocks.run.mockResolvedValue(handle)
|
||||
mocks.sendStdin.mockResolvedValue(undefined)
|
||||
mocks.closeStdin.mockResolvedValue(undefined)
|
||||
mocks.isPipedStdin.mockReturnValue(false)
|
||||
mocks.streamStdinChunks.mockResolvedValue(undefined)
|
||||
mocks.connect.mockResolvedValue({
|
||||
commands: {
|
||||
run: mocks.run,
|
||||
sendStdin: mocks.sendStdin,
|
||||
closeStdin: mocks.closeStdin,
|
||||
supportsStdinClose: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
test('does not pass stdin flag when stdin is not piped', async () => {
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined) as never)
|
||||
|
||||
const { execCommand } = await import('../../../src/commands/sandbox/exec')
|
||||
await execCommand.parseAsync(['sandbox-id', 'echo', 'hello'], {
|
||||
from: 'user',
|
||||
})
|
||||
|
||||
expect(mocks.run).toHaveBeenCalledTimes(1)
|
||||
const runOpts = mocks.run.mock.calls[0][1]
|
||||
expect(runOpts).not.toHaveProperty('stdin')
|
||||
expect(mocks.streamStdinChunks).not.toHaveBeenCalled()
|
||||
expect(exitSpy).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
test('passes stdin: true when stdin piping is active', async () => {
|
||||
mocks.isPipedStdin.mockReturnValue(true)
|
||||
const events: string[] = []
|
||||
mocks.setupSignalHandlers.mockImplementation(() => {
|
||||
events.push('setup')
|
||||
return () => {
|
||||
events.push('cleanup')
|
||||
}
|
||||
})
|
||||
mocks.streamStdinChunks.mockImplementation(
|
||||
async (
|
||||
_stream: NodeJS.ReadableStream,
|
||||
onChunk: (chunk: Uint8Array) => Promise<void | boolean>,
|
||||
_maxBytes: number
|
||||
) => {
|
||||
events.push('stream-start')
|
||||
await onChunk(Buffer.from('hello'))
|
||||
events.push('stream-end')
|
||||
}
|
||||
)
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined) as never)
|
||||
|
||||
const { execCommand } = await import('../../../src/commands/sandbox/exec')
|
||||
await execCommand.parseAsync(['sandbox-id', 'cat'], {
|
||||
from: 'user',
|
||||
})
|
||||
|
||||
expect(mocks.run).toHaveBeenCalledTimes(1)
|
||||
const runOpts = mocks.run.mock.calls[0][1]
|
||||
expect(runOpts).toHaveProperty('stdin', true)
|
||||
expect(mocks.streamStdinChunks).toHaveBeenCalled()
|
||||
expect(mocks.sendStdin).toHaveBeenCalled()
|
||||
expect(mocks.closeStdin).toHaveBeenCalled()
|
||||
expect(events).toContain('setup')
|
||||
expect(events).toContain('stream-start')
|
||||
expect(events.indexOf('setup')).toBeLessThan(events.indexOf('stream-start'))
|
||||
expect(events).toContain('cleanup')
|
||||
expect(exitSpy).toHaveBeenCalledWith(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
const apiKey = process.env.E2B_API_KEY
|
||||
const domain = process.env.E2B_DOMAIN || 'e2b.app'
|
||||
|
||||
const cliPath = path.join(process.cwd(), 'dist', 'index.js')
|
||||
const templateName = `cli-create-api-key-test-${Date.now()}`
|
||||
|
||||
describe('template create cli backend integration', () => {
|
||||
let testDir: string
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
'E2B_API_KEY must be set to run template create backend tests'
|
||||
)
|
||||
}
|
||||
testDir = await fs.mkdtemp('e2b-create-test-')
|
||||
await fs.writeFile(
|
||||
path.join(testDir, 'e2b.Dockerfile'),
|
||||
'FROM ubuntu:latest\n'
|
||||
)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (!testDir) return
|
||||
runCli(['template', 'delete', '--yes', templateName])
|
||||
await fs.rm(testDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test(
|
||||
'template create succeeds with E2B_API_KEY alone (no E2B_ACCESS_TOKEN)',
|
||||
{ timeout: 300_000 },
|
||||
() => {
|
||||
const result = runCli([
|
||||
'template',
|
||||
'create',
|
||||
templateName,
|
||||
'--path',
|
||||
testDir,
|
||||
])
|
||||
const output = String(result.stdout || '') + String(result.stderr || '')
|
||||
|
||||
expect(result.status, output).toBe(0)
|
||||
// Success marker printed by create.ts on a finished build; the failure
|
||||
// path prints "❌ Template build failed." instead.
|
||||
expect(output).toContain('✅ Building sandbox template')
|
||||
expect(output).not.toContain('❌ Template build failed')
|
||||
// Auth never fell through to the access-token error box.
|
||||
expect(output).not.toMatch(/You must be logged in/)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
function runCli(args: string[]): ReturnType<typeof spawnSync> {
|
||||
// Intentionally exclude E2B_ACCESS_TOKEN from the child env so this test
|
||||
// verifies the API-key-only auth path end-to-end.
|
||||
return spawnSync('node', [cliPath, ...args], {
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HOME: process.env.HOME,
|
||||
E2B_DOMAIN: domain,
|
||||
E2B_API_KEY: apiKey,
|
||||
},
|
||||
encoding: 'utf8',
|
||||
timeout: 300_000,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import { execSync } from 'child_process'
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest'
|
||||
|
||||
describe('Template Delete --config', () => {
|
||||
let testDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await fs.mkdtemp('e2b-delete-config-test-')
|
||||
|
||||
const defaultConfig = `template_id = "default-template-id"
|
||||
dockerfile = "e2b.Dockerfile"`
|
||||
await fs.writeFile(path.join(testDir, 'e2b.toml'), defaultConfig)
|
||||
|
||||
const customConfig = `template_id = "custom-template-id"
|
||||
dockerfile = "e2b.Dockerfile"`
|
||||
await fs.writeFile(path.join(testDir, 'custom.toml'), customConfig)
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'e2b.Dockerfile'), 'FROM alpine:3.18')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (testDir) {
|
||||
await fs.rm(testDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('uses the config file passed via --config even when e2b.toml exists', async () => {
|
||||
const cliPath = path.join(process.cwd(), 'dist', 'index.js')
|
||||
|
||||
let output = ''
|
||||
try {
|
||||
output = execSync(
|
||||
`node "${cliPath}" template delete --yes --path "${testDir}" --config custom.toml 2>&1`,
|
||||
{ encoding: 'utf-8', stdio: 'pipe', timeout: 10_000 }
|
||||
)
|
||||
} catch (err: any) {
|
||||
output = (err?.stdout ?? '') + (err?.stderr ?? '')
|
||||
}
|
||||
|
||||
expect(output).toContain('Sandbox templates to delete')
|
||||
expect(output).toContain('custom-template-id')
|
||||
expect(output).toContain('custom.toml')
|
||||
expect(output).not.toContain('default-template-id')
|
||||
})
|
||||
|
||||
test('uses the default e2b.toml when --config is not provided', async () => {
|
||||
const cliPath = path.join(process.cwd(), 'dist', 'index.js')
|
||||
|
||||
let output = ''
|
||||
try {
|
||||
output = execSync(
|
||||
`node "${cliPath}" template delete --yes --path "${testDir}" 2>&1`,
|
||||
{ encoding: 'utf-8', stdio: 'pipe', timeout: 10_000 }
|
||||
)
|
||||
} catch (err: any) {
|
||||
output = (err?.stdout ?? '') + (err?.stderr ?? '')
|
||||
}
|
||||
|
||||
expect(output).toContain('Sandbox templates to delete')
|
||||
expect(output).toContain('default-template-id')
|
||||
expect(output).toContain('e2b.toml')
|
||||
expect(output).not.toContain('custom-template-id')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM python:3.11-slim
|
||||
RUN apt-get update && apt-get install -y gcc g++ make libpq-dev && rm -rf /var/lib/apt/lists/*
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
RUN useradd -m -u 1000 appuser
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --upgrade pip && pip install -r requirements.txt
|
||||
COPY app.py .
|
||||
USER appuser
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:application"]
|
||||
@@ -0,0 +1,3 @@
|
||||
template_id = "complex-python"
|
||||
dockerfile = "e2b.Dockerfile"
|
||||
template_name = "complex-python-app"
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import asyncio
|
||||
from e2b import AsyncTemplate, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
async def main():
|
||||
await AsyncTemplate.build(
|
||||
template,
|
||||
"complex-python-app-dev",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import asyncio
|
||||
from e2b import AsyncTemplate, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
async def main():
|
||||
await AsyncTemplate.build(
|
||||
template,
|
||||
"complex-python-app",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
from e2b import AsyncTemplate
|
||||
|
||||
template = (
|
||||
AsyncTemplate()
|
||||
.from_image("python:3.11-slim")
|
||||
.set_user("root")
|
||||
.set_workdir("/")
|
||||
.run_cmd("apt-get update && apt-get install -y gcc g++ make libpq-dev && rm -rf /var/lib/apt/lists/*")
|
||||
.set_envs({
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
})
|
||||
.run_cmd("useradd -m -u 1000 appuser")
|
||||
.set_workdir("/app")
|
||||
.copy("requirements.txt", ".")
|
||||
.run_cmd("pip install --upgrade pip && pip install -r requirements.txt")
|
||||
.copy("app.py", ".")
|
||||
.set_user("appuser")
|
||||
.set_start_cmd("sudo gunicorn --bind 0.0.0.0:8000 app:application", "sleep 20")
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
from e2b import Template, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Template.build(
|
||||
template,
|
||||
"complex-python-app-dev",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
from e2b import Template, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Template.build(
|
||||
template,
|
||||
"complex-python-app",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
from e2b import Template
|
||||
|
||||
template = (
|
||||
Template()
|
||||
.from_image("python:3.11-slim")
|
||||
.set_user("root")
|
||||
.set_workdir("/")
|
||||
.run_cmd("apt-get update && apt-get install -y gcc g++ make libpq-dev && rm -rf /var/lib/apt/lists/*")
|
||||
.set_envs({
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
})
|
||||
.run_cmd("useradd -m -u 1000 appuser")
|
||||
.set_workdir("/app")
|
||||
.copy("requirements.txt", ".")
|
||||
.run_cmd("pip install --upgrade pip && pip install -r requirements.txt")
|
||||
.copy("app.py", ".")
|
||||
.set_user("appuser")
|
||||
.set_start_cmd("sudo gunicorn --bind 0.0.0.0:8000 app:application", "sleep 20")
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Template, defaultBuildLogger } from 'e2b'
|
||||
import { template } from './template'
|
||||
|
||||
async function main() {
|
||||
await Template.build(template, 'complex-python-app-dev', {
|
||||
onBuildLogs: defaultBuildLogger(),
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Template, defaultBuildLogger } from 'e2b'
|
||||
import { template } from './template'
|
||||
|
||||
async function main() {
|
||||
await Template.build(template, 'complex-python-app', {
|
||||
onBuildLogs: defaultBuildLogger(),
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { Template } from 'e2b'
|
||||
|
||||
export const template = Template()
|
||||
.fromImage('python:3.11-slim')
|
||||
.setUser('root')
|
||||
.setWorkdir('/')
|
||||
.runCmd('apt-get update && apt-get install -y gcc g++ make libpq-dev && rm -rf /var/lib/apt/lists/*')
|
||||
.setEnvs({
|
||||
'PYTHONDONTWRITEBYTECODE': '1',
|
||||
'PYTHONUNBUFFERED': '1',
|
||||
})
|
||||
.runCmd('useradd -m -u 1000 appuser')
|
||||
.setWorkdir('/app')
|
||||
.copy('requirements.txt', '.')
|
||||
.runCmd('pip install --upgrade pip && pip install -r requirements.txt')
|
||||
.copy('app.py', '.')
|
||||
.setUser('appuser')
|
||||
.setStartCmd('sudo gunicorn --bind 0.0.0.0:8000 app:application', 'sleep 20')
|
||||
@@ -0,0 +1,4 @@
|
||||
FROM alpine:latest
|
||||
COPY package.json /app/
|
||||
COPY src/index.js ./src/
|
||||
COPY config.json /etc/app/config.json
|
||||
@@ -0,0 +1,2 @@
|
||||
template_id = "copy-test"
|
||||
dockerfile = "e2b.Dockerfile"
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import asyncio
|
||||
from e2b import AsyncTemplate, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
async def main():
|
||||
await AsyncTemplate.build(
|
||||
template,
|
||||
"copy-test-dev",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import asyncio
|
||||
from e2b import AsyncTemplate, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
async def main():
|
||||
await AsyncTemplate.build(
|
||||
template,
|
||||
"copy-test",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
from e2b import AsyncTemplate
|
||||
|
||||
template = (
|
||||
AsyncTemplate()
|
||||
.from_image("alpine:latest")
|
||||
.set_user("root")
|
||||
.set_workdir("/")
|
||||
.copy("package.json", "/app/")
|
||||
.copy("src/index.js", "./src/")
|
||||
.copy("config.json", "/etc/app/config.json")
|
||||
.set_user("user")
|
||||
.set_workdir("/home/user")
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
from e2b import Template, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Template.build(
|
||||
template,
|
||||
"copy-test-dev",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
from e2b import Template, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Template.build(
|
||||
template,
|
||||
"copy-test",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
from e2b import Template
|
||||
|
||||
template = (
|
||||
Template()
|
||||
.from_image("alpine:latest")
|
||||
.set_user("root")
|
||||
.set_workdir("/")
|
||||
.copy("package.json", "/app/")
|
||||
.copy("src/index.js", "./src/")
|
||||
.copy("config.json", "/etc/app/config.json")
|
||||
.set_user("user")
|
||||
.set_workdir("/home/user")
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Template, defaultBuildLogger } from 'e2b'
|
||||
import { template } from './template'
|
||||
|
||||
async function main() {
|
||||
await Template.build(template, 'copy-test-dev', {
|
||||
onBuildLogs: defaultBuildLogger(),
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Template, defaultBuildLogger } from 'e2b'
|
||||
import { template } from './template'
|
||||
|
||||
async function main() {
|
||||
await Template.build(template, 'copy-test', {
|
||||
onBuildLogs: defaultBuildLogger(),
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { Template } from 'e2b'
|
||||
|
||||
export const template = Template()
|
||||
.fromImage('alpine:latest')
|
||||
.setUser('root')
|
||||
.setWorkdir('/')
|
||||
.copy('package.json', '/app/')
|
||||
.copy('src/index.js', './src/')
|
||||
.copy('config.json', '/etc/app/config.json')
|
||||
.setUser('user')
|
||||
.setWorkdir('/home/user')
|
||||
@@ -0,0 +1,3 @@
|
||||
FROM node:18
|
||||
WORKDIR /app
|
||||
COPY server.js .
|
||||
@@ -0,0 +1,4 @@
|
||||
template_id = "custom-app"
|
||||
dockerfile = "e2b.Dockerfile"
|
||||
start_cmd = "node server.js"
|
||||
ready_cmd = "curl -f http://localhost:3000/health || exit 1"
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import asyncio
|
||||
from e2b import AsyncTemplate, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
async def main():
|
||||
await AsyncTemplate.build(
|
||||
template,
|
||||
"custom-app-dev",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import asyncio
|
||||
from e2b import AsyncTemplate, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
async def main():
|
||||
await AsyncTemplate.build(
|
||||
template,
|
||||
"custom-app",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
from e2b import AsyncTemplate
|
||||
|
||||
template = (
|
||||
AsyncTemplate()
|
||||
.from_image("node:18")
|
||||
.set_user("root")
|
||||
.set_workdir("/")
|
||||
.set_workdir("/app")
|
||||
.copy("server.js", ".")
|
||||
.set_user("user")
|
||||
.set_start_cmd("sudo node server.js", "curl -f http://localhost:3000/health || exit 1")
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
from e2b import Template, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Template.build(
|
||||
template,
|
||||
"custom-app-dev",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
from e2b import Template, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Template.build(
|
||||
template,
|
||||
"custom-app",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
from e2b import Template
|
||||
|
||||
template = (
|
||||
Template()
|
||||
.from_image("node:18")
|
||||
.set_user("root")
|
||||
.set_workdir("/")
|
||||
.set_workdir("/app")
|
||||
.copy("server.js", ".")
|
||||
.set_user("user")
|
||||
.set_start_cmd("sudo node server.js", "curl -f http://localhost:3000/health || exit 1")
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Template, defaultBuildLogger } from 'e2b'
|
||||
import { template } from './template'
|
||||
|
||||
async function main() {
|
||||
await Template.build(template, 'custom-app-dev', {
|
||||
onBuildLogs: defaultBuildLogger(),
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Template, defaultBuildLogger } from 'e2b'
|
||||
import { template } from './template'
|
||||
|
||||
async function main() {
|
||||
await Template.build(template, 'custom-app', {
|
||||
onBuildLogs: defaultBuildLogger(),
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Template } from 'e2b'
|
||||
|
||||
export const template = Template()
|
||||
.fromImage('node:18')
|
||||
.setUser('root')
|
||||
.setWorkdir('/')
|
||||
.setWorkdir('/app')
|
||||
.copy('server.js', '.')
|
||||
.setUser('user')
|
||||
.setStartCmd('sudo node server.js', 'curl -f http://localhost:3000/health || exit 1')
|
||||
@@ -0,0 +1 @@
|
||||
FROM ubuntu:latest
|
||||
@@ -0,0 +1,2 @@
|
||||
template_id = "minimal-template"
|
||||
dockerfile = "e2b.Dockerfile"
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import asyncio
|
||||
from e2b import AsyncTemplate, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
async def main():
|
||||
await AsyncTemplate.build(
|
||||
template,
|
||||
"minimal-template-dev",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import asyncio
|
||||
from e2b import AsyncTemplate, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
async def main():
|
||||
await AsyncTemplate.build(
|
||||
template,
|
||||
"minimal-template",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
from e2b import AsyncTemplate
|
||||
|
||||
template = (
|
||||
AsyncTemplate()
|
||||
.from_image("ubuntu:latest")
|
||||
.set_user("root")
|
||||
.set_workdir("/")
|
||||
.set_user("user")
|
||||
.set_workdir("/home/user")
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
from e2b import Template, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Template.build(
|
||||
template,
|
||||
"minimal-template-dev",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
from e2b import Template, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Template.build(
|
||||
template,
|
||||
"minimal-template",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
from e2b import Template
|
||||
|
||||
template = (
|
||||
Template()
|
||||
.from_image("ubuntu:latest")
|
||||
.set_user("root")
|
||||
.set_workdir("/")
|
||||
.set_user("user")
|
||||
.set_workdir("/home/user")
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Template, defaultBuildLogger } from 'e2b'
|
||||
import { template } from './template'
|
||||
|
||||
async function main() {
|
||||
await Template.build(template, 'minimal-template-dev', {
|
||||
onBuildLogs: defaultBuildLogger(),
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Template, defaultBuildLogger } from 'e2b'
|
||||
import { template } from './template'
|
||||
|
||||
async function main() {
|
||||
await Template.build(template, 'minimal-template', {
|
||||
onBuildLogs: defaultBuildLogger(),
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { Template } from 'e2b'
|
||||
|
||||
export const template = Template()
|
||||
.fromImage('ubuntu:latest')
|
||||
.setUser('root')
|
||||
.setWorkdir('/')
|
||||
.setUser('user')
|
||||
.setWorkdir('/home/user')
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM node:18 AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:18-slim
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
CMD ["node", "index.js"]
|
||||
@@ -0,0 +1,2 @@
|
||||
template_id = "multi-stage"
|
||||
dockerfile = "e2b.Dockerfile"
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import asyncio
|
||||
from e2b import AsyncTemplate, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
async def main():
|
||||
await AsyncTemplate.build(
|
||||
template,
|
||||
"multi-stage-dev",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import asyncio
|
||||
from e2b import AsyncTemplate, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
async def main():
|
||||
await AsyncTemplate.build(
|
||||
template,
|
||||
"multi-stage",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
from e2b import AsyncTemplate
|
||||
|
||||
template = (
|
||||
AsyncTemplate()
|
||||
.from_image("my-custom-image")
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
from e2b import Template, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Template.build(
|
||||
template,
|
||||
"multi-stage-dev",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
from e2b import Template, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Template.build(
|
||||
template,
|
||||
"multi-stage",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
from e2b import Template
|
||||
|
||||
template = (
|
||||
Template()
|
||||
.from_image("my-custom-image")
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Template, defaultBuildLogger } from 'e2b'
|
||||
import { template } from './template'
|
||||
|
||||
async function main() {
|
||||
await Template.build(template, 'multi-stage-dev', {
|
||||
onBuildLogs: defaultBuildLogger(),
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Template, defaultBuildLogger } from 'e2b'
|
||||
import { template } from './template'
|
||||
|
||||
async function main() {
|
||||
await Template.build(template, 'multi-stage', {
|
||||
onBuildLogs: defaultBuildLogger(),
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import { Template } from 'e2b'
|
||||
|
||||
export const template = Template()
|
||||
.fromImage('my-custom-image')
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM node:18
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT 3000
|
||||
ENV DEBUG=false LOG_LEVEL=info API_URL=https://api.example.com
|
||||
ENV SINGLE_VAR single_value
|
||||
WORKDIR /app
|
||||
@@ -0,0 +1,2 @@
|
||||
template_id = "env-test"
|
||||
dockerfile = "e2b.Dockerfile"
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import asyncio
|
||||
from e2b import AsyncTemplate, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
async def main():
|
||||
await AsyncTemplate.build(
|
||||
template,
|
||||
"env-test-dev",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import asyncio
|
||||
from e2b import AsyncTemplate, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
async def main():
|
||||
await AsyncTemplate.build(
|
||||
template,
|
||||
"env-test",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
from e2b import AsyncTemplate
|
||||
|
||||
template = (
|
||||
AsyncTemplate()
|
||||
.from_image("node:18")
|
||||
.set_user("root")
|
||||
.set_workdir("/")
|
||||
.set_envs({
|
||||
"NODE_ENV": "production",
|
||||
})
|
||||
.set_envs({
|
||||
"PORT": "3000",
|
||||
})
|
||||
.set_envs({
|
||||
"DEBUG": "false",
|
||||
"LOG_LEVEL": "info",
|
||||
"API_URL": "https://api.example.com",
|
||||
})
|
||||
.set_envs({
|
||||
"SINGLE_VAR": "single_value",
|
||||
})
|
||||
.set_workdir("/app")
|
||||
.set_user("user")
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
from e2b import Template, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Template.build(
|
||||
template,
|
||||
"env-test-dev",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
from e2b import Template, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Template.build(
|
||||
template,
|
||||
"env-test",
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
from e2b import Template
|
||||
|
||||
template = (
|
||||
Template()
|
||||
.from_image("node:18")
|
||||
.set_user("root")
|
||||
.set_workdir("/")
|
||||
.set_envs({
|
||||
"NODE_ENV": "production",
|
||||
})
|
||||
.set_envs({
|
||||
"PORT": "3000",
|
||||
})
|
||||
.set_envs({
|
||||
"DEBUG": "false",
|
||||
"LOG_LEVEL": "info",
|
||||
"API_URL": "https://api.example.com",
|
||||
})
|
||||
.set_envs({
|
||||
"SINGLE_VAR": "single_value",
|
||||
})
|
||||
.set_workdir("/app")
|
||||
.set_user("user")
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Template, defaultBuildLogger } from 'e2b'
|
||||
import { template } from './template'
|
||||
|
||||
async function main() {
|
||||
await Template.build(template, 'env-test-dev', {
|
||||
onBuildLogs: defaultBuildLogger(),
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Template, defaultBuildLogger } from 'e2b'
|
||||
import { template } from './template'
|
||||
|
||||
async function main() {
|
||||
await Template.build(template, 'env-test', {
|
||||
onBuildLogs: defaultBuildLogger(),
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { Template } from 'e2b'
|
||||
|
||||
export const template = Template()
|
||||
.fromImage('node:18')
|
||||
.setUser('root')
|
||||
.setWorkdir('/')
|
||||
.setEnvs({
|
||||
'NODE_ENV': 'production',
|
||||
})
|
||||
.setEnvs({
|
||||
'PORT': '3000',
|
||||
})
|
||||
.setEnvs({
|
||||
'DEBUG': 'false',
|
||||
'LOG_LEVEL': 'info',
|
||||
'API_URL': 'https://api.example.com',
|
||||
})
|
||||
.setEnvs({
|
||||
'SINGLE_VAR': 'single_value',
|
||||
})
|
||||
.setWorkdir('/app')
|
||||
.setUser('user')
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM python:3.11
|
||||
WORKDIR /app
|
||||
RUN pip install --upgrade pip
|
||||
RUN pip install -r requirements.txt
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
CMD ["python", "app.py"]
|
||||
@@ -0,0 +1,5 @@
|
||||
template_id = "start-cmd"
|
||||
dockerfile = "e2b.Dockerfile"
|
||||
cpu_count = 2
|
||||
memory_mb = 1024
|
||||
start_cmd = "node server.js"
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import asyncio
|
||||
from e2b import AsyncTemplate, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
async def main():
|
||||
await AsyncTemplate.build(
|
||||
template,
|
||||
"start-cmd-dev",
|
||||
cpu_count=2,
|
||||
memory_mb=1024,
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import asyncio
|
||||
from e2b import AsyncTemplate, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
async def main():
|
||||
await AsyncTemplate.build(
|
||||
template,
|
||||
"start-cmd",
|
||||
cpu_count=2,
|
||||
memory_mb=1024,
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
from e2b import AsyncTemplate
|
||||
|
||||
template = (
|
||||
AsyncTemplate()
|
||||
.from_image("python:3.11")
|
||||
.set_user("root")
|
||||
.set_workdir("/")
|
||||
.set_workdir("/app")
|
||||
.run_cmd("pip install --upgrade pip")
|
||||
.run_cmd("pip install -r requirements.txt")
|
||||
.set_envs({
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
})
|
||||
.set_user("user")
|
||||
.set_start_cmd("sudo node server.js", "sleep 20")
|
||||
)
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
from e2b import Template, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Template.build(
|
||||
template,
|
||||
"start-cmd-dev",
|
||||
cpu_count=2,
|
||||
memory_mb=1024,
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
from e2b import Template, default_build_logger
|
||||
from template import template
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
Template.build(
|
||||
template,
|
||||
"start-cmd",
|
||||
cpu_count=2,
|
||||
memory_mb=1024,
|
||||
on_build_logs=default_build_logger(),
|
||||
)
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
from e2b import Template
|
||||
|
||||
template = (
|
||||
Template()
|
||||
.from_image("python:3.11")
|
||||
.set_user("root")
|
||||
.set_workdir("/")
|
||||
.set_workdir("/app")
|
||||
.run_cmd("pip install --upgrade pip")
|
||||
.run_cmd("pip install -r requirements.txt")
|
||||
.set_envs({
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
})
|
||||
.set_user("user")
|
||||
.set_start_cmd("sudo node server.js", "sleep 20")
|
||||
)
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { Template, defaultBuildLogger } from 'e2b'
|
||||
import { template } from './template'
|
||||
|
||||
async function main() {
|
||||
await Template.build(template, 'start-cmd-dev', {
|
||||
cpuCount: 2,
|
||||
memoryMB: 1024,
|
||||
onBuildLogs: defaultBuildLogger(),
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { Template, defaultBuildLogger } from 'e2b'
|
||||
import { template } from './template'
|
||||
|
||||
async function main() {
|
||||
await Template.build(template, 'start-cmd', {
|
||||
cpuCount: 2,
|
||||
memoryMB: 1024,
|
||||
onBuildLogs: defaultBuildLogger(),
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { Template } from 'e2b'
|
||||
|
||||
export const template = Template()
|
||||
.fromImage('python:3.11')
|
||||
.setUser('root')
|
||||
.setWorkdir('/')
|
||||
.setWorkdir('/app')
|
||||
.runCmd('pip install --upgrade pip')
|
||||
.runCmd('pip install -r requirements.txt')
|
||||
.setEnvs({
|
||||
'PYTHONUNBUFFERED': '1',
|
||||
})
|
||||
.setUser('user')
|
||||
.setStartCmd('sudo node server.js', 'sleep 20')
|
||||
@@ -0,0 +1,366 @@
|
||||
import { execSync } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest'
|
||||
import { Language } from '../../../src/commands/template/generators'
|
||||
|
||||
describe('Template Init', () => {
|
||||
let testDir: string
|
||||
const cliPath = path.join(process.cwd(), 'dist', 'index.js')
|
||||
|
||||
beforeEach(async () => {
|
||||
// Use Node.js built-in temp directory handling
|
||||
testDir = await fs.mkdtemp('e2b-init-test-')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test directory
|
||||
if (testDir) {
|
||||
await fs.rm(testDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('CLI Options', () => {
|
||||
Object.values(Language).forEach((language) => {
|
||||
test(`should generate files with --name and --language ${language}`, async () => {
|
||||
const templateName = 'my-test-template'
|
||||
|
||||
// Run init command with CLI options
|
||||
execSync(
|
||||
`node "${cliPath}" template init --name "${templateName}" --language "${language}" --path "${testDir}"`,
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
|
||||
// Verify template directory was created
|
||||
const templateDir = path.join(testDir, templateName)
|
||||
expect(existsSync(templateDir)).toBe(true)
|
||||
|
||||
// Verify files were created in the template directory
|
||||
const expectedFiles = getExpectedFiles(language)
|
||||
|
||||
for (const file of expectedFiles) {
|
||||
const filePath = path.join(templateDir, file)
|
||||
expect(existsSync(filePath)).toBe(true)
|
||||
|
||||
// Verify file content is not empty
|
||||
const content = await fs.readFile(filePath, 'utf8')
|
||||
expect(content.trim().length).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
// Verify template name is used in build files
|
||||
await verifyTemplateNameInBuildFiles(
|
||||
templateDir,
|
||||
language,
|
||||
templateName
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test('should validate template name format', async () => {
|
||||
// Matches the server-side rule in e2b-dev/infra (id.identifierRegex):
|
||||
// only lowercase letters, numbers, dashes and underscores are allowed.
|
||||
const invalidNames = [
|
||||
'invalid space', // contains space
|
||||
'invalid.dot', // contains a dot
|
||||
'', // empty
|
||||
'a'.repeat(129), // exceeds the 128 character limit
|
||||
]
|
||||
|
||||
for (const invalidName of invalidNames) {
|
||||
expect(() => {
|
||||
execSync(
|
||||
`node "${cliPath}" template init --name "${invalidName}" --language "typescript" --path "${testDir}"`,
|
||||
{ stdio: 'pipe' }
|
||||
)
|
||||
}).toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
test('should validate language parameter', async () => {
|
||||
expect(() => {
|
||||
execSync(
|
||||
`node "${cliPath}" template init --name "test" --language "invalid-lang" --path "${testDir}"`,
|
||||
{ stdio: 'pipe' }
|
||||
)
|
||||
}).toThrow()
|
||||
})
|
||||
|
||||
test('should work with valid template names', async () => {
|
||||
const validNames = [
|
||||
'a', // single character
|
||||
'abc', // simple
|
||||
'my-template', // with hyphens
|
||||
'my_template', // with underscores
|
||||
'my-template_name', // with hyphens and underscores
|
||||
'test123', // with numbers
|
||||
'123test', // starting with number
|
||||
'a-b-c-d-e', // multiple hyphens
|
||||
'a_b_c_d_e', // multiple underscores
|
||||
'api-server_v2', // mixed hyphens and underscores
|
||||
]
|
||||
|
||||
for (const validName of validNames) {
|
||||
// Clean directory before each test
|
||||
const files = await fs.readdir(testDir)
|
||||
for (const file of files) {
|
||||
await fs.rm(path.join(testDir, file), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Should not throw
|
||||
execSync(
|
||||
`node "${cliPath}" template init --name "${validName}" --language "typescript" --path "${testDir}"`,
|
||||
{ stdio: 'pipe' }
|
||||
)
|
||||
|
||||
// Verify template directory was created
|
||||
const templateDir = path.join(testDir, validName)
|
||||
expect(existsSync(templateDir)).toBe(true)
|
||||
|
||||
// Verify files were created in the template directory
|
||||
const expectedFiles = getExpectedFiles(Language.TypeScript)
|
||||
for (const file of expectedFiles) {
|
||||
expect(existsSync(path.join(templateDir, file))).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Package.json Integration', () => {
|
||||
test('should add scripts to existing package.json', async () => {
|
||||
// Create a package.json file in the parent directory
|
||||
const packageJson = {
|
||||
name: 'test-project',
|
||||
version: '1.0.0',
|
||||
scripts: {
|
||||
test: 'echo "test"',
|
||||
},
|
||||
}
|
||||
const packageJsonPath = path.join(testDir, 'package.json')
|
||||
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2))
|
||||
|
||||
// Run init command
|
||||
execSync(
|
||||
`node "${cliPath}" template init --name "test-template" --language "typescript" --path "${testDir}"`,
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
|
||||
// Verify package.json was updated (it should remain in the parent directory)
|
||||
const updatedPackageJson = JSON.parse(
|
||||
await fs.readFile(packageJsonPath, 'utf8')
|
||||
)
|
||||
|
||||
expect(updatedPackageJson.scripts).toHaveProperty('e2b:build:dev')
|
||||
expect(updatedPackageJson.scripts).toHaveProperty('e2b:build:prod')
|
||||
expect(updatedPackageJson.scripts.test).toBe('echo "test"') // existing script preserved
|
||||
})
|
||||
|
||||
test('should work without package.json', async () => {
|
||||
// Run init command without package.json
|
||||
execSync(
|
||||
`node "${cliPath}" template init --name "test-template" --language "typescript" --path "${testDir}"`,
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
|
||||
// Verify template directory was created
|
||||
const templateDir = path.join(testDir, 'test-template')
|
||||
expect(existsSync(templateDir)).toBe(true)
|
||||
|
||||
// Verify files were created in the template directory
|
||||
const expectedFiles = getExpectedFiles(Language.TypeScript)
|
||||
for (const file of expectedFiles) {
|
||||
expect(existsSync(path.join(templateDir, file))).toBe(true)
|
||||
}
|
||||
|
||||
// Verify package.json was created in the template directory
|
||||
const createdPackageJSON = JSON.parse(
|
||||
await fs.readFile(path.join(templateDir, 'package.json'), 'utf8')
|
||||
)
|
||||
|
||||
expect(createdPackageJSON.scripts).toHaveProperty('e2b:build:dev')
|
||||
expect(createdPackageJSON.scripts).toHaveProperty('e2b:build:prod')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Makefile Integration', () => {
|
||||
test('should add scripts to existing Makefile', async () => {
|
||||
// Create a Makefile file in the parent directory
|
||||
const makefile = `
|
||||
.PHONY: build
|
||||
build/%:
|
||||
\tCGO_ENABLED=1 go build
|
||||
`
|
||||
const makefilePath = path.join(testDir, 'Makefile')
|
||||
await fs.writeFile(makefilePath, makefile)
|
||||
|
||||
// Run init command
|
||||
execSync(
|
||||
`node "${cliPath}" template init --name "test-template" --language "python-sync" --path "${testDir}"`,
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
|
||||
// Verify Makefile was updated (it should remain in the parent directory)
|
||||
const updatedMakefile = await fs.readFile(makefilePath, 'utf8')
|
||||
|
||||
expect(updatedMakefile).toContain('e2b:build:dev')
|
||||
expect(updatedMakefile).toContain('e2b:build:prod')
|
||||
expect(updatedMakefile).toContain(`.PHONY: build
|
||||
build/%:
|
||||
\tCGO_ENABLED=1 go build`) // existing script preserved
|
||||
})
|
||||
|
||||
test('should work without Makefile', async () => {
|
||||
// Run init command without Makefile
|
||||
execSync(
|
||||
`node "${cliPath}" template init --name "test-template" --language "python-sync" --path "${testDir}"`,
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
|
||||
// Verify template directory was created
|
||||
const templateDir = path.join(testDir, 'test-template')
|
||||
expect(existsSync(templateDir)).toBe(true)
|
||||
|
||||
// Verify files were created in the template directory
|
||||
const expectedFiles = getExpectedFiles(Language.PythonSync)
|
||||
for (const file of expectedFiles) {
|
||||
expect(existsSync(path.join(templateDir, file))).toBe(true)
|
||||
}
|
||||
|
||||
// Verify Makefile was created in the template directory
|
||||
const createdMakefile = await fs.readFile(
|
||||
path.join(templateDir, 'Makefile'),
|
||||
'utf8'
|
||||
)
|
||||
expect(createdMakefile).toContain('e2b:build:dev')
|
||||
expect(createdMakefile).toContain('e2b:build:prod')
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Content Validation', () => {
|
||||
test('should generate correct TypeScript template content', async () => {
|
||||
execSync(
|
||||
`node "${cliPath}" template init --name "test-ts" --language "typescript" --path "${testDir}"`,
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
|
||||
const templateDir = path.join(testDir, 'test-ts')
|
||||
const templateContent = await fs.readFile(
|
||||
path.join(templateDir, 'template.ts'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
// Verify basic structure
|
||||
expect(templateContent).toContain("import { Template } from 'e2b'")
|
||||
expect(templateContent).toContain('export const template = Template()')
|
||||
expect(templateContent).toContain('fromImage')
|
||||
expect(templateContent).toContain('e2bdev/base')
|
||||
})
|
||||
|
||||
test('should generate correct Python template content', async () => {
|
||||
execSync(
|
||||
`node "${cliPath}" template init --name "test-py" --language "python-sync" --path "${testDir}"`,
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
|
||||
const templateDir = path.join(testDir, 'test-py')
|
||||
const templateContent = await fs.readFile(
|
||||
path.join(templateDir, 'template.py'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
// Verify basic structure
|
||||
expect(templateContent).toContain('from e2b import Template')
|
||||
expect(templateContent).toContain('template = (')
|
||||
expect(templateContent).toContain('Template()')
|
||||
expect(templateContent).toContain('from_image')
|
||||
expect(templateContent).toContain('e2bdev/base')
|
||||
})
|
||||
|
||||
test('should generate correct async Python template content', async () => {
|
||||
execSync(
|
||||
`node "${cliPath}" template init --name "test-py-async" --language "python-async" --path "${testDir}"`,
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
|
||||
const templateContent = await fs.readFile(
|
||||
path.join(testDir, 'test-py-async', 'template.py'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
// Verify async structure
|
||||
expect(templateContent).toContain('from e2b import AsyncTemplate')
|
||||
expect(templateContent).toContain('AsyncTemplate()')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Directory Conflict Handling', () => {
|
||||
test('should fail when directory already exists', async () => {
|
||||
// Create a directory that would conflict
|
||||
const conflictDir = path.join(testDir, 'test')
|
||||
await fs.mkdir(conflictDir)
|
||||
await fs.writeFile(
|
||||
path.join(conflictDir, 'existing.txt'),
|
||||
'existing content'
|
||||
)
|
||||
|
||||
// Should fail when trying to create template with existing directory name
|
||||
expect(() => {
|
||||
execSync(
|
||||
`node "${cliPath}" template init --name "test" --language "typescript" --path "${testDir}"`,
|
||||
{ stdio: 'pipe' }
|
||||
)
|
||||
}).toThrow()
|
||||
|
||||
// Verify original directory is preserved and unchanged
|
||||
expect(existsSync(path.join(conflictDir, 'existing.txt'))).toBe(true)
|
||||
|
||||
// Verify no template files were created in the existing directory
|
||||
expect(existsSync(path.join(conflictDir, 'template.ts'))).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Helper functions
|
||||
function getExpectedFiles(language: Language): string[] {
|
||||
const extension = language === Language.TypeScript ? '.ts' : '.py'
|
||||
const buildDevName =
|
||||
language === Language.TypeScript ? 'build.dev' : 'build_dev'
|
||||
const buildProdName =
|
||||
language === Language.TypeScript ? 'build.prod' : 'build_prod'
|
||||
|
||||
return [
|
||||
`template${extension}`,
|
||||
`${buildDevName}${extension}`,
|
||||
`${buildProdName}${extension}`,
|
||||
]
|
||||
}
|
||||
|
||||
async function verifyTemplateNameInBuildFiles(
|
||||
testDir: string,
|
||||
language: Language,
|
||||
templateName: string
|
||||
): Promise<void> {
|
||||
const extension = language === Language.TypeScript ? '.ts' : '.py'
|
||||
const buildDevName =
|
||||
language === Language.TypeScript ? 'build.dev' : 'build_dev'
|
||||
const buildProdName =
|
||||
language === Language.TypeScript ? 'build.prod' : 'build_prod'
|
||||
|
||||
// Check dev build file contains template name with -dev suffix
|
||||
const devContent = await fs.readFile(
|
||||
path.join(testDir, `${buildDevName}${extension}`),
|
||||
'utf8'
|
||||
)
|
||||
expect(devContent).toContain(`${templateName}-dev`)
|
||||
|
||||
// Check prod build file contains template name
|
||||
const prodContent = await fs.readFile(
|
||||
path.join(testDir, `${buildProdName}${extension}`),
|
||||
'utf8'
|
||||
)
|
||||
expect(prodContent).toContain(templateName)
|
||||
expect(prodContent).not.toContain(`${templateName}-dev`) // should not have -dev suffix
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import { execSync } from 'child_process'
|
||||
import { Language } from '../../../src/commands/template/generators'
|
||||
|
||||
interface FileNames {
|
||||
template: string
|
||||
buildDev: string
|
||||
buildProd: string
|
||||
}
|
||||
|
||||
function getFileNames(language: Language): FileNames {
|
||||
switch (language) {
|
||||
case Language.TypeScript: {
|
||||
return {
|
||||
template: 'template.ts',
|
||||
buildDev: 'build.dev.ts',
|
||||
buildProd: 'build.prod.ts',
|
||||
}
|
||||
}
|
||||
case Language.PythonSync:
|
||||
case Language.PythonAsync: {
|
||||
return {
|
||||
template: 'template.py',
|
||||
buildDev: 'build_dev.py',
|
||||
buildProd: 'build_prod.py',
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported language: ${language}`)
|
||||
}
|
||||
}
|
||||
|
||||
describe('Template Migration', () => {
|
||||
let testDir: string
|
||||
const cliPath = path.join(process.cwd(), 'dist', 'index.js')
|
||||
const fixturesDir = path.join(__dirname, 'fixtures')
|
||||
|
||||
beforeEach(async () => {
|
||||
// Use Node.js built-in temp directory handling
|
||||
testDir = await fs.mkdtemp('e2b-migrate-test-')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test directory
|
||||
if (testDir) {
|
||||
await fs.rm(testDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// Run tests for each test case
|
||||
describe('Migration Test Cases', () => {
|
||||
// Test case names correspond to fixture directory names
|
||||
const testCases = [
|
||||
'complex-python',
|
||||
'copy-variations',
|
||||
'custom-commands',
|
||||
'minimal-dockerfile',
|
||||
'multiple-env',
|
||||
'start-cmd',
|
||||
'multi-stage',
|
||||
]
|
||||
|
||||
testCases.forEach((testCaseName) => {
|
||||
describe(testCaseName.replace('-', ' '), () => {
|
||||
test('should migrate to TypeScript', async () => {
|
||||
await runMigrationTest(testCaseName, Language.TypeScript)
|
||||
})
|
||||
|
||||
test('should migrate to Python sync', async () => {
|
||||
await runMigrationTest(testCaseName, Language.PythonSync)
|
||||
})
|
||||
|
||||
test('should migrate to Python async', async () => {
|
||||
await runMigrationTest(testCaseName, Language.PythonAsync)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
async function runMigrationTest(testCaseName: string, language: Language) {
|
||||
const fixtureDir = path.join(fixturesDir, testCaseName)
|
||||
|
||||
// Copy fixture files to test directory
|
||||
await copyFixtureFiles(fixtureDir, testDir)
|
||||
|
||||
// Run migration
|
||||
execSync(`node ${cliPath} template migrate --language ${language}`, {
|
||||
cwd: testDir,
|
||||
})
|
||||
|
||||
// Determine file extensions and names based on language
|
||||
const fileNames = getFileNames(language)
|
||||
|
||||
// Load expected outputs
|
||||
const expectedDir = path.join(fixtureDir, 'expected', language)
|
||||
const expectedTemplate = await fs.readFile(
|
||||
path.join(expectedDir, fileNames.template),
|
||||
'utf-8'
|
||||
)
|
||||
const expectedBuildDev = await fs.readFile(
|
||||
path.join(expectedDir, fileNames.buildDev),
|
||||
'utf-8'
|
||||
)
|
||||
const expectedBuildProd = await fs.readFile(
|
||||
path.join(expectedDir, fileNames.buildProd),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
// Check generated files
|
||||
const templateFile = await fs.readFile(
|
||||
path.join(testDir, fileNames.template),
|
||||
'utf-8'
|
||||
)
|
||||
expect(templateFile.trim()).toEqual(expectedTemplate.trim())
|
||||
|
||||
const buildDevFile = await fs.readFile(
|
||||
path.join(testDir, fileNames.buildDev),
|
||||
'utf-8'
|
||||
)
|
||||
expect(buildDevFile.trim()).toEqual(expectedBuildDev.trim())
|
||||
|
||||
const buildProdFile = await fs.readFile(
|
||||
path.join(testDir, fileNames.buildProd),
|
||||
'utf-8'
|
||||
)
|
||||
expect(buildProdFile.trim()).toEqual(expectedBuildProd.trim())
|
||||
if (
|
||||
language === Language.PythonSync ||
|
||||
language === Language.PythonAsync
|
||||
) {
|
||||
for (const content of [buildDevFile, buildProdFile]) {
|
||||
expect(content).toContain('from template import template')
|
||||
expect(content).not.toContain('from .template import template')
|
||||
}
|
||||
}
|
||||
|
||||
// Check that old files are renamed to .old extensions
|
||||
const oldDockerfilePath = path.join(testDir, 'e2b.Dockerfile.old')
|
||||
const oldConfigPath = path.join(testDir, 'e2b.toml.old')
|
||||
|
||||
expect(
|
||||
await fs
|
||||
.access(oldDockerfilePath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
).toBe(true)
|
||||
expect(
|
||||
await fs
|
||||
.access(oldConfigPath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
).toBe(true)
|
||||
|
||||
// Verify original files no longer exist
|
||||
const originalDockerfilePath = path.join(testDir, 'e2b.Dockerfile')
|
||||
const originalConfigPath = path.join(testDir, 'e2b.toml')
|
||||
|
||||
expect(
|
||||
await fs
|
||||
.access(originalDockerfilePath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
).toBe(false)
|
||||
expect(
|
||||
await fs
|
||||
.access(originalConfigPath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
).toBe(false)
|
||||
}
|
||||
|
||||
async function copyFixtureFiles(fixtureDir: string, targetDir: string) {
|
||||
// Copy Dockerfile and config
|
||||
await fs.copyFile(
|
||||
path.join(fixtureDir, 'e2b.Dockerfile'),
|
||||
path.join(targetDir, 'e2b.Dockerfile')
|
||||
)
|
||||
await fs.copyFile(
|
||||
path.join(fixtureDir, 'e2b.toml'),
|
||||
path.join(targetDir, 'e2b.toml')
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
describe('Override Options', () => {
|
||||
test('should apply name, command and resource overrides', async () => {
|
||||
const dockerfile = 'FROM node:18'
|
||||
await fs.writeFile(path.join(testDir, 'e2b.Dockerfile'), dockerfile)
|
||||
|
||||
const config = `template_id = "config-name"
|
||||
dockerfile = "e2b.Dockerfile"
|
||||
cpu_count = 2
|
||||
memory_mb = 512`
|
||||
await fs.writeFile(path.join(testDir, 'e2b.toml'), config)
|
||||
|
||||
execSync(
|
||||
`node ${cliPath} template migrate --language typescript --name " Overridden-Name " --cmd "node server.js" --ready-cmd "curl localhost:3000" --cpu-count 4 --memory-mb 2048`,
|
||||
{
|
||||
cwd: testDir,
|
||||
}
|
||||
)
|
||||
|
||||
const templateFile = await fs.readFile(
|
||||
path.join(testDir, 'template.ts'),
|
||||
'utf-8'
|
||||
)
|
||||
expect(templateFile).toContain(
|
||||
".setStartCmd('sudo node server.js', 'curl localhost:3000')"
|
||||
)
|
||||
|
||||
const buildProdFile = await fs.readFile(
|
||||
path.join(testDir, 'build.prod.ts'),
|
||||
'utf-8'
|
||||
)
|
||||
expect(buildProdFile).toContain("'overridden-name'")
|
||||
expect(buildProdFile).not.toContain("'config-name'")
|
||||
expect(buildProdFile).toContain('cpuCount: 4')
|
||||
expect(buildProdFile).toContain('memoryMB: 2048')
|
||||
})
|
||||
|
||||
test('should reject an invalid --name', async () => {
|
||||
const dockerfile = 'FROM node:18'
|
||||
await fs.writeFile(path.join(testDir, 'e2b.Dockerfile'), dockerfile)
|
||||
|
||||
const config = `template_id = "config-name"
|
||||
dockerfile = "e2b.Dockerfile"`
|
||||
await fs.writeFile(path.join(testDir, 'e2b.toml'), config)
|
||||
|
||||
expect(() => {
|
||||
execSync(
|
||||
`node ${cliPath} template migrate --language typescript --name "Invalid Name"`,
|
||||
{
|
||||
cwd: testDir,
|
||||
}
|
||||
)
|
||||
}).toThrow()
|
||||
})
|
||||
|
||||
test('should reject non-numeric resource overrides', async () => {
|
||||
const dockerfile = 'FROM node:18'
|
||||
await fs.writeFile(path.join(testDir, 'e2b.Dockerfile'), dockerfile)
|
||||
|
||||
const config = `template_id = "config-name"
|
||||
dockerfile = "e2b.Dockerfile"`
|
||||
await fs.writeFile(path.join(testDir, 'e2b.toml'), config)
|
||||
|
||||
expect(() => {
|
||||
execSync(
|
||||
`node ${cliPath} template migrate --language typescript --cpu-count abc`,
|
||||
{
|
||||
cwd: testDir,
|
||||
}
|
||||
)
|
||||
}).toThrow()
|
||||
|
||||
expect(() => {
|
||||
execSync(
|
||||
`node ${cliPath} template migrate --language typescript --memory-mb abc`,
|
||||
{
|
||||
cwd: testDir,
|
||||
}
|
||||
)
|
||||
}).toThrow()
|
||||
})
|
||||
|
||||
test('should reject an odd memory override', async () => {
|
||||
const dockerfile = 'FROM node:18'
|
||||
await fs.writeFile(path.join(testDir, 'e2b.Dockerfile'), dockerfile)
|
||||
|
||||
const config = `template_id = "resources"
|
||||
dockerfile = "e2b.Dockerfile"`
|
||||
await fs.writeFile(path.join(testDir, 'e2b.toml'), config)
|
||||
|
||||
expect(() => {
|
||||
execSync(
|
||||
`node ${cliPath} template migrate --language typescript --memory-mb 1023`,
|
||||
{
|
||||
cwd: testDir,
|
||||
}
|
||||
)
|
||||
}).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Cases', () => {
|
||||
test('should succeed with warning when config file is missing', async () => {
|
||||
// Create only Dockerfile, no config
|
||||
const dockerfile = 'FROM node:18'
|
||||
await fs.writeFile(path.join(testDir, 'e2b.Dockerfile'), dockerfile)
|
||||
|
||||
// Run migration and expect it to succeed with warning (capture stderr + stdout)
|
||||
const output = execSync(
|
||||
`node ${cliPath} template migrate --language typescript 2>&1`,
|
||||
{
|
||||
cwd: testDir,
|
||||
encoding: 'utf-8',
|
||||
}
|
||||
)
|
||||
|
||||
expect(output).toContain(
|
||||
'Config file ./e2b.toml not found. Using defaults.'
|
||||
)
|
||||
expect(output).toContain('Migration completed successfully')
|
||||
})
|
||||
|
||||
test('should fail gracefully when Dockerfile is missing', async () => {
|
||||
// Create only config, no Dockerfile
|
||||
const config = `template_id = "test-app"
|
||||
dockerfile = "e2b.Dockerfile"`
|
||||
await fs.writeFile(path.join(testDir, 'e2b.toml'), config)
|
||||
|
||||
// Run migration and expect it to fail
|
||||
expect(() => {
|
||||
execSync(`node ${cliPath} template migrate --language typescript`, {
|
||||
cwd: testDir,
|
||||
})
|
||||
}).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Collision Handling', () => {
|
||||
test('should error out if files already exist', async () => {
|
||||
// Create test Dockerfile
|
||||
const dockerfile = 'FROM node:18'
|
||||
await fs.writeFile(path.join(testDir, 'e2b.Dockerfile'), dockerfile)
|
||||
|
||||
// Create test config
|
||||
const config = `template_id = "test-app"
|
||||
dockerfile = "e2b.Dockerfile"`
|
||||
await fs.writeFile(path.join(testDir, 'e2b.toml'), config)
|
||||
|
||||
// Create existing files
|
||||
await fs.writeFile(path.join(testDir, 'template.ts'), '// existing')
|
||||
await fs.writeFile(path.join(testDir, 'build.dev.ts'), '// existing')
|
||||
await fs.writeFile(path.join(testDir, 'build.prod.ts'), '// existing')
|
||||
|
||||
// Run migration
|
||||
expect(() => {
|
||||
execSync(`node ${cliPath} template migrate --language typescript`, {
|
||||
cwd: testDir,
|
||||
})
|
||||
}).toThrow()
|
||||
|
||||
const files = await fs.readdir(testDir)
|
||||
expect(files).toContain('template.ts')
|
||||
expect(files).toContain('build.dev.ts')
|
||||
expect(files).toContain('build.prod.ts')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import { execSync } from 'child_process'
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest'
|
||||
|
||||
describe('Template Publish --config', () => {
|
||||
let testDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = await fs.mkdtemp('e2b-publish-config-test-')
|
||||
|
||||
const defaultConfig = `template_id = "default-template-id"
|
||||
dockerfile = "e2b.Dockerfile"`
|
||||
await fs.writeFile(path.join(testDir, 'e2b.toml'), defaultConfig)
|
||||
|
||||
const customConfig = `template_id = "custom-template-id"
|
||||
dockerfile = "e2b.Dockerfile"`
|
||||
await fs.writeFile(path.join(testDir, 'custom.toml'), customConfig)
|
||||
|
||||
await fs.writeFile(path.join(testDir, 'e2b.Dockerfile'), 'FROM alpine:3.18')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (testDir) {
|
||||
await fs.rm(testDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('uses the config file passed via --config even when e2b.toml exists', async () => {
|
||||
const cliPath = path.join(process.cwd(), 'dist', 'index.js')
|
||||
|
||||
let output = ''
|
||||
try {
|
||||
// This will likely fail on the network call, but we only need stdout up to that point
|
||||
output = execSync(
|
||||
`node "${cliPath}" template publish --yes --path "${testDir}" --config custom.toml 2>&1`,
|
||||
{ encoding: 'utf-8', stdio: 'pipe', timeout: 10_000 }
|
||||
)
|
||||
} catch (err: any) {
|
||||
// Capture partial output from the failed process (expected due to no network)
|
||||
output = (err?.stdout ?? '') + (err?.stderr ?? '')
|
||||
}
|
||||
|
||||
// It should log the sandbox template selection with the custom template ID and path
|
||||
expect(output).toContain('Sandbox templates to publish')
|
||||
expect(output).toContain('custom-template-id')
|
||||
expect(output).toContain('custom.toml')
|
||||
// And should not show the default template id when custom is provided
|
||||
expect(output).not.toContain('default-template-id')
|
||||
})
|
||||
|
||||
test('uses the default e2b.toml when --config is not provided', async () => {
|
||||
const cliPath = path.join(process.cwd(), 'dist', 'index.js')
|
||||
|
||||
let output = ''
|
||||
try {
|
||||
output = execSync(
|
||||
`node "${cliPath}" template publish --yes --path "${testDir}" 2>&1`,
|
||||
{ encoding: 'utf-8', stdio: 'pipe', timeout: 10_000 }
|
||||
)
|
||||
} catch (err: any) {
|
||||
output = (err?.stdout ?? '') + (err?.stderr ?? '')
|
||||
}
|
||||
|
||||
// Should reference the default config and template id
|
||||
expect(output).toContain('Sandbox templates to publish')
|
||||
expect(output).toContain('default-template-id')
|
||||
expect(output).toContain('e2b.toml')
|
||||
// Should not mention the custom config/template id
|
||||
expect(output).not.toContain('custom-template-id')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { execSync, spawn, spawnSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
|
||||
export const isDebug = process.env.E2B_DEBUG !== undefined
|
||||
|
||||
type CliRunOptions = {
|
||||
timeoutMs: number
|
||||
env?: NodeJS.ProcessEnv
|
||||
}
|
||||
|
||||
type CliRunSyncOptions = CliRunOptions & {
|
||||
input?: string | Buffer
|
||||
}
|
||||
|
||||
export type CliRunResult = {
|
||||
status: number | null
|
||||
stdout: Buffer
|
||||
stderr: Buffer
|
||||
error?: Error
|
||||
}
|
||||
|
||||
const cliPath = path.join(process.cwd(), 'dist', 'index.js')
|
||||
|
||||
export async function setup() {
|
||||
execSync('pnpm build', { stdio: 'inherit' })
|
||||
}
|
||||
|
||||
export function runCli(
|
||||
args: string[],
|
||||
options: CliRunSyncOptions
|
||||
): ReturnType<typeof spawnSync> {
|
||||
return spawnSync('node', [cliPath, ...args], {
|
||||
env: options.env ?? process.env,
|
||||
input: options.input,
|
||||
encoding: 'utf8',
|
||||
timeout: options.timeoutMs,
|
||||
})
|
||||
}
|
||||
|
||||
export async function runCliWithPipedStdin(
|
||||
args: string[],
|
||||
input: Buffer,
|
||||
options: CliRunOptions
|
||||
): Promise<CliRunResult> {
|
||||
return await new Promise((resolve) => {
|
||||
const child = spawn('node', [cliPath, ...args], {
|
||||
env: options.env ?? process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
const stdoutChunks: Buffer[] = []
|
||||
const stderrChunks: Buffer[] = []
|
||||
let childError: Error | undefined
|
||||
let timedOut = false
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
child.kill()
|
||||
}, options.timeoutMs)
|
||||
|
||||
child.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)))
|
||||
child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)))
|
||||
child.on('error', (err) => {
|
||||
childError = err
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer)
|
||||
const timeoutError = timedOut
|
||||
? Object.assign(new Error('CLI command timed out'), {
|
||||
code: 'ETIMEDOUT',
|
||||
} as NodeJS.ErrnoException)
|
||||
: undefined
|
||||
|
||||
resolve({
|
||||
status: code,
|
||||
stdout: Buffer.concat(stdoutChunks),
|
||||
stderr: Buffer.concat(stderrChunks),
|
||||
error: childError ?? timeoutError,
|
||||
})
|
||||
})
|
||||
|
||||
child.stdin.write(input)
|
||||
child.stdin.end()
|
||||
})
|
||||
}
|
||||
|
||||
export function bufferToText(value: Buffer | string | null | undefined): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
return typeof value === 'string' ? value : value.toString('utf8')
|
||||
}
|
||||
|
||||
export function parseEnvInt(name: string, fallback: number): number {
|
||||
const raw = process.env[name]
|
||||
if (!raw) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as fs from 'fs'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { writeUserConfig, type UserConfig } from '../src/user'
|
||||
|
||||
const tmpDirs: string[] = []
|
||||
const isPosix = process.platform !== 'win32'
|
||||
|
||||
afterEach(() => {
|
||||
for (const tmpDir of tmpDirs.splice(0)) {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('writeUserConfig', () => {
|
||||
it('stores API credentials in an owner-only config file and directory', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'e2b-config-perms-'))
|
||||
tmpDirs.push(tmpDir)
|
||||
const configPath = path.join(tmpDir, '.e2b', 'config.json')
|
||||
const config: UserConfig = {
|
||||
version: 1,
|
||||
identity: {
|
||||
email: 'victim@example.com',
|
||||
},
|
||||
oauth: {
|
||||
token_endpoint: 'https://hydra.example.com/oauth2/token',
|
||||
revoke_endpoint: 'https://hydra.example.com/oauth2/revoke',
|
||||
client_id: 'cli-client-id',
|
||||
},
|
||||
tokens: {
|
||||
access_token: 'access-token-secret',
|
||||
refresh_token: 'refresh-token-secret',
|
||||
},
|
||||
last_refresh: '2024-06-24T12:00:00.000Z',
|
||||
teamName: 'default',
|
||||
teamId: 'team-id',
|
||||
teamApiKey: 'team-api-key-secret',
|
||||
}
|
||||
|
||||
writeUserConfig(configPath, config)
|
||||
|
||||
// POSIX permission bits (chmod/stat.mode) are not reliably preserved on
|
||||
// Windows, where Node reports broad Windows-derived modes regardless of
|
||||
// the chmod call. Only assert the 0o700/0o600 masks on POSIX platforms.
|
||||
if (isPosix) {
|
||||
expect(fs.statSync(path.dirname(configPath)).mode & 0o777).toBe(0o700)
|
||||
expect(fs.statSync(configPath).mode & 0o777).toBe(0o600)
|
||||
}
|
||||
expect(JSON.parse(fs.readFileSync(configPath, 'utf8'))).toEqual(config)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { handleE2BRequestError, E2BRequestError } from '../../src/utils/errors'
|
||||
|
||||
describe('handleE2BRequestError', () => {
|
||||
test('does not throw when there is no error', () => {
|
||||
const res = { data: { id: '123' } }
|
||||
expect(() => handleE2BRequestError(res)).not.toThrow()
|
||||
})
|
||||
|
||||
test('throws E2BRequestError for known status codes', () => {
|
||||
const res = { error: { code: 401, message: 'invalid token' } }
|
||||
expect(() => handleE2BRequestError(res, 'Auth failed')).toThrow(
|
||||
E2BRequestError
|
||||
)
|
||||
expect(() => handleE2BRequestError(res, 'Auth failed')).toThrow(
|
||||
'Auth failed: [401] unauthorized: invalid token'
|
||||
)
|
||||
})
|
||||
|
||||
test('throws E2BRequestError with message for status code 0', () => {
|
||||
const res = { error: { code: 0, message: 'connection reset' } }
|
||||
expect(() => handleE2BRequestError(res, 'Request failed')).toThrow(
|
||||
E2BRequestError
|
||||
)
|
||||
expect(() => handleE2BRequestError(res, 'Request failed')).toThrow(
|
||||
'Request failed: [0] unknown error: connection reset'
|
||||
)
|
||||
})
|
||||
|
||||
test('throws E2BRequestError when error code is missing', () => {
|
||||
const res = { error: { message: 'something went wrong' } } as any
|
||||
expect(() => handleE2BRequestError(res, 'Request failed')).toThrow(
|
||||
E2BRequestError
|
||||
)
|
||||
expect(() => handleE2BRequestError(res, 'Request failed')).toThrow(
|
||||
'Request failed: [0] unknown error: something went wrong'
|
||||
)
|
||||
})
|
||||
|
||||
test('handles valid but unlisted HTTP status codes via statuses package', () => {
|
||||
const res = { error: { code: 502, message: 'upstream down' } }
|
||||
expect(() => handleE2BRequestError(res)).toThrow(E2BRequestError)
|
||||
expect(() => handleE2BRequestError(res)).toThrow(
|
||||
'[502] Bad Gateway: upstream down'
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user