ReviewOS

also looking at this

stacks/drone

test: retain bounded browser launch evidence on failure

#70
Merged chris wants to merge codex/browser-startup-evidence into main
18 files +620 -23
.github/workflows/ci.ymlmodified+32-0
Changes to .github/workflows/ci.yml
@@ -30,6 +30,34 @@ jobs:
3030 - run: bun run parity:check
3131 - run: python3 -m py_compile resources/functions/drone/camera/imx500_worker.py
3232 - run: python3 -m unittest discover -s tests/hardware
33 - name: Qualify cold build setup across the former hook deadline
34 run: bun scripts/qualify-runtime-cold-build.ts "$RUNNER_TEMP/runtime-cold-build"
35 - uses: actions/upload-artifact@v4
36 if: always()
37 with:
38 name: runtime-cold-build-evidence
39 if-no-files-found: ignore
40 retention-days: 7
41 path: |
42 ${{ runner.temp }}/runtime-cold-build/receipt.json
43 ${{ runner.temp }}/runtime-cold-build/capture-result.json
44 ${{ runner.temp }}/runtime-cold-build/tool-events.ndjson
45 ${{ runner.temp }}/runtime-cold-build/stdout.log
46 ${{ runner.temp }}/runtime-cold-build/stderr.log
47 ${{ runner.temp }}/runtime-cold-build/project/firmware/runtime/CMakeLists.txt
48 - name: Verify cold-build evidence failure cannot bypass detached tool cleanup
49 run: bun scripts/qualify-runtime-cold-build.ts "$RUNNER_TEMP/runtime-cold-report-failure" --interrupt-before-capture-report
50 - uses: actions/upload-artifact@v4
51 if: always()
52 with:
53 name: runtime-cold-report-failure-evidence
54 if-no-files-found: ignore
55 retention-days: 7
56 path: |
57 ${{ runner.temp }}/runtime-cold-report-failure/receipt.json
58 ${{ runner.temp }}/runtime-cold-report-failure/tool-events.ndjson
59 ${{ runner.temp }}/runtime-cold-report-failure/stdout.log
60 ${{ runner.temp }}/runtime-cold-report-failure/stderr.log
3361 - name: Verify a kernel-confirmed full input pipe on Linux
3462 run: bun test tests/unit/runtime.test.ts --test-name-pattern 'kernel-confirmed full input FIFO'
3563 - name: Verify an actual partial host image write and fresh-process recovery
@@ -423,6 +451,8 @@ jobs:
423451 path: |
424452 ${{ runner.temp }}/workspace-browser-evidence/*.png
425453 ${{ runner.temp }}/workspace-browser-evidence/compiled.html
454 ${{ runner.temp }}/workspace-browser-evidence/browser-launch.json
455 ${{ runner.temp }}/workspace-browser-evidence/chrome-startup-stderr.log
426456 execution-browser:
427457 runs-on: ubuntu-latest
428458 timeout-minutes: 10
@@ -452,6 +482,8 @@ jobs:
452482 path: |
453483 ${{ runner.temp }}/execution-browser-evidence/*.png
454484 ${{ runner.temp }}/execution-browser-evidence/compiled.html
485 ${{ runner.temp }}/execution-browser-evidence/browser-launch.json
486 ${{ runner.temp }}/execution-browser-evidence/chrome-startup-stderr.log
455487 purge-namespace:
456488 runs-on: ubuntu-latest
457489 timeout-minutes: 30
docs/runtime.mdmodified+29-0
Changes to docs/runtime.md
@@ -28,6 +28,35 @@ stream. Native Stacks process capture drains stdout and stderr concurrently and
2828cleans up the owned process group on POSIX before reporting failure. This also
2929applies to SDK inspection and Pico 2 builds; it does not install firmware.
3030
31Native runtime and sensor tests await build initialization before their test timers
32start. An unrelated setup hook must not interrupt the build API's owned command
33cleanup. Runtime response and test deadlines are unchanged. Build failures identify
34the configure, compile, contracts or SDK inspection stage.
35
36CI also compiles a fresh disposable source copy whose real CMake configure takes
37at least 21 seconds, crossing the former 20-second setup hook. It then runs the
38original Linux full-input FIFO case, retains real tool exit observations and checks
39that no owned tool groups remain. Run the same qualification locally with a new
40evidence directory:
41
42```sh
43bun scripts/qualify-runtime-cold-build.ts /absolute/path/to/new-cold-build-evidence
44```
45
46On macOS the qualification uses the native boot/clean-exit case; the kernel FIFO
47case is Linux-only. The qualification retains its owned source and logs, including
48failures. It does not access a board or measure flight timing.
49
50The qualification supervisor derives its aggregate budget from the three separate
51120-second build commands, their settlement allowance, the selected test's existing
52deadline and startup allowance. It captures at most 2 MB per stream concurrently,
53retains failed output and checks both parent and recorded tool process groups.
54This test-only aggregate budget does not extend the build API or runtime deadlines.
55CI separately interrupts the actual test parent while CMake is active and makes
56the early capture receipt path a directory. That expected reporting refusal must
57still retain its error and settle the recorded detached tool group. Run it with
58`--interrupt-before-capture-report` after a new evidence directory argument.
59
3160```sh
3261./buddy runtime:build
3362./buddy runtime:bench --ticks 1000
docs/validation.mdmodified+20-0
Changes to docs/validation.md
@@ -1,5 +1,25 @@
11# Validation evidence
22
3## Browser qualification diagnostics
4
5Browser fixtures keep their failed private directory after owned process-group
6cleanup. It contains native fixture credentials, database state and the browser
7profile, so CI does not upload that directory. Local retained paths are reported;
8CI runner disposal still ends access to private files that were not exported.
9
10CI exports a closed launch-child observation and at most 64 KiB of startup stderr.
11Public stderr capture stops before any page visit can receive credentials. Later
12browser stderr remains in the private fixture, while exported metadata reports
13that capture was withheld. Refused evidence destinations report unavailable and
14preserve the original browser failure. Launch exit observation, signal and cleanup
15intent are separate fields; they do not prove browser/page/thread liveness or the
16kernel's exact exit time. Post-renewal CDP liveness has its own operation label.
17
18Discovery, transport, stream and outer-test limits remain unchanged. These
19observations support the open investigations in [#44](https://github.com/stacksjs/drone/issues/44)
20and [#64](https://github.com/stacksjs/drone/issues/64); retaining a new attempt does
21not explain an earlier failure whose Chrome evidence was removed.
22
323## Owned mission simulation slice
424
525- Full release suite: 211 Bun tests, 17,424 assertions, zero failures. Buddy lint, application/runtime/view
resources/functions/drone/runtime/build.tsmodified+8-8
Changes to resources/functions/drone/runtime/build.ts
@@ -3,9 +3,9 @@ import { runBoundedHostProcess } from '../../host/process'
33
44export const picoSdkRevision = '079c6f39023649b154152db30f1d781e884879bc'
55export interface RuntimeBuildOptions { project?: string, target?: 'host' | 'pico2', sdk?: string, toolchain?: string, watchdogTest?: boolean, sensors?: boolean }
6async function run(args: string[], cwd: string, env = process.env) {
7 const result = await runBoundedHostProcess(args, { cwd, env, timeoutMs: 120000, stdoutBytes: 2_000_000, stderrBytes: 2_000_000, label: `Runtime build ${args[0]}` })
8 if (result.exitCode !== 0) throw new Error(`${args[0]} exited ${result.exitCode}:\n${(result.stdout + result.stderr).slice(-8000)}`, { cause: result })
6async function run(stage: string, args: string[], cwd: string, env = process.env) {
7 const result = await runBoundedHostProcess(args, { cwd, env, timeoutMs: 120000, stdoutBytes: 2_000_000, stderrBytes: 2_000_000, label: `Runtime build ${stage}` })
8 if (result.exitCode !== 0) throw new Error(`Runtime build ${stage}: ${args[0]} exited ${result.exitCode}:\n${(result.stdout + result.stderr).slice(-8000)}`, { cause: result })
99 return result.stdout.trim()
1010}
1111/** Local tools only; no shell interpolation, SDK download, firmware flashing or device access. */
@@ -20,14 +20,14 @@ export async function buildRuntime(options: RuntimeBuildOptions = {}) {
2020 const sdk = options.sdk ?? env.PICO_SDK_PATH
2121 if (!sdk) throw new Error('Provide --sdk or PICO_SDK_PATH with the pinned Pico SDK 2.3.1 checkout')
2222 env.PICO_SDK_PATH = resolve(sdk)
23 if (await run(['git', 'rev-parse', 'HEAD'], env.PICO_SDK_PATH) !== picoSdkRevision) throw new Error('Pico SDK revision does not match the retained build pin')
23 if (await run('SDK revision', ['git', 'rev-parse', 'HEAD'], env.PICO_SDK_PATH) !== picoSdkRevision) throw new Error('Pico SDK revision does not match the retained build pin')
2424 // A dirty SDK is not a reproducible input.
25 if (await run(['git', 'status', '--porcelain', '--untracked-files=no'], env.PICO_SDK_PATH)) throw new Error('Pico SDK tracked files must be clean')
25 if (await run('SDK worktree', ['git', 'status', '--porcelain', '--untracked-files=no'], env.PICO_SDK_PATH)) throw new Error('Pico SDK tracked files must be clean')
2626 if (options.toolchain) env.PICO_TOOLCHAIN_PATH = resolve(options.toolchain)
2727 }
2828 else if (options.watchdogTest || options.sensors) throw new Error('Sensor and watchdog firmware profiles are only available for pico2')
29 await run(['cmake', '-S', join(project, 'firmware/runtime'), '-B', directory, '-DCMAKE_BUILD_TYPE=Release', `-DDRONE_PICO2=${target === 'pico2' ? 'ON' : 'OFF'}`, `-DDRONE_WATCHDOG_TEST=${options.watchdogTest ? 'ON' : 'OFF'}`, `-DDRONE_SENSOR_BENCH=${options.sensors ? 'ON' : 'OFF'}`], project, env)
30 await run(['cmake', '--build', directory, '--parallel', '2'], project, env)
31 if (target === 'host') await run(['ctest', '--test-dir', directory, '--output-on-failure'], project, env)
29 await run(`${target} configure`, ['cmake', '-S', join(project, 'firmware/runtime'), '-B', directory, '-DCMAKE_BUILD_TYPE=Release', `-DDRONE_PICO2=${target === 'pico2' ? 'ON' : 'OFF'}`, `-DDRONE_WATCHDOG_TEST=${options.watchdogTest ? 'ON' : 'OFF'}`, `-DDRONE_SENSOR_BENCH=${options.sensors ? 'ON' : 'OFF'}`], project, env)
30 await run(`${target} compile`, ['cmake', '--build', directory, '--parallel', '2'], project, env)
31 if (target === 'host') await run('host contracts', ['ctest', '--test-dir', directory, '--output-on-failure'], project, env)
3232 return { target, directory, artifact: join(directory, target === 'host' ? `drone_runtime_host${process.platform === 'win32' ? '.exe' : ''}` : 'drone_runtime.uf2'), physicalFlightEnabled: false as const }
3333}
scripts/qualify-runtime-cold-build.tsadded+134-0
Changes to scripts/qualify-runtime-cold-build.ts
@@ -0,0 +1,134 @@
1import assert from 'node:assert/strict'
2import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'
3import { join, resolve } from 'node:path'
4import process from 'node:process'
5import { captureColdBuild, coldBuildQualificationTimeout } from '../tests/helpers/runtime-cold-build-supervisor'
6
7// Exercise actual build/test initialization across the former 20-second hook.
8// Tool witnesses forward to real CMake/CTest and retain actual child settlement.
9interface ToolEvent { event: 'start' | 'spawn' | 'exit', stage: string, groupId: number, pid: number, exitCode?: number, elapsedMs?: number }
10assert(process.platform !== 'win32', 'Cold build qualification requires POSIX process groups')
11assert(process.argv[2], 'Provide a new owned evidence directory')
12const reportingFault = process.argv[3] === '--interrupt-before-capture-report'
13assert(process.argv.length <= 4 && (!process.argv[3] || reportingFault), 'Unknown qualification mode')
14const repository = resolve(import.meta.dir, '..'), evidence = resolve(process.argv[2]!)
15await mkdir(evidence)
16if (reportingFault) await mkdir(join(evidence, 'capture-result.json'))
17const project = join(evidence, 'project'), tools = join(evidence, 'tools'), eventsPath = join(evidence, 'tool-events.ndjson')
18await mkdir(join(project, 'firmware'), { recursive: true })
19await mkdir(tools)
20await cp(join(repository, 'firmware/runtime'), join(project, 'firmware/runtime'), { recursive: true })
21await writeFile(join(project, 'bunfig.toml'), '[test]\npreload = []\n')
22const cmakePath = join(project, 'firmware/runtime/CMakeLists.txt'), cmake = await readFile(cmakePath, 'utf8')
23assert(cmake.startsWith('cmake_minimum_required(VERSION 3.20)'), 'Expected recorded runtime CMake entrypoint')
24await writeFile(cmakePath, cmake.replace('cmake_minimum_required(VERSION 3.20)', 'cmake_minimum_required(VERSION 3.20)\nexecute_process(COMMAND "${CMAKE_COMMAND}" -E sleep 21 COMMAND_ERROR_IS_FATAL ANY)'))
25assert(!await Bun.file(join(project, 'storage/runtime-build/host/CMakeCache.txt')).exists(), 'Qualification must start cold')
26assert(!/\s/.test(process.execPath), 'Tool witness needs a single executable shebang path')
27for (const tool of ['cmake', 'ctest']) {
28 const executable = Bun.which(tool)
29 assert(executable, `Missing native tool: ${tool}`)
30 const source = String.raw`#!${process.execPath}
31import { appendFileSync } from 'node:fs'
32const stage = ${JSON.stringify(tool)} === 'ctest' ? 'contracts' : process.argv[2] === '-S' ? 'configure' : 'compile'
33const began = performance.now()
34const owner = { stage, groupId: process.pid, pid: process.pid }
35appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify({ ...owner, event: 'start' }) + '\n')
36const child = Bun.spawn([${JSON.stringify(executable)}, ...process.argv.slice(2)], { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' })
37const event = { stage, groupId: process.pid, pid: child.pid }
38appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify({ ...event, event: 'spawn' }) + '\n')
39if (${reportingFault} && stage === 'configure') {
40 process.kill(child.pid, 0)
41 process.kill(process.ppid, 'SIGKILL')
42}
43const exitCode = await child.exited
44appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify({ ...event, event: 'exit', exitCode, elapsedMs: performance.now() - began }) + '\n')
45process.exit(exitCode)
46`
47 await writeFile(join(tools, tool), source, { mode: 0o700 })
48}
49const pattern = process.platform === 'linux' ? 'kernel-confirmed full input FIFO' : 'separate supervised boots'
50const buildCommandTimeoutMs = 120000, selectedTestTimeoutMs = process.platform === 'linux' ? 20000 : 5000
51const qualificationTimeoutMs = coldBuildQualificationTimeout(buildCommandTimeoutMs, selectedTestTimeoutMs)
52let result: Awaited<ReturnType<typeof captureColdBuild>> | undefined, failure: unknown
53const began = performance.now()
54try {
55 result = await captureColdBuild([process.execPath, 'test', join(repository, 'tests/unit/runtime.test.ts'), '--test-name-pattern', pattern], { cwd: project, env: { ...process.env, PATH: `${tools}:${process.env.PATH ?? ''}` }, timeoutMs: qualificationTimeoutMs })
56 failure = result.failure
57}
58catch (error) { failure = error }
59// Retain the capture outcome even if a later cleanup observation itself fails.
60const reportingErrors: { stage: string, code: string | null }[] = []
61try {
62 await writeFile(join(evidence, 'capture-result.json'), `${JSON.stringify({ exitCode: result?.exitCode ?? null, elapsedMs: performance.now() - began, failure: failure instanceof Error ? failure.message : failure ? String(failure) : null }, null, 2)}\n`)
63}
64catch (error) {
65 failure ??= error
66 reportingErrors.push({ stage: 'capture-result', code: (error as NodeJS.ErrnoException).code ?? null })
67}
68let eventText = ''
69try { eventText = await readFile(eventsPath, 'utf8') }
70catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
71const events: ToolEvent[] = []
72for (const line of eventText.split('\n').filter(Boolean)) {
73 try { events.push(JSON.parse(line)) }
74 catch (error) { failure ??= error }
75}
76const groups = [...new Set(events.map(event => event.groupId))]
77assert(groups.every(group => Number.isSafeInteger(group) && group > 1), 'Tool witnesses must identify owned process groups')
78const probeErrors: { group: number, code: string }[] = []
79const cleanupErrors: { group: number, code: string | null }[] = []
80function alive(group: number): boolean {
81 try { process.kill(-group, 0); return true }
82 catch (error) {
83 const code = (error as NodeJS.ErrnoException).code
84 if (code === 'ESRCH') return false
85 if (code === 'EPERM') {
86 if (probeErrors.length < 20) probeErrors.push({ group, code })
87 return true // Unknown membership remains live until an actual absence probe.
88 }
89 failure ??= error
90 if (cleanupErrors.length < 20) cleanupErrors.push({ group, code: code ?? null })
91 return true
92 }
93}
94const beforeCleanup = groups.filter(alive)
95for (const group of beforeCleanup) {
96 try { process.kill(-group, 'SIGKILL') }
97 catch (error) {
98 if ((error as NodeJS.ErrnoException).code !== 'ESRCH') {
99 failure ??= error
100 cleanupErrors.push({ group, code: (error as NodeJS.ErrnoException).code ?? null })
101 }
102 }
103}
104const cleanupDeadline = performance.now() + 5000
105while (groups.some(alive) && performance.now() < cleanupDeadline) await Bun.sleep(25)
106const afterCleanup = groups.filter(alive)
107await writeFile(join(evidence, 'stdout.log'), result?.stdout ?? '')
108await writeFile(join(evidence, 'stderr.log'), result?.stderr ?? '')
109const report = { evidence: reportingFault ? 'controlled-cold-native-report-refusal' : 'controlled-cold-native-build', platform: process.platform, bun: Bun.version, pattern, reportingFault, configureDelaySeconds: 21, buildCommandTimeoutMs, selectedTestTimeoutMs, qualificationTimeoutMs, subjectExitCode: result?.exitCode ?? null, captureAvailable: result !== undefined, capture: result ? { pid: result.pid, deadlineReached: result.deadlineReached, stopRequested: result.stopRequested, rootCleanupRequired: result.rootCleanupRequired, rootGroupRemaining: result.rootGroupRemaining, probeErrors: result.probeErrors } : null, elapsedMs: performance.now() - began, events, beforeCleanup, afterCleanup, probeErrors, cleanupErrors, reportingErrors, failure: failure instanceof Error ? failure.message : failure ? String(failure) : null, physicalFlightEnabled: false }
110await writeFile(join(evidence, 'receipt.json'), `${JSON.stringify(report, null, 2)}\n`)
111assert.equal(afterCleanup.length, 0, 'Owned tool groups must settle before any evidence removal')
112assert.equal(cleanupErrors.length, 0, 'Unexpected tool cleanup errors must remain qualification failures')
113assert(result, 'Actual parent capture must be available')
114assert.equal(result.rootGroupRemaining, false, 'The actual parent process group must settle')
115if (reportingFault) {
116 assert.notEqual(result.exitCode, 0, 'The actual test parent must have been interrupted')
117 assert(failure instanceof Error && (failure as NodeJS.ErrnoException).code === 'EISDIR', 'Retain the actual report refusal')
118 assert(reportingErrors.some(error => error.stage === 'capture-result' && error.code === 'EISDIR'))
119 assert(beforeCleanup.length > 0, 'Reporting refusal must encounter a real live detached tool group')
120}
121else {
122 if (failure) throw failure
123 assert.equal(result.exitCode, 0, 'Original selected native runtime test must pass')
124 assert.equal(result.rootCleanupRequired, false, 'A successful qualification must require no parent group cleanup')
125 assert.match(result.stderr, /1 pass/)
126 assert.match(result.stderr, /0 fail/)
127 assert.equal(beforeCleanup.length, 0, 'A successful build must require no controller cleanup')
128 const exits = events.filter(event => event.event === 'exit')
129 assert.deepEqual(exits.map(event => event.stage), ['configure', 'compile', 'contracts'])
130 assert(exits.every(event => event.exitCode === 0), 'Real CMake and CTest must exit cleanly')
131 assert(exits[0]!.elapsedMs! >= 21000, 'Actual configure must cross the former setup hook')
132 assert(await Bun.file(join(project, 'storage/runtime-build/host/drone_runtime_host')).exists(), 'Real compiler output must exist')
133}
134console.log(JSON.stringify(report))
tests/helpers/browser-launch-evidence.tsadded+59-0
Changes to tests/helpers/browser-launch-evidence.ts
@@ -0,0 +1,59 @@
1import { Buffer } from 'node:buffer'
2import { writeSync } from 'node:fs'
3import { mkdir, open, writeFile } from 'node:fs/promises'
4import { join } from 'node:path'
5
6export interface BrowserLaunchObservation {
7 phase: 'startup-unavailable' | 'discovery-failed' | 'closed'
8 elapsedMs: number
9 launch: { pid: number, exitObserved: boolean, settledExitCode: number | null, signalCode: string | number | null, exitObservedBeforeCleanup: boolean, cleanupRequested: boolean, exitElapsedMs: number | null }
10 portProbe: { attempts: number, successfulReads: number, failedReads: number, lastReadError: string | null, discovered: boolean }
11}
12export function browserFileErrorCode(error: unknown): string {
13 const code = (error as NodeJS.ErrnoException | null)?.code
14 return typeof code === 'string' && /^[A-Z][A-Z0-9_]{0,31}$/.test(code) ? code : 'OTHER'
15}
16function unavailable(target: 'local' | 'export', error: unknown) {
17 const bytes = new TextEncoder().encode(`${JSON.stringify({ oracle: 'browser-launch-evidence-unavailable', target, code: browserFileErrorCode(error) })}\n`)
18 for (const descriptor of [2, 1]) {
19 try { if (writeSync(descriptor, bytes) === bytes.byteLength) return }
20 catch { /* Reporting must preserve the original browser failure. */ }
21 }
22}
23/** Public stderr is captured only before any page can receive native fixture credentials. */
24export async function retainBrowserLaunchEvidence(root: string, observation: BrowserLaunchObservation, options: { startupStderr: boolean, exportDirectory?: string }) {
25 let stderr: { status: 'retained' | 'unavailable' | 'withheld', retainedBytes: number, totalBytes: number | null, truncated: boolean, code: string | null } = { status: 'withheld', retainedBytes: 0, totalBytes: null, truncated: false, code: null }
26 let retained: Buffer | undefined
27 if (options.startupStderr) {
28 try {
29 const handle = await open(join(root, 'chrome.log'), 'r')
30 try {
31 const stat = await handle.stat()
32 if (!stat.isFile()) throw new Error('Startup stderr is not a regular file')
33 const size = Math.min(stat.size, 65536), bytes = Buffer.alloc(size)
34 const { bytesRead } = await handle.read(bytes, 0, size, Math.max(0, stat.size - size))
35 retained = bytes.subarray(0, bytesRead)
36 stderr = { status: 'retained', retainedBytes: bytesRead, totalBytes: stat.size, truncated: stat.size > bytesRead, code: null }
37 }
38 finally { await handle.close() }
39 }
40 catch (error) { stderr = { status: 'unavailable', retainedBytes: 0, totalBytes: null, truncated: false, code: browserFileErrorCode(error) } }
41 }
42 const outcomes = { local: false, export: false }
43 for (const target of ['local', 'export'] as const) {
44 const directory = target === 'local' ? root : options.exportDirectory
45 if (!directory) continue
46 try {
47 if (target === 'export') await mkdir(directory, { recursive: true })
48 let snapshot = stderr
49 if (retained) {
50 try { await writeFile(join(directory, 'chrome-startup-stderr.log'), retained, { mode: 0o600 }) }
51 catch (error) { snapshot = { ...stderr, status: 'unavailable', retainedBytes: 0, code: browserFileErrorCode(error) } }
52 }
53 await writeFile(join(directory, 'browser-launch.json'), `${JSON.stringify({ version: 1, evidence: 'owned-browser-launch-observation', ...observation, stderr: snapshot }, null, 2)}\n`, { mode: 0o600 })
54 outcomes[target] = true
55 }
56 catch (error) { unavailable(target, error) }
57 }
58 return outcomes
59}
tests/helpers/runtime-cold-build-supervisor.tsadded+114-0
Changes to tests/helpers/runtime-cold-build-supervisor.ts
@@ -0,0 +1,114 @@
1import { Buffer } from 'node:buffer'
2import process from 'node:process'
3
4/** Test-only ownership of the aggregate build/test process, beyond a single command's budget. */
5export interface ColdBuildCapture {
6 exitCode: number
7 stdout: string
8 stderr: string
9 pid: number
10 timeoutMs: number
11 deadlineReached: boolean
12 stopRequested: boolean
13 rootCleanupRequired: boolean
14 rootGroupRemaining: boolean
15 probeErrors: string[]
16 failure: unknown
17}
18
19export const coldBuildCaptureBytes = 2_000_000
20export const coldBuildSettlementMs = 5000
21
22/** Three build commands, each with its own settlement allowance, then the unchanged selected test. */
23export function coldBuildQualificationTimeout(commandTimeoutMs: number, selectedTestTimeoutMs: number): number {
24 if (!Number.isSafeInteger(commandTimeoutMs) || commandTimeoutMs < 1 || commandTimeoutMs > 120000)
25 throw new Error('Invalid cold-build command budget')
26 if (![5000, 20000].includes(selectedTestTimeoutMs))
27 throw new Error('Invalid selected runtime test budget')
28 return 3 * (commandTimeoutMs + coldBuildSettlementMs) + selectedTestTimeoutMs + 10000
29}
30
31export async function captureColdBuild(command: string[], options: { cwd: string, env: NodeJS.ProcessEnv, timeoutMs: number }): Promise<ColdBuildCapture> {
32 if (process.platform === 'win32') throw new Error('Cold build capture requires POSIX process groups')
33 if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1 || options.timeoutMs > 405000)
34 throw new Error('Invalid aggregate cold-build budget')
35 const child = Bun.spawn(command, { cwd: options.cwd, env: options.env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', detached: true })
36 const readers = [child.stdout.getReader(), child.stderr.getReader()]
37 const chunks: Uint8Array[][] = [[], []], sizes = [0, 0]
38 const cancellations: Promise<unknown>[] = [], cleanupErrors: unknown[] = [], probeErrors: string[] = []
39 let exited = false, groupTerminated = false
40 let failure: unknown, deadlineReached = false, stopped = false, rootCleanupRequired = false, pipeTimer: ReturnType<typeof setTimeout> | undefined
41 const group = (signal: NodeJS.Signals | 0): boolean => {
42 try { process.kill(-child.pid, signal); return true }
43 catch (error) {
44 const code = (error as NodeJS.ErrnoException).code
45 if (code === 'ESRCH') return false
46 if (signal === 0 && code === 'EPERM') {
47 if (probeErrors.length < 20) probeErrors.push(code)
48 return true // Permission uncertainty never proves that ownership has settled.
49 }
50 throw error
51 }
52 }
53 const groupExists = (): boolean => {
54 try { return group(0) }
55 catch (error) { cleanupErrors.push(error); return true }
56 }
57 const terminate = () => {
58 if (!groupTerminated) {
59 try { group('SIGKILL'); groupTerminated = true }
60 catch (error) { cleanupErrors.push(error) }
61 }
62 if (!exited && !groupTerminated) {
63 try { child.kill('SIGKILL') }
64 catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') cleanupErrors.push(error) }
65 }
66 }
67 const stop = (error: unknown) => {
68 failure ??= error
69 if (stopped) return
70 stopped = true
71 terminate()
72 for (const reader of readers) cancellations.push(reader.cancel().catch(error => { cleanupErrors.push(error) }))
73 }
74 async function consume(index: number): Promise<void> {
75 try {
76 while (true) {
77 const { done, value } = await readers[index]!.read()
78 if (done) return
79 const room = coldBuildCaptureBytes - sizes[index]!
80 const retained = value.subarray(0, room)
81 if (retained.byteLength) { chunks[index]!.push(retained); sizes[index]! += retained.byteLength }
82 if (value.byteLength > room) { stop(new Error(`Cold build ${index === 0 ? 'stdout' : 'stderr'} exceeds byte limit`)); return }
83 }
84 }
85 catch (error) { stop(error) }
86 }
87 const timer = setTimeout(() => { deadlineReached = true; stop(new Error('Aggregate cold build qualification timed out')) }, options.timeoutMs)
88 const outputs = [consume(0), consume(1)]
89 let exitCode: number
90 try {
91 exitCode = await child.exited
92 exited = true
93 clearTimeout(timer)
94 pipeTimer = setTimeout(() => stop(new Error('Cold build inherited output pipes did not settle')), coldBuildSettlementMs)
95 await Promise.all(outputs)
96 }
97 finally {
98 clearTimeout(timer)
99 clearTimeout(pipeTimer)
100 rootCleanupRequired = groupExists()
101 terminate()
102 await Promise.allSettled([child.exited, ...outputs, ...cancellations])
103 for (const reader of readers) {
104 try { reader.releaseLock() }
105 catch (error) { cleanupErrors.push(error) }
106 }
107 }
108 const end = performance.now() + coldBuildSettlementMs
109 while (groupExists() && performance.now() < end) await Bun.sleep(25)
110 const rootGroupRemaining = groupExists()
111 if (rootGroupRemaining) cleanupErrors.push(new Error('Owned cold-build parent group did not settle'))
112 if (cleanupErrors.length) failure = new AggregateError(failure ? [failure, ...cleanupErrors] : cleanupErrors, 'Cold-build capture cleanup failed')
113 return { exitCode, stdout: Buffer.concat(chunks[0]!, sizes[0]).toString('utf8'), stderr: Buffer.concat(chunks[1]!, sizes[1]).toString('utf8'), pid: child.pid, timeoutMs: options.timeoutMs, deadlineReached, stopRequested: stopped, rootCleanupRequired, rootGroupRemaining, probeErrors, failure }
114}
tests/helpers/workspace-browser-driver.tsmodified+1-1
Changes to tests/helpers/workspace-browser-driver.ts
@@ -89,7 +89,7 @@ try {
8989 console.log(`QA driver33s timer drift ms: ${Date.now() - waitStart - 33000}`)
9090 const status = await control('status')
9191 console.log(`QA actual native recipient streams: ${status.recipientStreams}`)
92 assert.equal(await second.evaluate('1'), 1, 'Existing actual browser tab must stay responsive')
92 await second.checkLiveness()
9393 assert(status.recipientStreams > baseline.recipientStreams, 'Actual native stream must reconnect during the measured lifetime window')
9494 const starts = status.recipientStreamStarts as number[]
9595 assert(starts.slice(baseline.recipientStreams).some((start, index) => start >= waitStart && start - starts[baseline.recipientStreams + index - 1]! >= 30000), 'Measured renewal must follow an actual default30s stream lifetime')
tests/helpers/workspace-browser.tsmodified+19-6
Changes to tests/helpers/workspace-browser.ts
@@ -2,6 +2,8 @@ import assert from 'node:assert/strict'
22import { mkdir, readFile } from 'node:fs/promises'
33import { join } from 'node:path'
44import { observeBrowserOperation } from './browser-operation'
5import { browserFileErrorCode, retainBrowserLaunchEvidence } from './browser-launch-evidence'
6import type { BrowserLaunchObservation } from './browser-launch-evidence'
57
68/** Dependency-free CDP, with a fresh profile. Credentials never enter command arguments. */
79async function openSocket(url: string): Promise<WebSocket> {
@@ -19,15 +21,24 @@ export async function startWorkspaceBrowser(root: string) {
1921 await mkdir(profile)
2022 const child = Bun.spawn([process.env.BROWSE_BROWSER ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '--headless=new', '--disable-gpu', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows', '--disable-features=CalculateNativeWinOcclusion', '--disable-dev-shm-usage', '--disable-extensions', '--disable-component-update', '--no-first-run', '--no-default-browser-check', '--disable-background-networking', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdout: 'ignore', stderr: Bun.file(join(root, 'chrome.log')) })
2123 const started = Date.now()
24 const began = performance.now()
2225 let closing = false
23 void child.exited.then(code => { if (!closing) console.log(`QA isolated Chrome exited unexpectedly: ${code}, elapsedMs:${Date.now() - started}`) })
26 const launch: BrowserLaunchObservation['launch'] = { pid: child.pid, exitObserved: false, settledExitCode: null, signalCode: null, exitObservedBeforeCleanup: false, cleanupRequested: false, exitElapsedMs: null }
27 const portProbe: BrowserLaunchObservation['portProbe'] = { attempts: 0, successfulReads: 0, failedReads: 0, lastReadError: null, discovered: false }
28 let pageVisited = false
29 const retain = (phase: BrowserLaunchObservation['phase']) => retainBrowserLaunchEvidence(root, { phase, elapsedMs: performance.now() - began, launch: { ...launch }, portProbe: { ...portProbe } }, { startupStderr: !pageVisited, exportDirectory: process.env.WORKSPACE_BROWSER_EVIDENCE ?? process.env.EXECUTION_BROWSER_EVIDENCE })
30 void child.exited.then(code => {
31 launch.exitObserved = true; launch.settledExitCode = code; launch.signalCode = child.signalCode; launch.exitObservedBeforeCleanup = !closing; launch.exitElapsedMs = performance.now() - began
32 if (!closing) console.log(`QA isolated Chrome exited unexpectedly: ${code}, elapsedMs:${Date.now() - started}`)
33 })
2434 let port = ''
2535 for (let attempt = 0; attempt < 400; attempt++) {
26 port = await readFile(join(profile, 'DevToolsActivePort'), 'utf8').then(value => value.split('\n')[0]!).catch(() => '')
27 if (port) break
36 portProbe.attempts++
37 port = await readFile(join(profile, 'DevToolsActivePort'), 'utf8').then(value => { portProbe.successfulReads++; portProbe.lastReadError = null; return value.split('\n')[0]! }).catch(error => { portProbe.failedReads++; portProbe.lastReadError = browserFileErrorCode(error); return '' })
38 if (port) { portProbe.discovered = true; break }
2839 await Bun.sleep(50)
2940 }
30 if (!port) { closing = true; if (child.exitCode === null) child.kill(); await Promise.race([child.exited, Bun.sleep(2000)]); throw new Error('Fresh browser must start') }
41 if (!port) { closing = true; launch.cleanupRequested = true; if (child.exitCode === null) child.kill(); await Promise.race([child.exited, Bun.sleep(2000)]); await retain('startup-unavailable'); throw new Error('Fresh browser must start') }
3142 // Keep a browser-level session, as well as page sessions, for the owned lifecycle.
3243 let browserSocket: WebSocket
3344 try {
@@ -35,13 +46,14 @@ export async function startWorkspaceBrowser(root: string) {
3546 const version = await observeBrowserOperation('version-json', () => response.json()) as { webSocketDebuggerUrl: string }
3647 browserSocket = await observeBrowserOperation('browser-socket', () => openSocket(version.webSocketDebuggerUrl))
3748 }
38 catch (error) { closing = true; if (child.exitCode === null) child.kill(); await Promise.race([child.exited, Bun.sleep(2000)]); throw error }
49 catch (error) { closing = true; launch.cleanupRequested = true; if (child.exitCode === null) child.kill(); await Promise.race([child.exited, Bun.sleep(2000)]); await retain('discovery-failed'); throw error }
3950 let browserSequence = 0
4051 browserSocket.onmessage = () => {}
4152 const keepAlive = setInterval(() => { if (browserSocket.readyState === WebSocket.OPEN) browserSocket.send(JSON.stringify({ id: ++browserSequence, method: 'Browser.getVersion' })) }, 5000)
4253 const pages: Array<{ close: () => void }> = []
4354 return {
4455 async page(url: string, javascript = true) {
56 pageVisited = true
4557 const response = await observeBrowserOperation('target-fetch', () => fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT', signal: AbortSignal.timeout(5000) }))
4658 const target = await observeBrowserOperation('target-json', () => response.json()) as { webSocketDebuggerUrl: string }
4759 const socket = await observeBrowserOperation('page-socket', () => openSocket(target.webSocketDebuggerUrl))
@@ -73,6 +85,7 @@ export async function startWorkspaceBrowser(root: string) {
7385 while (Date.now() < end) { if (await evaluate(expression)) return; await Bun.sleep(50) }
7486 throw new Error(message)
7587 },
88 async checkLiveness() { phase = 'post-renewal liveness'; assert.equal(await evaluate('1'), 1) },
7689 async fill(selector: string, value: string) { phase = `field ${selector}`; await evaluate(`(() => { const element = document.querySelector(${JSON.stringify(selector)}); if (!element) throw Error('Missing form field'); element.value = ${JSON.stringify(value)}; element.dispatchEvent(new Event('input',{bubbles:true})); element.dispatchEvent(new Event('change',{bubbles:true})); })()`) },
7790 async click(text: string) { phase = `action ${text}`; await evaluate(`(() => { const element = [...document.querySelectorAll('button')].find(value => value.textContent.trim() === ${JSON.stringify(text)} && !value.disabled); if (!element) throw Error('Missing enabled action'); element.click(); })()`) },
7891 async screenshot(path: string, width = 1280, height = 1000) { await send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 500 }); await Bun.sleep(100); const shot = await send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }); await Bun.write(path, Buffer.from(shot.data, 'base64')) },
@@ -83,6 +96,6 @@ export async function startWorkspaceBrowser(root: string) {
8396 await page.wait(javascript ? "document.querySelector('#incident-key') !== null && document.querySelector('[data-workspace-status]')?.textContent.trim() === 'disconnected'" : "document.querySelector('#incident-key') !== null", 'Compiled workspace must initialize')
8497 return page
8598 },
86 async close() { console.log(`QA browser.close requested, elapsedMs:${Date.now() - started}`); closing = true; clearInterval(keepAlive); browserSocket.close(); for (const page of pages) page.close(); if (child.exitCode === null) child.kill(); await Promise.race([child.exited, Bun.sleep(2000)]) },
99 async close() { console.log(`QA browser.close requested, elapsedMs:${Date.now() - started}`); closing = true; launch.cleanupRequested = true; clearInterval(keepAlive); browserSocket.close(); for (const page of pages) page.close(); if (child.exitCode === null) child.kill(); await Promise.race([child.exited, Bun.sleep(2000)]); await retain('closed') },
87100 }
88101}
tests/tsconfig.runtime.jsonmodified+3-0
Changes to tests/tsconfig.runtime.json
@@ -25,6 +25,9 @@
2525 "./unit/estimation.test.ts",
2626 "./fixtures/sensors/generate.ts",
2727 "./unit/runtime.test.ts",
28 "./unit/runtime-build.test.ts",
29 "./unit/runtime-cold-build-supervisor.test.ts",
30 "../scripts/qualify-runtime-cold-build.ts",
2831 "./unit/sensors.test.ts",
2932 "../resources/functions/drone/payloads/**/*.ts",
3033 "./unit/payloads.test.ts",
tests/unit/public-safety-browser-launch-evidence.test.tsadded+59-0
Changes to tests/unit/public-safety-browser-launch-evidence.test.ts
@@ -0,0 +1,59 @@
1import { expect, test } from 'bun:test'
2import { Buffer } from 'node:buffer'
3import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { retainBrowserLaunchEvidence } from '../helpers/browser-launch-evidence'
7import type { BrowserLaunchObservation } from '../helpers/browser-launch-evidence'
8
9const observation: BrowserLaunchObservation = { phase: 'startup-unavailable', elapsedMs: 20000, launch: { pid: 12345, exitObserved: true, settledExitCode: 23, signalCode: null, exitObservedBeforeCleanup: true, cleanupRequested: true, exitElapsedMs: 10 }, portProbe: { attempts: 400, successfulReads: 0, failedReads: 400, lastReadError: 'ENOENT', discovered: false } }
10
11test('startup capture retains only a bounded native stderr tail at both evidence destinations', async () => {
12 const root = await mkdtemp(join(tmpdir(), 'drone-browser-evidence-')), exported = join(root, 'export')
13 try {
14 const source = 'unretained-prefix'.repeat(5000) + 'x'.repeat(65500) + 'controlled-tail'
15 await writeFile(join(root, 'chrome.log'), source)
16 expect(await retainBrowserLaunchEvidence(root, observation, { startupStderr: true, exportDirectory: exported })).toEqual({ local: true, export: true })
17 for (const directory of [root, exported]) {
18 const report = JSON.parse(await readFile(join(directory, 'browser-launch.json'), 'utf8'))
19 expect(report.stderr.totalBytes).toBe(Buffer.byteLength(source))
20 expect(report.stderr.retainedBytes).toBe(65536)
21 expect(report.stderr.truncated).toBe(true)
22 const captured = await readFile(join(directory, 'chrome-startup-stderr.log'))
23 expect(captured.byteLength).toBe(65536)
24 expect(captured.equals(Buffer.from(source).subarray(-65536))).toBe(true)
25 expect((await stat(join(directory, 'browser-launch.json'))).mode & 0o777).toBe(0o600)
26 }
27 expect(await readFile(join(root, 'chrome.log'), 'utf8')).toBe(source)
28 }
29 finally { await rm(root, { recursive: true, force: true }) }
30})
31
32test('post-navigation capture withholds private stderr and exports only lifecycle scalars', async () => {
33 const root = await mkdtemp(join(tmpdir(), 'drone-browser-evidence-')), exported = join(root, 'export')
34 try {
35 await writeFile(join(root, 'chrome.log'), 'PRIVATE_FIXTURE_CREDENTIAL_SENTINEL')
36 const result = await retainBrowserLaunchEvidence(root, { ...observation, phase: 'closed' }, { startupStderr: false, exportDirectory: exported })
37 expect(result).toEqual({ local: true, export: true })
38 expect(await readdir(exported)).toEqual(['browser-launch.json'])
39 const publicText = await readFile(join(exported, 'browser-launch.json'), 'utf8')
40 expect(publicText).not.toContain('PRIVATE_FIXTURE_CREDENTIAL_SENTINEL')
41 expect(JSON.parse(publicText).stderr.status).toBe('withheld')
42 expect(await readFile(join(root, 'chrome.log'), 'utf8')).toBe('PRIVATE_FIXTURE_CREDENTIAL_SENTINEL')
43 }
44 finally { await rm(root, { recursive: true, force: true }) }
45})
46
47test('actual refused metadata and export paths report unavailable without replacing the browser failure', async () => {
48 const root = await mkdtemp(join(tmpdir(), 'drone-browser-evidence-')), exported = join(root, 'not-a-directory')
49 try {
50 await writeFile(join(root, 'chrome.log'), 'controlled startup message')
51 await mkdir(join(root, 'browser-launch.json'))
52 await writeFile(exported, 'owned obstruction')
53 const original = new Error('original browser failure')
54 const run = async () => { await retainBrowserLaunchEvidence(root, observation, { startupStderr: true, exportDirectory: exported }); throw original }
55 await expect(run()).rejects.toBe(original)
56 expect(await readFile(exported, 'utf8')).toBe('owned obstruction')
57 }
58 finally { await rm(root, { recursive: true, force: true }) }
59})
tests/unit/public-safety-browser-launch.test.tsadded+48-0
Changes to tests/unit/public-safety-browser-launch.test.ts
@@ -0,0 +1,48 @@
1import { expect, test } from 'bun:test'
2import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import process from 'node:process'
6import { startWorkspaceBrowser } from '../helpers/workspace-browser'
7
8test.skipIf(process.platform === 'win32')('failed native launch retains startup stderr, actual exit and port observations at the original discovery bound', async () => {
9 const supplied = process.env.BROWSER_LAUNCH_EVIDENCE
10 const root = supplied ? (await mkdir(supplied), supplied) : await mkdtemp(join(tmpdir(), 'drone-browser-launch-'))
11 const executable = join(root, 'launch.ts'), pidPath = join(root, 'launch.pid')
12 let passed = false
13 const original = process.env.BROWSE_BROWSER
14 try {
15 await writeFile(executable, `#!${process.execPath}\nimport { writeFileSync } from 'node:fs'\nwriteFileSync(${JSON.stringify(pidPath)}, String(process.pid))\nconsole.error('controlled native browser startup refusal')\nprocess.exit(23)\n`, { mode: 0o700 })
16 process.env.BROWSE_BROWSER = executable
17 await expect(startWorkspaceBrowser(root)).rejects.toThrow('Fresh browser must start')
18 const actualPid = Number(await readFile(pidPath, 'utf8'))
19 const report = JSON.parse(await readFile(join(root, 'browser-launch.json'), 'utf8'))
20 expect(report.phase).toBe('startup-unavailable')
21 expect(report.launch.pid).toBe(actualPid)
22 expect(report.launch.exitObserved).toBe(true)
23 expect(report.launch.settledExitCode).toBe(23)
24 expect(report.launch.exitObservedBeforeCleanup).toBe(true)
25 expect(report.launch.cleanupRequested).toBe(true)
26 expect(report.portProbe.attempts).toBe(400)
27 expect(report.portProbe.lastReadError).toBe('ENOENT')
28 expect(report.stderr.status).toBe('retained')
29 expect(report.stderr.retainedBytes).toBeLessThanOrEqual(65536)
30 expect(await readFile(join(root, 'chrome-startup-stderr.log'), 'utf8')).toContain('controlled native browser startup refusal')
31 expect(() => process.kill(actualPid, 0)).toThrow()
32 passed = true
33 }
34 finally {
35 if (original === undefined) delete process.env.BROWSE_BROWSER
36 else process.env.BROWSE_BROWSER = original
37 // The owned native program exits23 before discovery completes; never delete a live fixture.
38 const actualPid = Number(await readFile(pidPath, 'utf8').catch(() => '0'))
39 if (actualPid > 1) {
40 let gone = false
41 try { process.kill(actualPid, 0) }
42 catch (error) { gone = (error as NodeJS.ErrnoException).code === 'ESRCH' }
43 expect(gone).toBe(true)
44 }
45 if (passed && !supplied) await rm(root, { recursive: true, force: true })
46 else console.log(JSON.stringify({ evidence: 'controlled-native-launch', retainedRoot: root, passed }))
47 }
48}, 30000)
tests/unit/public-safety-execution-browser.test.tsmodified+7-1
Changes to tests/unit/public-safety-execution-browser.test.ts
@@ -7,6 +7,7 @@ import { spawnWorkspaceFixture } from '../helpers/workspace-process'
77test.skipIf(process.env.EXECUTION_BROWSER_QA !== 'true')('compiled execution panel uses real native routes and a genuine Worker', async () => {
88 const directory = await mkdtemp(join(tmpdir(), 'drone-incident-access-'))
99 let owned: ReturnType<typeof spawnWorkspaceFixture> | undefined
10 let passed = false
1011 try {
1112 const config = join(directory, 'bunfig.toml'); await writeFile(config, 'preload=[]\n')
1213 owned = spawnWorkspaceFixture([process.execPath, `--config=${config}`, '--no-env-file', `${import.meta.dir}/../fixtures/public-safety-access/execution-browser.ts`], directory, { ...process.env, APP_ENV: 'test', APP_DEBUG: 'false', NODE_ENV: 'test', DB_CONNECTION: 'sqlite', DB_DATABASE_PATH: join(directory, 'access.sqlite'), STX_DIR: join(directory, 'stx') })
@@ -16,10 +17,15 @@ test.skipIf(process.env.EXECUTION_BROWSER_QA !== 'true')('compiled execution pan
1617 const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout as ReadableStream).text(), new Response(child.stderr as ReadableStream).text()])
1718 expect(code, `${stdout}\n${stderr}`).toBe(0)
1819 expect(stdout).toContain('PASS actual execution browser: native live-before-return')
20 passed = true
1921 }
2022 finally { clearTimeout(timeout) }
2123 }
22 finally { await owned?.stop(); await rm(directory, { recursive: true, force: true }) }
24 finally {
25 await owned?.stop()
26 if (passed) await rm(directory, { recursive: true, force: true })
27 else console.log(JSON.stringify({ evidence: 'retained-private-browser-fixture', directory }))
28 }
2329}, 110000)
2430
2531test('execution browser fixture refuses an unguarded directory before database or application writes', async () => {
tests/unit/public-safety-workspace-browser.test.tsmodified+4-1
Changes to tests/unit/public-safety-workspace-browser.test.ts
@@ -8,6 +8,7 @@ import { spawnWorkspaceFixture } from '../helpers/workspace-process'
88test.skipIf(process.env.WORKSPACE_BROWSER_QA !== 'true')('actual rendered workspace with native credentials and routes', async () => {
99 const directory = await mkdtemp(join(tmpdir(), 'drone-incident-access-'))
1010 let owned: ReturnType<typeof spawnWorkspaceFixture> | undefined
11 let passed = false
1112 try {
1213 const config = join(directory, 'bunfig.toml'); await writeFile(config, 'preload=[]\n')
1314 owned = spawnWorkspaceFixture([process.execPath, `--config=${config}`, '--no-env-file', `${import.meta.dir}/../fixtures/public-safety-access/workspace-browser.ts`], directory, { ...process.env, APP_ENV: 'test', APP_DEBUG: 'false', NODE_ENV: 'test', DB_CONNECTION: 'sqlite', DB_DATABASE_PATH: join(directory, 'access.sqlite'), STX_DIR: join(directory, 'stx') })
@@ -17,12 +18,14 @@ test.skipIf(process.env.WORKSPACE_BROWSER_QA !== 'true')('actual rendered worksp
1718 const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout as ReadableStream).text(), new Response(child.stderr as ReadableStream).text()])
1819 expect(code, `${stdout}\n${stderr}`).toBe(0)
1920 expect(stdout).toContain('PASS actual compiled workspace browser')
21 passed = true
2022 }
2123 finally { clearTimeout(timeout) }
2224 }
2325 finally {
2426 await owned?.stop()
25 await rm(directory, { recursive: true, force: true })
27 if (passed) await rm(directory, { recursive: true, force: true })
28 else console.log(JSON.stringify({ evidence: 'retained-private-browser-fixture', directory }))
2629 }
2730}, 110000)
2831
tests/unit/runtime-build.test.tsadded+15-0
Changes to tests/unit/runtime-build.test.ts
@@ -0,0 +1,15 @@
1import { expect, test } from 'bun:test'
2import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { buildRuntime } from '../../resources/functions/drone/runtime/build'
6
7test('actual CMake refusal identifies the build stage before any runtime test starts', async () => {
8 const project = await mkdtemp(join(tmpdir(), 'drone-build-refusal-'))
9 try {
10 await mkdir(join(project, 'firmware/runtime'), { recursive: true })
11 await writeFile(join(project, 'firmware/runtime/CMakeLists.txt'), 'cmake_minimum_required(VERSION 3.20)\nproject(controlled_refusal NONE)\nmessage(FATAL_ERROR "Controlled configure refusal")\n')
12 await expect(buildRuntime({ project })).rejects.toThrow('Runtime build host configure: cmake exited 1')
13 }
14 finally { await rm(project, { recursive: true, force: true }) }
15}, 10000)
tests/unit/runtime-cold-build-supervisor.test.tsadded+62-0
Changes to tests/unit/runtime-cold-build-supervisor.test.ts
@@ -0,0 +1,62 @@
1import { expect, test } from 'bun:test'
2import { Buffer } from 'node:buffer'
3import process from 'node:process'
4import { captureColdBuild, coldBuildCaptureBytes, coldBuildQualificationTimeout } from '../helpers/runtime-cold-build-supervisor'
5
6const options = { cwd: process.cwd(), env: process.env, timeoutMs: 15000 }
7const run = (source: string, timeoutMs = options.timeoutMs) => captureColdBuild([process.execPath, '-e', source], { ...options, timeoutMs })
8function groupExists(pid: number): boolean {
9 try { process.kill(-pid, 0); return true }
10 catch (error) { if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false; throw error }
11}
12
13test.skipIf(process.platform === 'win32')('aggregate capture drains both real streams and preserves a native nonzero exit', async () => {
14 const result = await run("await Promise.all([Bun.write(Bun.stdout, 'o'.repeat(262144)), Bun.write(Bun.stderr, 'e'.repeat(262144))]); process.exit(7)")
15 expect(result.exitCode).toBe(7)
16 expect(result.stdout).toBe('o'.repeat(262144))
17 expect(result.stderr).toBe('e'.repeat(262144))
18 expect(result.failure).toBeUndefined()
19 expect(result.stopRequested).toBe(false)
20 expect(result.rootCleanupRequired).toBe(false)
21 expect(result.rootGroupRemaining).toBe(false)
22 expect(groupExists(result.pid)).toBe(false)
23}, 25000)
24
25test.skipIf(process.platform === 'win32')('aggregate deadline retains actual child output and settles its owned group', async () => {
26 const result = await run("await Bun.write(Bun.stdout, 'ready\\n'); setInterval(() => {}, 1000)", 2000)
27 expect(result.stdout).toBe('ready\n')
28 expect(result.exitCode).not.toBe(0)
29 expect(result.deadlineReached).toBe(true)
30 expect(result.stopRequested).toBe(true)
31 expect(String(result.failure)).toContain('timed out')
32 expect(result.rootGroupRemaining).toBe(false)
33 expect(groupExists(result.pid)).toBe(false)
34}, 15000)
35
36test.skipIf(process.platform === 'win32')('actual output overflow retains the bounded prefix and terminates the writer', async () => {
37 const result = await run("await Bun.write(Bun.stdout, 'x'.repeat(2000001)); setInterval(() => {}, 1000)")
38 expect(Buffer.byteLength(result.stdout)).toBe(coldBuildCaptureBytes)
39 expect(result.stdout).toBe('x'.repeat(coldBuildCaptureBytes))
40 expect(result.exitCode).not.toBe(0)
41 expect(String(result.failure)).toContain('stdout exceeds byte limit')
42 expect(result.deadlineReached).toBe(false)
43 expect(result.rootGroupRemaining).toBe(false)
44 expect(groupExists(result.pid)).toBe(false)
45}, 25000)
46
47test.skipIf(process.platform === 'win32')('exited parent with an actual inherited pipe is refused and its descendant group settles', async () => {
48 const result = await run("const child = Bun.spawn([process.execPath, '-e', 'setInterval(() => {}, 1000)'], { stdout: 'inherit', stderr: 'inherit' }); console.log(child.pid); process.exit(0)")
49 expect(result.exitCode).toBe(0)
50 expect(result.stdout).toMatch(/^\d+\n$/)
51 expect(result.deadlineReached).toBe(false)
52 expect(result.stopRequested).toBe(true)
53 expect(String(result.failure)).toContain('inherited output pipes did not settle')
54 expect(result.rootGroupRemaining).toBe(false)
55 expect(groupExists(result.pid)).toBe(false)
56}, 25000)
57
58test('qualification derives its aggregate budget without changing the individual command limit', () => {
59 expect(coldBuildQualificationTimeout(120000, 5000)).toBe(390000)
60 expect(coldBuildQualificationTimeout(120000, 20000)).toBe(405000)
61 expect(() => coldBuildQualificationTimeout(120001, 5000)).toThrow('command budget')
62})
tests/unit/runtime.test.tsmodified+3-3
Changes to tests/unit/runtime.test.ts
@@ -1,4 +1,4 @@
1import { beforeAll, describe, expect, test } from 'bun:test'
1import { describe, expect, test } from 'bun:test'
22import { mkdtemp, rm, writeFile } from 'node:fs/promises'
33import { tmpdir } from 'node:os'
44import { join } from 'node:path'
@@ -82,8 +82,8 @@ describe('Pi bench session authority', () => {
8282 })
8383})
8484
85let artifact: string
86beforeAll(async () => { artifact = (await buildRuntime()).artifact }, 20000)
85// Build setup uses the build API's bounded command lifecycle, before test timers start.
86const { artifact } = await buildRuntime()
8787async function withNative(run: (harness: ReturnType<typeof openRuntimeHarness>) => Promise<void>) {
8888 const harness = openRuntimeHarness(artifact)
8989 try { await run(harness) }
tests/unit/sensors.test.tsmodified+3-3
Changes to tests/unit/sensors.test.ts
@@ -1,4 +1,4 @@
1import { beforeAll, describe, expect, test } from 'bun:test'
1import { describe, expect, test } from 'bun:test'
22import { command } from '@stacksjs/testing'
33import { join } from 'node:path'
44import fixture from '../fixtures/sensors/bench-v1.json'
@@ -199,8 +199,8 @@ describe('sensor clocks and independent health', () => {
199199 })
200200})
201201
202let nativeDirectory: string
203beforeAll(async () => { nativeDirectory = (await buildRuntime()).directory }, 20000)
202// A cold native build must settle before the sensor test's own timing begins.
203const { directory: nativeDirectory } = await buildRuntime()
204204describe('native capture and simulator replay', () => {
205205 test('the native C++ codec preserves independently encoded records byte-for-byte', async () => {
206206 const wire = Buffer.concat(['imu', 'barometer', 'gnss'].map(kind => Buffer.from(bytes(kind as SensorKind))))