Skip to content

Commit ebf0aee

Browse files
committed
Improve smoke test error reporting and stability
1 parent 40ddae5 commit ebf0aee

4 files changed

Lines changed: 175 additions & 64 deletions

File tree

apps/admin/src/app/api/smoke/route.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,7 @@ function getSmokeConfig() {
2626

2727
return {
2828
apiKey,
29-
baseUrl:
30-
process.env['SANDCHEST_SMOKE_BASE_URL']?.trim() ||
31-
process.env['SANDCHEST_BASE_URL']?.trim() ||
32-
DEFAULT_BASE_URL,
29+
baseUrl: DEFAULT_BASE_URL,
3330
image: process.env['SANDCHEST_SMOKE_IMAGE']?.trim() || undefined,
3431
profile: (process.env['SANDCHEST_SMOKE_PROFILE']?.trim() || undefined) as
3532
| SmokeProfile
@@ -38,12 +35,33 @@ function getSmokeConfig() {
3835
}
3936
}
4037

38+
function errorMessages(error: unknown): string[] {
39+
if (error instanceof AggregateError) {
40+
return error.errors.flatMap((entry) => errorMessages(entry))
41+
}
42+
if (error instanceof Error) {
43+
const messages = [error.message]
44+
if (error.cause instanceof Error && error.cause.message !== error.message) {
45+
messages.push(error.cause.message)
46+
}
47+
return messages
48+
}
49+
return [String(error)]
50+
}
51+
4152
export async function POST() {
4253
try {
4354
const result = await runSandboxSmokeTest(getSmokeConfig())
4455
return NextResponse.json(result)
4556
} catch (error) {
46-
const message = error instanceof Error ? error.message : 'Unknown error'
47-
return NextResponse.json({ error: message }, { status: 500 })
57+
console.error('Admin smoke failed', error)
58+
const details = errorMessages(error)
59+
return NextResponse.json(
60+
{
61+
error: details[0] ?? 'Unknown error',
62+
details: details.length > 1 ? details.slice(1) : [],
63+
},
64+
{ status: 500 },
65+
)
4866
}
4967
}

apps/admin/src/app/smoke/page.tsx

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,35 @@ interface SmokeResult {
1515
checks: SmokeCheck[]
1616
}
1717

18+
interface SmokeErrorPayload {
19+
error: string
20+
details?: string[] | undefined
21+
}
22+
1823
export default function SmokePage() {
1924
const [isRunning, setIsRunning] = useState(false)
2025
const [error, setError] = useState<string | null>(null)
26+
const [errorDetails, setErrorDetails] = useState<string[]>([])
2127
const [result, setResult] = useState<SmokeResult | null>(null)
2228

2329
async function handleRun() {
2430
setIsRunning(true)
2531
setError(null)
32+
setErrorDetails([])
2633
setResult(null)
2734

2835
try {
2936
const response = await fetch('/api/smoke', { method: 'POST' })
3037
const body = (await response.json().catch(() => ({ error: 'Request failed' }))) as
3138
| SmokeResult
32-
| { error: string }
39+
| SmokeErrorPayload
3340

3441
if (!response.ok || 'error' in body) {
35-
throw new Error('error' in body ? body.error : `Request failed (${response.status})`)
42+
if ('error' in body) {
43+
setErrorDetails(body.details ?? [])
44+
throw new Error(body.error)
45+
}
46+
throw new Error(`Request failed (${response.status})`)
3647
}
3748

3849
setResult(body)
@@ -53,9 +64,9 @@ export default function SmokePage() {
5364
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
5465
<div className="card-title">Production Lifecycle Test</div>
5566
<p className="text-weak" style={{ fontSize: '0.8125rem', lineHeight: 1.6 }}>
56-
Runs the full sandbox lifecycle from the admin server using configured environment
57-
credentials. The flow creates live sandboxes, exercises exec/session/file/fork paths,
58-
and attempts cleanup in all cases.
67+
Runs the full sandbox lifecycle against <code>https://api.sandchest.com</code> from
68+
the admin server using configured credentials. The flow creates live sandboxes,
69+
exercises exec/session/file/fork paths, and attempts cleanup in all cases.
5970
</p>
6071
</div>
6172

@@ -72,6 +83,15 @@ export default function SmokePage() {
7283
{error ? (
7384
<div className="card feedback-card feedback-danger">
7485
Smoke test failed: {error}
86+
{errorDetails.length > 0 ? (
87+
<div style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
88+
{errorDetails.map((detail) => (
89+
<div key={detail} style={{ fontSize: '0.75rem', lineHeight: 1.5 }}>
90+
{detail}
91+
</div>
92+
))}
93+
</div>
94+
) : null}
7595
</div>
7696
) : null}
7797

packages/admin-cli/src/sandbox-smoke.live.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ describe.skipIf(!RUN_ADMIN_SANDBOX_SMOKE_TESTS)('sandbox smoke (live)', () => {
1313
baseUrl: process.env['SANDCHEST_BASE_URL'] ?? config.api?.baseUrl,
1414
})
1515

16-
expect(result.checks.length).toBeGreaterThanOrEqual(8)
16+
expect(result.checks.length).toBeGreaterThanOrEqual(9)
1717
expect(result.rootSandboxId.startsWith('sb_')).toBe(true)
1818
expect(result.forkSandboxId.startsWith('sb_')).toBe(true)
1919
})

packages/admin-cli/src/sandbox-smoke.ts

Lines changed: 125 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ type CleanupTask = {
6161
destroy: () => Promise<void>
6262
}
6363

64+
type SandboxStateSnapshot = {
65+
status: string
66+
failureReason: string | null
67+
}
68+
6469
function toError(error: unknown): Error {
6570
if (error instanceof Error) {
6671
return error
@@ -93,14 +98,65 @@ function makeRunId(): string {
9398
return `admin-smoke-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`
9499
}
95100

101+
async function fetchSandboxState(
102+
baseUrl: string,
103+
apiKey: string,
104+
sandboxId: string,
105+
): Promise<SandboxStateSnapshot | null> {
106+
try {
107+
const response = await fetch(`${baseUrl}/v1/sandboxes/${sandboxId}`, {
108+
headers: {
109+
Authorization: `Bearer ${apiKey}`,
110+
},
111+
})
112+
113+
if (!response.ok) {
114+
return null
115+
}
116+
117+
const body = await response.json() as {
118+
status?: unknown
119+
failure_reason?: unknown
120+
}
121+
122+
if (typeof body.status !== 'string') {
123+
return null
124+
}
125+
126+
return {
127+
status: body.status,
128+
failureReason: typeof body.failure_reason === 'string' ? body.failure_reason : null,
129+
}
130+
} catch {
131+
return null
132+
}
133+
}
134+
135+
function formatSandboxState(snapshot: SandboxStateSnapshot | null): string {
136+
if (!snapshot) {
137+
return 'sandbox state unavailable'
138+
}
139+
if (snapshot.failureReason) {
140+
return `sandbox state ${snapshot.status} (${snapshot.failureReason})`
141+
}
142+
return `sandbox state ${snapshot.status}`
143+
}
144+
96145
async function measureCheck(
97146
name: string,
98147
logger: SandboxSmokeLogger | undefined,
99148
fn: () => Promise<void>,
100149
): Promise<SandboxSmokeCheckResult> {
101150
logger?.step?.('check', name)
102151
const startedAt = Date.now()
103-
await fn()
152+
try {
153+
await fn()
154+
} catch (error) {
155+
const normalized = toError(error)
156+
throw new Error(`Smoke step "${name}" failed: ${normalized.message}`, {
157+
cause: normalized,
158+
})
159+
}
104160
const durationMs = Date.now() - startedAt
105161
logger?.info?.(`${name} (${durationMs}ms)`)
106162
return { name, durationMs }
@@ -216,8 +272,8 @@ export async function runSandboxSmokeTest(
216272
const tracker = new SandboxSmokeTracker()
217273
const checks: SandboxSmokeCheckResult[] = []
218274
const runId = makeRunId()
219-
const sharedPath = `/work/${runId}.txt`
220-
const sessionPath = `/work/${runId}.session.txt`
275+
const sharedPath = `/tmp/${runId}.txt`
276+
const sessionPath = `/tmp/${runId}.session.txt`
221277
const fileContents = `smoke:${runId}`
222278

223279
let rootSandboxId = ''
@@ -232,9 +288,6 @@ export async function runSandboxSmokeTest(
232288
image: resolved.image,
233289
profile: resolved.profile,
234290
ttlSeconds: resolved.ttlSeconds,
235-
env: {
236-
SANDCHEST_SMOKE_RUN: runId,
237-
},
238291
})
239292
tracker.trackSandbox('root', rootSandbox)
240293
rootSandboxId = rootSandbox.id
@@ -245,37 +298,33 @@ export async function runSandboxSmokeTest(
245298
assert(rootSandbox, 'Sandbox was not created')
246299
const sandbox = rootSandbox
247300

248-
checks.push(
249-
await measureCheck('lookup sandbox', logger, async () => {
250-
const fetched = await client.get(sandbox.id)
251-
assert(fetched.id === sandbox.id, 'client.get returned the wrong sandbox id')
252-
253-
const runningSandboxes = await client.list({ status: 'running' })
254-
assert(
255-
runningSandboxes.some((listedSandbox: { id: string }) => listedSandbox.id === sandbox.id),
256-
`client.list did not include sandbox ${sandbox.id}`,
257-
)
258-
}),
259-
)
260-
261301
checks.push(
262302
await measureCheck('exec command', logger, async () => {
263303
const execResult = await sandbox.exec(
264-
['sh', '-lc', 'test "$SANDCHEST_SMOKE_RUN" = "$EXPECTED" && printf smoke-ok'],
265-
{ env: { EXPECTED: runId }, timeout: 60 },
304+
['sh', '-lc', 'test "$SMOKE_EXEC" = "$EXPECTED" && printf smoke-ok'],
305+
{ env: { SMOKE_EXEC: runId, EXPECTED: runId }, timeout: 60 },
266306
)
267307
assertExecSuccess(execResult, 'sandbox exec')
268308
assert(execResult.stdout === 'smoke-ok', `Unexpected exec stdout: ${JSON.stringify(execResult.stdout)}`)
269309
}),
270310
)
271311

272312
checks.push(
273-
await measureCheck('file operations', logger, async () => {
313+
await measureCheck('upload file', logger, async () => {
274314
await sandbox.fs.write(sharedPath, fileContents)
315+
}),
316+
)
317+
318+
checks.push(
319+
await measureCheck('read file', logger, async () => {
275320
const fileValue = await sandbox.fs.read(sharedPath)
276321
assert(fileValue === fileContents, 'sandbox.fs.read did not match written content')
322+
}),
323+
)
277324

278-
const files = await sandbox.fs.ls('/work')
325+
checks.push(
326+
await measureCheck('list files', logger, async () => {
327+
const files = await sandbox.fs.ls('/tmp')
279328
assert(
280329
files.some((entry: { path: string }) => entry.path === sharedPath),
281330
`sandbox.fs.ls did not include ${sharedPath}`,
@@ -287,45 +336,47 @@ export async function runSandboxSmokeTest(
287336
await measureCheck('artifact registration', logger, async () => {
288337
const registered = await sandbox.artifacts.register([sharedPath])
289338
assert(registered.registered >= 1, 'artifact registration returned zero registered artifacts')
290-
291-
const artifacts = await sandbox.artifacts.list()
292-
assert(
293-
artifacts.some((artifact: { name: string }) => artifact.name === sharedPath),
294-
`artifact list did not include ${sharedPath}`,
295-
)
339+
assert(registered.total >= 1, 'artifact registration did not increase total registered paths')
296340
}),
297341
)
298342

299343
checks.push(
300344
await measureCheck('session lifecycle', logger, async () => {
301-
const session = await sandbox.session.create({ shell: '/bin/bash' })
302-
tracker.trackSession('root-shell', session)
303-
304-
const primeResult = await session.exec(
305-
`cd /work && printf '%s' "session:${runId}" > "${sessionPath}"`,
306-
{ timeout: 60 },
307-
)
308-
assertExecSuccess(primeResult, 'session prime exec')
309-
310-
const persistedResult = await session.exec(`pwd && cat "${sessionPath}"`, { timeout: 60 })
311-
assertExecSuccess(persistedResult, 'session persisted exec')
312-
313-
const output = persistedResult.stdout.trimEnd()
314-
assert(
315-
output === `/work\nsession:${runId}`,
316-
`Session state did not persist as expected: ${JSON.stringify(output)}`,
317-
)
318-
319-
await session.destroy()
320-
tracker.releaseSession(session.id)
345+
try {
346+
const session = await sandbox.session.create({ shell: '/bin/sh' })
347+
tracker.trackSession('root-shell', session)
348+
349+
const primeResult = await session.exec(
350+
`cd /tmp && printf '%s' "session:${runId}" > "${sessionPath}"`,
351+
{ timeout: 60 },
352+
)
353+
assertExecSuccess(primeResult, 'session prime exec')
354+
355+
const persistedResult = await session.exec(`pwd && cat "${sessionPath}"`, { timeout: 60 })
356+
assertExecSuccess(persistedResult, 'session persisted exec')
357+
358+
const output = persistedResult.stdout.trimEnd()
359+
assert(
360+
output === `/tmp\nsession:${runId}`,
361+
`Session state did not persist as expected: ${JSON.stringify(output)}`,
362+
)
363+
364+
await session.destroy()
365+
tracker.releaseSession(session.id)
366+
} catch (error) {
367+
const snapshot = await fetchSandboxState(resolved.baseUrl, resolved.apiKey, sandbox.id)
368+
const normalized = toError(error)
369+
throw new Error(`${normalized.message}; ${formatSandboxState(snapshot)}`, {
370+
cause: normalized,
371+
})
372+
}
321373
}),
322374
)
323375

324376
let forkSandbox: Sandbox | undefined
325377
checks.push(
326378
await measureCheck('fork sandbox', logger, async () => {
327379
forkSandbox = await sandbox.fork({
328-
env: { SANDCHEST_SMOKE_FORK: runId },
329380
ttlSeconds: resolved.ttlSeconds,
330381
})
331382
tracker.trackSandbox('fork', forkSandbox)
@@ -335,8 +386,8 @@ export async function runSandboxSmokeTest(
335386
assert(forkReadback === fileContents, 'Fork did not inherit parent filesystem state')
336387

337388
const forkExec = await forkSandbox.exec(
338-
['sh', '-lc', 'test "$SANDCHEST_SMOKE_FORK" = "$EXPECTED" && printf fork-ok'],
339-
{ env: { EXPECTED: runId }, timeout: 60 },
389+
['sh', '-lc', 'test "$SMOKE_FORK" = "$EXPECTED" && printf fork-ok'],
390+
{ env: { SMOKE_FORK: runId, EXPECTED: runId }, timeout: 60 },
340391
)
341392
assertExecSuccess(forkExec, 'fork exec')
342393
assert(forkExec.stdout === 'fork-ok', `Unexpected fork stdout: ${JSON.stringify(forkExec.stdout)}`)
@@ -369,6 +420,28 @@ export async function runSandboxSmokeTest(
369420
)
370421
}),
371422
)
423+
424+
checks.push(
425+
await measureCheck('collect artifacts on stop', logger, async () => {
426+
await sandbox.stop()
427+
assert(
428+
sandbox.status === 'stopping' || sandbox.status === 'stopped',
429+
`Expected root sandbox to be stopping or stopped, got ${sandbox.status}`,
430+
)
431+
432+
const fetched = await client.get(sandbox.id)
433+
assert(
434+
fetched.status === 'stopping' || fetched.status === 'stopped',
435+
`Expected fetched root sandbox to be stopping or stopped, got ${fetched.status}`,
436+
)
437+
438+
const artifacts = await sandbox.artifacts.list()
439+
assert(
440+
artifacts.some((artifact: { name: string }) => artifact.name === sharedPath),
441+
`artifact list did not include ${sharedPath}`,
442+
)
443+
}),
444+
)
372445
} catch (error) {
373446
primaryError = toError(error)
374447
}

0 commit comments

Comments
 (0)