569 lines
17 KiB
TypeScript
569 lines
17 KiB
TypeScript
import { assert, test, beforeEach, afterEach } from 'vitest'
|
|
import {
|
|
ConnectionConfig,
|
|
setupRequestController,
|
|
wrapStreamWithConnectionCleanup,
|
|
} from '../src/connectionConfig'
|
|
|
|
// Store original env vars to restore after tests
|
|
let originalEnv: { [key: string]: string | undefined }
|
|
|
|
beforeEach(() => {
|
|
originalEnv = {
|
|
E2B_API_URL: process.env.E2B_API_URL,
|
|
E2B_DOMAIN: process.env.E2B_DOMAIN,
|
|
E2B_SANDBOX_URL: process.env.E2B_SANDBOX_URL,
|
|
E2B_DEBUG: process.env.E2B_DEBUG,
|
|
E2B_VALIDATE_API_KEY: process.env.E2B_VALIDATE_API_KEY,
|
|
}
|
|
})
|
|
|
|
afterEach(() => {
|
|
// Restore original env vars
|
|
Object.keys(originalEnv).forEach((key) => {
|
|
if (originalEnv[key] === undefined) {
|
|
delete process.env[key]
|
|
} else {
|
|
process.env[key] = originalEnv[key]
|
|
}
|
|
})
|
|
|
|
// Clear the process-wide integration attribution
|
|
ConnectionConfig.setIntegration(undefined)
|
|
})
|
|
|
|
test('api_url defaults correctly', () => {
|
|
// Ensure no env vars interfere
|
|
delete process.env.E2B_API_URL
|
|
delete process.env.E2B_DOMAIN
|
|
delete process.env.E2B_DEBUG
|
|
|
|
const config = new ConnectionConfig()
|
|
assert.equal(config.apiUrl, 'https://api.e2b.app')
|
|
})
|
|
|
|
test('api_url in args', () => {
|
|
const config = new ConnectionConfig({ apiUrl: 'http://localhost:8080' })
|
|
assert.equal(config.apiUrl, 'http://localhost:8080')
|
|
})
|
|
|
|
test('api_url in env var', () => {
|
|
process.env.E2B_API_URL = 'http://localhost:8080'
|
|
|
|
const config = new ConnectionConfig()
|
|
assert.equal(config.apiUrl, 'http://localhost:8080')
|
|
})
|
|
|
|
test('api_url has correct priority', () => {
|
|
process.env.E2B_API_URL = 'http://localhost:1111'
|
|
|
|
const config = new ConnectionConfig({ apiUrl: 'http://localhost:8080' })
|
|
assert.equal(config.apiUrl, 'http://localhost:8080')
|
|
})
|
|
|
|
test('sandbox_url defaults to stable sandbox host in production', () => {
|
|
delete process.env.E2B_SANDBOX_URL
|
|
delete process.env.E2B_DOMAIN
|
|
delete process.env.E2B_DEBUG
|
|
|
|
const config = new ConnectionConfig()
|
|
|
|
assert.equal(
|
|
config.getSandboxUrl('sbx-test', {
|
|
sandboxDomain: 'e2b.app',
|
|
envdPort: 49983,
|
|
}),
|
|
'https://sandbox.e2b.app'
|
|
)
|
|
})
|
|
|
|
test('sandbox_direct_url keeps per-sandbox host in production', () => {
|
|
delete process.env.E2B_SANDBOX_URL
|
|
delete process.env.E2B_DOMAIN
|
|
delete process.env.E2B_DEBUG
|
|
|
|
const config = new ConnectionConfig()
|
|
|
|
assert.equal(
|
|
config.getSandboxDirectUrl('sbx-test', {
|
|
sandboxDomain: 'e2b.app',
|
|
envdPort: 49983,
|
|
}),
|
|
'https://49983-sbx-test.e2b.app'
|
|
)
|
|
})
|
|
|
|
test('sandbox_url keeps per-sandbox host outside production', () => {
|
|
delete process.env.E2B_SANDBOX_URL
|
|
delete process.env.E2B_DEBUG
|
|
|
|
const config = new ConnectionConfig({ domain: 'e2b.dev' })
|
|
|
|
assert.equal(
|
|
config.getSandboxUrl('sbx-test', {
|
|
sandboxDomain: 'sandbox.e2b.dev',
|
|
envdPort: 49983,
|
|
}),
|
|
'https://49983-sbx-test.sandbox.e2b.dev'
|
|
)
|
|
})
|
|
|
|
test('sandbox_url in args has priority', () => {
|
|
process.env.E2B_SANDBOX_URL = 'https://sandbox.from-env'
|
|
|
|
const config = new ConnectionConfig({ sandboxUrl: 'https://sandbox.custom' })
|
|
|
|
assert.equal(
|
|
config.getSandboxUrl('sbx-test', {
|
|
sandboxDomain: 'e2b.app',
|
|
envdPort: 49983,
|
|
}),
|
|
'https://sandbox.custom'
|
|
)
|
|
})
|
|
|
|
test('sandbox_url in env var overrides default', () => {
|
|
process.env.E2B_SANDBOX_URL = 'https://sandbox.from-env'
|
|
|
|
const config = new ConnectionConfig()
|
|
|
|
assert.equal(
|
|
config.getSandboxUrl('sbx-test', {
|
|
sandboxDomain: 'e2b.app',
|
|
envdPort: 49983,
|
|
}),
|
|
'https://sandbox.from-env'
|
|
)
|
|
})
|
|
|
|
test('sandbox_url stays localhost in debug mode', () => {
|
|
delete process.env.E2B_SANDBOX_URL
|
|
process.env.E2B_DEBUG = 'true'
|
|
|
|
const config = new ConnectionConfig()
|
|
|
|
assert.equal(
|
|
config.getSandboxUrl('sbx-test', {
|
|
sandboxDomain: 'e2b.app',
|
|
envdPort: 49983,
|
|
}),
|
|
'http://localhost:49983'
|
|
)
|
|
})
|
|
|
|
test('validateApiKey defaults to true', () => {
|
|
delete process.env.E2B_VALIDATE_API_KEY
|
|
|
|
const config = new ConnectionConfig()
|
|
assert.equal(config.validateApiKey, true)
|
|
})
|
|
|
|
test('validateApiKey disabled via env var', () => {
|
|
process.env.E2B_VALIDATE_API_KEY = 'false'
|
|
|
|
const config = new ConnectionConfig()
|
|
assert.equal(config.validateApiKey, false)
|
|
})
|
|
|
|
test('validateApiKey in args has priority over env var', () => {
|
|
process.env.E2B_VALIDATE_API_KEY = 'true'
|
|
|
|
const config = new ConnectionConfig({ validateApiKey: false })
|
|
assert.equal(config.validateApiKey, false)
|
|
})
|
|
|
|
test('debug false in args overrides E2B_DEBUG env var', () => {
|
|
process.env.E2B_DEBUG = 'true'
|
|
|
|
const config = new ConnectionConfig({ debug: false })
|
|
assert.equal(config.debug, false)
|
|
})
|
|
|
|
test('debug defaults to E2B_DEBUG env var', () => {
|
|
process.env.E2B_DEBUG = 'true'
|
|
|
|
const config = new ConnectionConfig()
|
|
assert.equal(config.debug, true)
|
|
})
|
|
|
|
test('setIntegration appends the integration to the user agent', () => {
|
|
ConnectionConfig.setIntegration('testing/version')
|
|
const config = new ConnectionConfig()
|
|
|
|
assert.equal(config.headers?.['User-Agent']?.startsWith('e2b-js-sdk/'), true)
|
|
assert.equal(
|
|
config.headers?.['User-Agent']?.endsWith(' testing/version'),
|
|
true
|
|
)
|
|
})
|
|
|
|
test('integration survives config rebuilds', () => {
|
|
ConnectionConfig.setIntegration('testing/version')
|
|
const config = new ConnectionConfig()
|
|
const rebuiltConfig = new ConnectionConfig({ ...config })
|
|
|
|
assert.equal(
|
|
rebuiltConfig.headers?.['User-Agent']?.endsWith(' testing/version'),
|
|
true
|
|
)
|
|
})
|
|
|
|
test('setIntegration does not retro-tag configs built earlier', () => {
|
|
const before = new ConnectionConfig()
|
|
ConnectionConfig.setIntegration('testing/version')
|
|
const after = new ConnectionConfig()
|
|
|
|
assert.equal(before.headers?.['User-Agent']?.includes('testing'), false)
|
|
assert.equal(
|
|
after.headers?.['User-Agent']?.endsWith(' testing/version'),
|
|
true
|
|
)
|
|
})
|
|
|
|
test('clearing the integration restores the plain user agent', () => {
|
|
ConnectionConfig.setIntegration('testing/version')
|
|
ConnectionConfig.setIntegration(undefined)
|
|
const config = new ConnectionConfig()
|
|
|
|
assert.equal(config.headers?.['User-Agent']?.startsWith('e2b-js-sdk/'), true)
|
|
assert.equal(config.headers?.['User-Agent']?.includes('testing'), false)
|
|
})
|
|
|
|
test('custom user agent is preserved without integration', () => {
|
|
const config = new ConnectionConfig({
|
|
apiHeaders: { 'User-Agent': 'my-app/1.0' },
|
|
})
|
|
|
|
assert.equal(config.headers?.['User-Agent'], 'my-app/1.0')
|
|
})
|
|
|
|
test('custom user agent wins over integration', () => {
|
|
ConnectionConfig.setIntegration('testing/version')
|
|
|
|
const config = new ConnectionConfig({
|
|
headers: { 'User-Agent': 'my-app/1.0' },
|
|
})
|
|
|
|
assert.equal(config.headers?.['User-Agent'], 'my-app/1.0')
|
|
})
|
|
|
|
test('custom user agent survives config rebuilds', () => {
|
|
ConnectionConfig.setIntegration('testing/version')
|
|
const config = new ConnectionConfig({
|
|
apiHeaders: { 'User-Agent': 'my-app/1.0' },
|
|
})
|
|
const rebuiltConfig = new ConnectionConfig({ ...config })
|
|
|
|
assert.equal(rebuiltConfig.headers?.['User-Agent'], 'my-app/1.0')
|
|
})
|
|
|
|
test('clearing the integration propagates to config rebuilds', () => {
|
|
ConnectionConfig.setIntegration('testing/version')
|
|
const config = new ConnectionConfig()
|
|
ConnectionConfig.setIntegration(undefined)
|
|
const rebuiltConfig = new ConnectionConfig({ ...config })
|
|
|
|
assert.equal(
|
|
rebuiltConfig.headers?.['User-Agent']?.startsWith('e2b-js-sdk/'),
|
|
true
|
|
)
|
|
assert.equal(
|
|
rebuiltConfig.headers?.['User-Agent']?.includes('testing'),
|
|
false
|
|
)
|
|
})
|
|
|
|
test('getSignal returns user signal when no timeout is set', () => {
|
|
const config = new ConnectionConfig({ requestTimeoutMs: 0 })
|
|
const controller = new AbortController()
|
|
const signal = config.getSignal(0, controller.signal)
|
|
assert.strictEqual(signal, controller.signal)
|
|
})
|
|
|
|
test('getSignal aborts when user signal is aborted', () => {
|
|
const config = new ConnectionConfig({ requestTimeoutMs: 60_000 })
|
|
const controller = new AbortController()
|
|
const signal = config.getSignal(undefined, controller.signal)
|
|
assert.ok(signal)
|
|
assert.equal(signal!.aborted, false)
|
|
controller.abort()
|
|
assert.equal(signal!.aborted, true)
|
|
})
|
|
|
|
test('getSignal returns timeout signal when no user signal is provided', () => {
|
|
const config = new ConnectionConfig({ requestTimeoutMs: 60_000 })
|
|
const signal = config.getSignal()
|
|
assert.ok(signal)
|
|
assert.equal(signal!.aborted, false)
|
|
})
|
|
|
|
test('getSignal returns undefined when no timeout and no signal', () => {
|
|
const config = new ConnectionConfig({ requestTimeoutMs: 0 })
|
|
const signal = config.getSignal(0)
|
|
assert.equal(signal, undefined)
|
|
})
|
|
|
|
test('requestTimeoutMs 0 from the config disables the timeout', () => {
|
|
const config = new ConnectionConfig({ requestTimeoutMs: 0 })
|
|
// The stored value is kept as 0 (not replaced by the default).
|
|
assert.equal(config.requestTimeoutMs, 0)
|
|
// getSignal() with no per-call arg falls back to the stored 0, which must
|
|
// NOT produce a timeout signal.
|
|
assert.equal(config.getSignal(), undefined)
|
|
// With only a user signal, no timeout signal is layered on top.
|
|
const controller = new AbortController()
|
|
assert.strictEqual(
|
|
config.getSignal(undefined, controller.signal),
|
|
controller.signal
|
|
)
|
|
})
|
|
|
|
test('setupRequestController with config timeout 0 never auto-aborts', async () => {
|
|
const config = new ConnectionConfig({ requestTimeoutMs: 0 })
|
|
const { controller } = setupRequestController(
|
|
config.requestTimeoutMs,
|
|
undefined
|
|
)
|
|
await new Promise((resolve) => setTimeout(resolve, 40))
|
|
assert.equal(controller.signal.aborted, false)
|
|
})
|
|
|
|
test('setupRequestController aborts when user signal aborts', () => {
|
|
const userController = new AbortController()
|
|
const { controller } = setupRequestController(0, userController.signal)
|
|
|
|
assert.equal(controller.signal.aborted, false)
|
|
userController.abort()
|
|
assert.equal(controller.signal.aborted, true)
|
|
})
|
|
|
|
test('setupRequestController is already aborted when user signal was pre-aborted', () => {
|
|
const userController = new AbortController()
|
|
userController.abort()
|
|
|
|
const { controller } = setupRequestController(0, userController.signal)
|
|
assert.equal(controller.signal.aborted, true)
|
|
})
|
|
|
|
test('setupRequestController cleanup removes the user-signal listener', () => {
|
|
const userController = new AbortController()
|
|
const { controller, cleanup } = setupRequestController(
|
|
0,
|
|
userController.signal
|
|
)
|
|
|
|
cleanup()
|
|
// Internal controller is aborted by cleanup, so it stays aborted.
|
|
assert.equal(controller.signal.aborted, true)
|
|
|
|
// After cleanup the listener is detached — a subsequent abort on the
|
|
// user signal must not propagate (verified indirectly: cleanup is
|
|
// idempotent and no error is thrown).
|
|
userController.abort()
|
|
cleanup()
|
|
})
|
|
|
|
test('setupRequestController clearStartTimeout stops the handshake timer', async () => {
|
|
const { controller, clearStartTimeout } = setupRequestController(
|
|
20,
|
|
undefined
|
|
)
|
|
clearStartTimeout()
|
|
await new Promise((resolve) => setTimeout(resolve, 40))
|
|
assert.equal(controller.signal.aborted, false)
|
|
})
|
|
|
|
test('setupRequestController handshake timer aborts when not cleared', async () => {
|
|
const { controller } = setupRequestController(20, undefined)
|
|
await new Promise((resolve) => setTimeout(resolve, 40))
|
|
assert.equal(controller.signal.aborted, true)
|
|
})
|
|
|
|
test('setupRequestController handshake timeout aborts with TimeoutError reason', async () => {
|
|
const { controller } = setupRequestController(20, undefined)
|
|
await new Promise((resolve) => setTimeout(resolve, 40))
|
|
assert.equal(controller.signal.aborted, true)
|
|
assert.ok(controller.signal.reason instanceof DOMException)
|
|
assert.equal((controller.signal.reason as DOMException).name, 'TimeoutError')
|
|
})
|
|
|
|
test('setupRequestController user signal still cancels after clearStartTimeout', () => {
|
|
const userController = new AbortController()
|
|
const { controller, clearStartTimeout } = setupRequestController(
|
|
60_000,
|
|
userController.signal
|
|
)
|
|
clearStartTimeout()
|
|
assert.equal(controller.signal.aborted, false)
|
|
userController.abort()
|
|
assert.equal(controller.signal.aborted, true)
|
|
})
|
|
|
|
// Builds a source ReadableStream that records whether its underlying reader was
|
|
// cancelled, standing in for a fetch response body backed by a pooled
|
|
// connection. `cancel` being invoked is what releases that connection.
|
|
function trackedSource() {
|
|
const state: { cancelled: boolean; cancelReason: unknown } = {
|
|
cancelled: false,
|
|
cancelReason: undefined,
|
|
}
|
|
const chunks = ['a', 'b'].map((s) => new TextEncoder().encode(s))
|
|
let i = 0
|
|
const body = new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
if (i < chunks.length) {
|
|
controller.enqueue(chunks[i++])
|
|
} else {
|
|
controller.close()
|
|
}
|
|
},
|
|
cancel(reason) {
|
|
state.cancelled = true
|
|
state.cancelReason = reason
|
|
},
|
|
})
|
|
return { body, state }
|
|
}
|
|
|
|
async function readAll(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
const reader = stream.getReader()
|
|
let out = ''
|
|
const decoder = new TextDecoder()
|
|
for (;;) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
out += decoder.decode(value, { stream: true })
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Builds a source ReadableStream that never produces a chunk and errors its
|
|
// pending read when `signal` aborts, standing in for a stalled fetch response
|
|
// body whose connection is torn down by aborting the request controller.
|
|
function stallingSource(signal: AbortSignal) {
|
|
const state: { cancelled: boolean } = { cancelled: false }
|
|
const body = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
signal.addEventListener('abort', () => controller.error(signal.reason), {
|
|
once: true,
|
|
})
|
|
},
|
|
cancel() {
|
|
state.cancelled = true
|
|
},
|
|
})
|
|
return { body, state }
|
|
}
|
|
|
|
test('wrapStreamWithConnectionCleanup releases once on full read', async () => {
|
|
const { body } = trackedSource()
|
|
let cleanups = 0
|
|
const stream = wrapStreamWithConnectionCleanup(body, {
|
|
clearStartTimeout: () => {},
|
|
cleanup: () => {
|
|
cleanups++
|
|
},
|
|
controller: new AbortController(),
|
|
})
|
|
assert.equal(await readAll(stream), 'ab')
|
|
assert.equal(cleanups, 1)
|
|
})
|
|
|
|
test('wrapStreamWithConnectionCleanup cancel cancels the underlying reader', async () => {
|
|
const { body, state } = trackedSource()
|
|
let cleanups = 0
|
|
const stream = wrapStreamWithConnectionCleanup(body, {
|
|
clearStartTimeout: () => {},
|
|
cleanup: () => {
|
|
cleanups++
|
|
},
|
|
controller: new AbortController(),
|
|
})
|
|
await stream.cancel('done')
|
|
assert.equal(state.cancelled, true)
|
|
assert.equal(state.cancelReason, 'done')
|
|
assert.equal(cleanups, 1)
|
|
})
|
|
|
|
test('wrapStreamWithConnectionCleanup handles a null body', async () => {
|
|
let cleanups = 0
|
|
let cleared = 0
|
|
const stream = wrapStreamWithConnectionCleanup(null, {
|
|
clearStartTimeout: () => {
|
|
cleared++
|
|
},
|
|
cleanup: () => {
|
|
cleanups++
|
|
},
|
|
controller: new AbortController(),
|
|
})
|
|
assert.equal(cleared, 1)
|
|
assert.equal(cleanups, 1)
|
|
assert.equal(await readAll(stream), '')
|
|
})
|
|
|
|
test('wrapStreamWithConnectionCleanup aborts and releases an idle stream', async () => {
|
|
const controller = new AbortController()
|
|
const { body } = stallingSource(controller.signal)
|
|
let cleanups = 0
|
|
const stream = wrapStreamWithConnectionCleanup(body, {
|
|
clearStartTimeout: () => {},
|
|
cleanup: () => {
|
|
cleanups++
|
|
},
|
|
controller,
|
|
idleTimeoutMs: 20,
|
|
})
|
|
|
|
// No chunk ever arrives, so the idle timer fires, aborts the controller,
|
|
// and the read rejects with the TimeoutError reason.
|
|
let error: unknown
|
|
try {
|
|
await readAll(stream)
|
|
} catch (err) {
|
|
error = err
|
|
}
|
|
assert.equal((error as DOMException)?.name, 'TimeoutError')
|
|
assert.equal(cleanups, 1)
|
|
assert.equal(controller.signal.aborted, true)
|
|
})
|
|
|
|
test('wrapStreamWithConnectionCleanup with idle timeout 0 never auto-aborts', async () => {
|
|
const { body } = trackedSource()
|
|
let cleanups = 0
|
|
const stream = wrapStreamWithConnectionCleanup(body, {
|
|
clearStartTimeout: () => {},
|
|
cleanup: () => {
|
|
cleanups++
|
|
},
|
|
controller: new AbortController(),
|
|
idleTimeoutMs: 0,
|
|
})
|
|
assert.equal(await readAll(stream), 'ab')
|
|
assert.equal(cleanups, 1)
|
|
})
|
|
|
|
test('wrapStreamWithConnectionCleanup does not abort a slow consumer (wire-only)', async () => {
|
|
// Source has both chunks ready immediately; the consumer pauses far longer
|
|
// than the idle timeout between reads. Because the timer is armed only around
|
|
// the network read and cleared as soon as a chunk arrives, the consumer's
|
|
// pace must not trip it.
|
|
const controller = new AbortController()
|
|
const { body } = trackedSource()
|
|
const stream = wrapStreamWithConnectionCleanup(body, {
|
|
clearStartTimeout: () => {},
|
|
cleanup: () => {},
|
|
controller,
|
|
idleTimeoutMs: 20,
|
|
})
|
|
const reader = stream.getReader()
|
|
const decoder = new TextDecoder()
|
|
|
|
const first = await reader.read()
|
|
assert.equal(decoder.decode(first.value), 'a')
|
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
const second = await reader.read()
|
|
assert.equal(decoder.decode(second.value), 'b')
|
|
assert.equal(controller.signal.aborted, false)
|
|
})
|