also looking at this
test: retain bounded browser launch evidence on failure
#70
18 files
+620
-23
| @@ -30,6 +30,34 @@ jobs: | ||
| 30 | 30 | - run: bun run parity:check |
| 31 | 31 | - run: python3 -m py_compile resources/functions/drone/camera/imx500_worker.py |
| 32 | 32 | - 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 | |
| 33 | 61 | - name: Verify a kernel-confirmed full input pipe on Linux |
| 34 | 62 | run: bun test tests/unit/runtime.test.ts --test-name-pattern 'kernel-confirmed full input FIFO' |
| 35 | 63 | - name: Verify an actual partial host image write and fresh-process recovery |
| @@ -423,6 +451,8 @@ jobs: | ||
| 423 | 451 | path: | |
| 424 | 452 | ${{ runner.temp }}/workspace-browser-evidence/*.png |
| 425 | 453 | ${{ 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 | |
| 426 | 456 | execution-browser: |
| 427 | 457 | runs-on: ubuntu-latest |
| 428 | 458 | timeout-minutes: 10 |
| @@ -452,6 +482,8 @@ jobs: | ||
| 452 | 482 | path: | |
| 453 | 483 | ${{ runner.temp }}/execution-browser-evidence/*.png |
| 454 | 484 | ${{ 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 | |
| 455 | 487 | purge-namespace: |
| 456 | 488 | runs-on: ubuntu-latest |
| 457 | 489 | timeout-minutes: 30 |
| @@ -28,6 +28,35 @@ stream. Native Stacks process capture drains stdout and stderr concurrently and | ||
| 28 | 28 | cleans up the owned process group on POSIX before reporting failure. This also |
| 29 | 29 | applies to SDK inspection and Pico 2 builds; it does not install firmware. |
| 30 | 30 | |
| 31 | Native runtime and sensor tests await build initialization before their test timers | |
| 32 | start. An unrelated setup hook must not interrupt the build API's owned command | |
| 33 | cleanup. Runtime response and test deadlines are unchanged. Build failures identify | |
| 34 | the configure, compile, contracts or SDK inspection stage. | |
| 35 | ||
| 36 | CI also compiles a fresh disposable source copy whose real CMake configure takes | |
| 37 | at least 21 seconds, crossing the former 20-second setup hook. It then runs the | |
| 38 | original Linux full-input FIFO case, retains real tool exit observations and checks | |
| 39 | that no owned tool groups remain. Run the same qualification locally with a new | |
| 40 | evidence directory: | |
| 41 | ||
| 42 | ```sh | |
| 43 | bun scripts/qualify-runtime-cold-build.ts /absolute/path/to/new-cold-build-evidence | |
| 44 | ``` | |
| 45 | ||
| 46 | On macOS the qualification uses the native boot/clean-exit case; the kernel FIFO | |
| 47 | case is Linux-only. The qualification retains its owned source and logs, including | |
| 48 | failures. It does not access a board or measure flight timing. | |
| 49 | ||
| 50 | The qualification supervisor derives its aggregate budget from the three separate | |
| 51 | 120-second build commands, their settlement allowance, the selected test's existing | |
| 52 | deadline and startup allowance. It captures at most 2 MB per stream concurrently, | |
| 53 | retains failed output and checks both parent and recorded tool process groups. | |
| 54 | This test-only aggregate budget does not extend the build API or runtime deadlines. | |
| 55 | CI separately interrupts the actual test parent while CMake is active and makes | |
| 56 | the early capture receipt path a directory. That expected reporting refusal must | |
| 57 | still 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 | ||
| 31 | 60 | ```sh |
| 32 | 61 | ./buddy runtime:build |
| 33 | 62 | ./buddy runtime:bench --ticks 1000 |
| @@ -1,5 +1,25 @@ | ||
| 1 | 1 | # Validation evidence |
| 2 | 2 | |
| 3 | ## Browser qualification diagnostics | |
| 4 | ||
| 5 | Browser fixtures keep their failed private directory after owned process-group | |
| 6 | cleanup. It contains native fixture credentials, database state and the browser | |
| 7 | profile, so CI does not upload that directory. Local retained paths are reported; | |
| 8 | CI runner disposal still ends access to private files that were not exported. | |
| 9 | ||
| 10 | CI exports a closed launch-child observation and at most 64 KiB of startup stderr. | |
| 11 | Public stderr capture stops before any page visit can receive credentials. Later | |
| 12 | browser stderr remains in the private fixture, while exported metadata reports | |
| 13 | that capture was withheld. Refused evidence destinations report unavailable and | |
| 14 | preserve the original browser failure. Launch exit observation, signal and cleanup | |
| 15 | intent are separate fields; they do not prove browser/page/thread liveness or the | |
| 16 | kernel's exact exit time. Post-renewal CDP liveness has its own operation label. | |
| 17 | ||
| 18 | Discovery, transport, stream and outer-test limits remain unchanged. These | |
| 19 | observations support the open investigations in [#44](https://github.com/stacksjs/drone/issues/44) | |
| 20 | and [#64](https://github.com/stacksjs/drone/issues/64); retaining a new attempt does | |
| 21 | not explain an earlier failure whose Chrome evidence was removed. | |
| 22 | ||
| 3 | 23 | ## Owned mission simulation slice |
| 4 | 24 | |
| 5 | 25 | - Full release suite: 211 Bun tests, 17,424 assertions, zero failures. Buddy lint, application/runtime/view |
| @@ -3,9 +3,9 @@ import { runBoundedHostProcess } from '../../host/process' | ||
| 3 | 3 | |
| 4 | 4 | export const picoSdkRevision = '079c6f39023649b154152db30f1d781e884879bc' |
| 5 | 5 | export interface RuntimeBuildOptions { project?: string, target?: 'host' | 'pico2', sdk?: string, toolchain?: string, watchdogTest?: boolean, sensors?: boolean } |
| 6 | async 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 }) | |
| 6 | async 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 }) | |
| 9 | 9 | return result.stdout.trim() |
| 10 | 10 | } |
| 11 | 11 | /** Local tools only; no shell interpolation, SDK download, firmware flashing or device access. */ |
| @@ -20,14 +20,14 @@ export async function buildRuntime(options: RuntimeBuildOptions = {}) { | ||
| 20 | 20 | const sdk = options.sdk ?? env.PICO_SDK_PATH |
| 21 | 21 | if (!sdk) throw new Error('Provide --sdk or PICO_SDK_PATH with the pinned Pico SDK 2.3.1 checkout') |
| 22 | 22 | 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') | |
| 24 | 24 | // 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') | |
| 26 | 26 | if (options.toolchain) env.PICO_TOOLCHAIN_PATH = resolve(options.toolchain) |
| 27 | 27 | } |
| 28 | 28 | 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) | |
| 32 | 32 | return { target, directory, artifact: join(directory, target === 'host' ? `drone_runtime_host${process.platform === 'win32' ? '.exe' : ''}` : 'drone_runtime.uf2'), physicalFlightEnabled: false as const } |
| 33 | 33 | } |
| @@ -0,0 +1,134 @@ | ||
| 1 | import assert from 'node:assert/strict' | |
| 2 | import { cp, mkdir, readFile, writeFile } from 'node:fs/promises' | |
| 3 | import { join, resolve } from 'node:path' | |
| 4 | import process from 'node:process' | |
| 5 | import { 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. | |
| 9 | interface ToolEvent { event: 'start' | 'spawn' | 'exit', stage: string, groupId: number, pid: number, exitCode?: number, elapsedMs?: number } | |
| 10 | assert(process.platform !== 'win32', 'Cold build qualification requires POSIX process groups') | |
| 11 | assert(process.argv[2], 'Provide a new owned evidence directory') | |
| 12 | const reportingFault = process.argv[3] === '--interrupt-before-capture-report' | |
| 13 | assert(process.argv.length <= 4 && (!process.argv[3] || reportingFault), 'Unknown qualification mode') | |
| 14 | const repository = resolve(import.meta.dir, '..'), evidence = resolve(process.argv[2]!) | |
| 15 | await mkdir(evidence) | |
| 16 | if (reportingFault) await mkdir(join(evidence, 'capture-result.json')) | |
| 17 | const project = join(evidence, 'project'), tools = join(evidence, 'tools'), eventsPath = join(evidence, 'tool-events.ndjson') | |
| 18 | await mkdir(join(project, 'firmware'), { recursive: true }) | |
| 19 | await mkdir(tools) | |
| 20 | await cp(join(repository, 'firmware/runtime'), join(project, 'firmware/runtime'), { recursive: true }) | |
| 21 | await writeFile(join(project, 'bunfig.toml'), '[test]\npreload = []\n') | |
| 22 | const cmakePath = join(project, 'firmware/runtime/CMakeLists.txt'), cmake = await readFile(cmakePath, 'utf8') | |
| 23 | assert(cmake.startsWith('cmake_minimum_required(VERSION 3.20)'), 'Expected recorded runtime CMake entrypoint') | |
| 24 | await 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)')) | |
| 25 | assert(!await Bun.file(join(project, 'storage/runtime-build/host/CMakeCache.txt')).exists(), 'Qualification must start cold') | |
| 26 | assert(!/\s/.test(process.execPath), 'Tool witness needs a single executable shebang path') | |
| 27 | for (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} | |
| 31 | import { appendFileSync } from 'node:fs' | |
| 32 | const stage = ${JSON.stringify(tool)} === 'ctest' ? 'contracts' : process.argv[2] === '-S' ? 'configure' : 'compile' | |
| 33 | const began = performance.now() | |
| 34 | const owner = { stage, groupId: process.pid, pid: process.pid } | |
| 35 | appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify({ ...owner, event: 'start' }) + '\n') | |
| 36 | const child = Bun.spawn([${JSON.stringify(executable)}, ...process.argv.slice(2)], { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }) | |
| 37 | const event = { stage, groupId: process.pid, pid: child.pid } | |
| 38 | appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify({ ...event, event: 'spawn' }) + '\n') | |
| 39 | if (${reportingFault} && stage === 'configure') { | |
| 40 | process.kill(child.pid, 0) | |
| 41 | process.kill(process.ppid, 'SIGKILL') | |
| 42 | } | |
| 43 | const exitCode = await child.exited | |
| 44 | appendFileSync(${JSON.stringify(eventsPath)}, JSON.stringify({ ...event, event: 'exit', exitCode, elapsedMs: performance.now() - began }) + '\n') | |
| 45 | process.exit(exitCode) | |
| 46 | ` | |
| 47 | await writeFile(join(tools, tool), source, { mode: 0o700 }) | |
| 48 | } | |
| 49 | const pattern = process.platform === 'linux' ? 'kernel-confirmed full input FIFO' : 'separate supervised boots' | |
| 50 | const buildCommandTimeoutMs = 120000, selectedTestTimeoutMs = process.platform === 'linux' ? 20000 : 5000 | |
| 51 | const qualificationTimeoutMs = coldBuildQualificationTimeout(buildCommandTimeoutMs, selectedTestTimeoutMs) | |
| 52 | let result: Awaited<ReturnType<typeof captureColdBuild>> | undefined, failure: unknown | |
| 53 | const began = performance.now() | |
| 54 | try { | |
| 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 | } | |
| 58 | catch (error) { failure = error } | |
| 59 | // Retain the capture outcome even if a later cleanup observation itself fails. | |
| 60 | const reportingErrors: { stage: string, code: string | null }[] = [] | |
| 61 | try { | |
| 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 | } | |
| 64 | catch (error) { | |
| 65 | failure ??= error | |
| 66 | reportingErrors.push({ stage: 'capture-result', code: (error as NodeJS.ErrnoException).code ?? null }) | |
| 67 | } | |
| 68 | let eventText = '' | |
| 69 | try { eventText = await readFile(eventsPath, 'utf8') } | |
| 70 | catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } | |
| 71 | const events: ToolEvent[] = [] | |
| 72 | for (const line of eventText.split('\n').filter(Boolean)) { | |
| 73 | try { events.push(JSON.parse(line)) } | |
| 74 | catch (error) { failure ??= error } | |
| 75 | } | |
| 76 | const groups = [...new Set(events.map(event => event.groupId))] | |
| 77 | assert(groups.every(group => Number.isSafeInteger(group) && group > 1), 'Tool witnesses must identify owned process groups') | |
| 78 | const probeErrors: { group: number, code: string }[] = [] | |
| 79 | const cleanupErrors: { group: number, code: string | null }[] = [] | |
| 80 | function 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 | } | |
| 94 | const beforeCleanup = groups.filter(alive) | |
| 95 | for (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 | } | |
| 104 | const cleanupDeadline = performance.now() + 5000 | |
| 105 | while (groups.some(alive) && performance.now() < cleanupDeadline) await Bun.sleep(25) | |
| 106 | const afterCleanup = groups.filter(alive) | |
| 107 | await writeFile(join(evidence, 'stdout.log'), result?.stdout ?? '') | |
| 108 | await writeFile(join(evidence, 'stderr.log'), result?.stderr ?? '') | |
| 109 | const 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 } | |
| 110 | await writeFile(join(evidence, 'receipt.json'), `${JSON.stringify(report, null, 2)}\n`) | |
| 111 | assert.equal(afterCleanup.length, 0, 'Owned tool groups must settle before any evidence removal') | |
| 112 | assert.equal(cleanupErrors.length, 0, 'Unexpected tool cleanup errors must remain qualification failures') | |
| 113 | assert(result, 'Actual parent capture must be available') | |
| 114 | assert.equal(result.rootGroupRemaining, false, 'The actual parent process group must settle') | |
| 115 | if (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 | } | |
| 121 | else { | |
| 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 | } | |
| 134 | console.log(JSON.stringify(report)) | |
| @@ -0,0 +1,59 @@ | ||
| 1 | import { Buffer } from 'node:buffer' | |
| 2 | import { writeSync } from 'node:fs' | |
| 3 | import { mkdir, open, writeFile } from 'node:fs/promises' | |
| 4 | import { join } from 'node:path' | |
| 5 | ||
| 6 | export 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 | } | |
| 12 | export 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 | } | |
| 16 | function 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. */ | |
| 24 | export 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 | } | |
| @@ -0,0 +1,114 @@ | ||
| 1 | import { Buffer } from 'node:buffer' | |
| 2 | import process from 'node:process' | |
| 3 | ||
| 4 | /** Test-only ownership of the aggregate build/test process, beyond a single command's budget. */ | |
| 5 | export 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 | ||
| 19 | export const coldBuildCaptureBytes = 2_000_000 | |
| 20 | export const coldBuildSettlementMs = 5000 | |
| 21 | ||
| 22 | /** Three build commands, each with its own settlement allowance, then the unchanged selected test. */ | |
| 23 | export 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 | ||
| 31 | export 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 | } | |
| @@ -89,7 +89,7 @@ try { | ||
| 89 | 89 | console.log(`QA driver33s timer drift ms: ${Date.now() - waitStart - 33000}`) |
| 90 | 90 | const status = await control('status') |
| 91 | 91 | 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() | |
| 93 | 93 | assert(status.recipientStreams > baseline.recipientStreams, 'Actual native stream must reconnect during the measured lifetime window') |
| 94 | 94 | const starts = status.recipientStreamStarts as number[] |
| 95 | 95 | 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') |
| @@ -2,6 +2,8 @@ import assert from 'node:assert/strict' | ||
| 2 | 2 | import { mkdir, readFile } from 'node:fs/promises' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | 4 | import { observeBrowserOperation } from './browser-operation' |
| 5 | import { browserFileErrorCode, retainBrowserLaunchEvidence } from './browser-launch-evidence' | |
| 6 | import type { BrowserLaunchObservation } from './browser-launch-evidence' | |
| 5 | 7 | |
| 6 | 8 | /** Dependency-free CDP, with a fresh profile. Credentials never enter command arguments. */ |
| 7 | 9 | async function openSocket(url: string): Promise<WebSocket> { |
| @@ -19,15 +21,24 @@ export async function startWorkspaceBrowser(root: string) { | ||
| 19 | 21 | await mkdir(profile) |
| 20 | 22 | 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')) }) |
| 21 | 23 | const started = Date.now() |
| 24 | const began = performance.now() | |
| 22 | 25 | 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 | }) | |
| 24 | 34 | let port = '' |
| 25 | 35 | 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 } | |
| 28 | 39 | await Bun.sleep(50) |
| 29 | 40 | } |
| 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') } | |
| 31 | 42 | // Keep a browser-level session, as well as page sessions, for the owned lifecycle. |
| 32 | 43 | let browserSocket: WebSocket |
| 33 | 44 | try { |
| @@ -35,13 +46,14 @@ export async function startWorkspaceBrowser(root: string) { | ||
| 35 | 46 | const version = await observeBrowserOperation('version-json', () => response.json()) as { webSocketDebuggerUrl: string } |
| 36 | 47 | browserSocket = await observeBrowserOperation('browser-socket', () => openSocket(version.webSocketDebuggerUrl)) |
| 37 | 48 | } |
| 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 } | |
| 39 | 50 | let browserSequence = 0 |
| 40 | 51 | browserSocket.onmessage = () => {} |
| 41 | 52 | const keepAlive = setInterval(() => { if (browserSocket.readyState === WebSocket.OPEN) browserSocket.send(JSON.stringify({ id: ++browserSequence, method: 'Browser.getVersion' })) }, 5000) |
| 42 | 53 | const pages: Array<{ close: () => void }> = [] |
| 43 | 54 | return { |
| 44 | 55 | async page(url: string, javascript = true) { |
| 56 | pageVisited = true | |
| 45 | 57 | const response = await observeBrowserOperation('target-fetch', () => fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT', signal: AbortSignal.timeout(5000) })) |
| 46 | 58 | const target = await observeBrowserOperation('target-json', () => response.json()) as { webSocketDebuggerUrl: string } |
| 47 | 59 | const socket = await observeBrowserOperation('page-socket', () => openSocket(target.webSocketDebuggerUrl)) |
| @@ -73,6 +85,7 @@ export async function startWorkspaceBrowser(root: string) { | ||
| 73 | 85 | while (Date.now() < end) { if (await evaluate(expression)) return; await Bun.sleep(50) } |
| 74 | 86 | throw new Error(message) |
| 75 | 87 | }, |
| 88 | async checkLiveness() { phase = 'post-renewal liveness'; assert.equal(await evaluate('1'), 1) }, | |
| 76 | 89 | 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})); })()`) }, |
| 77 | 90 | 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(); })()`) }, |
| 78 | 91 | 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) { | ||
| 83 | 96 | 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') |
| 84 | 97 | return page |
| 85 | 98 | }, |
| 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') }, | |
| 87 | 100 | } |
| 88 | 101 | } |
| @@ -25,6 +25,9 @@ | ||
| 25 | 25 | "./unit/estimation.test.ts", |
| 26 | 26 | "./fixtures/sensors/generate.ts", |
| 27 | 27 | "./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", | |
| 28 | 31 | "./unit/sensors.test.ts", |
| 29 | 32 | "../resources/functions/drone/payloads/**/*.ts", |
| 30 | 33 | "./unit/payloads.test.ts", |
| @@ -0,0 +1,59 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { Buffer } from 'node:buffer' | |
| 3 | import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { retainBrowserLaunchEvidence } from '../helpers/browser-launch-evidence' | |
| 7 | import type { BrowserLaunchObservation } from '../helpers/browser-launch-evidence' | |
| 8 | ||
| 9 | const 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 | ||
| 11 | test('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 | ||
| 32 | test('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 | ||
| 47 | test('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 | }) | |
| @@ -0,0 +1,48 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import process from 'node:process' | |
| 6 | import { startWorkspaceBrowser } from '../helpers/workspace-browser' | |
| 7 | ||
| 8 | test.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) | |
| @@ -7,6 +7,7 @@ import { spawnWorkspaceFixture } from '../helpers/workspace-process' | ||
| 7 | 7 | test.skipIf(process.env.EXECUTION_BROWSER_QA !== 'true')('compiled execution panel uses real native routes and a genuine Worker', async () => { |
| 8 | 8 | const directory = await mkdtemp(join(tmpdir(), 'drone-incident-access-')) |
| 9 | 9 | let owned: ReturnType<typeof spawnWorkspaceFixture> | undefined |
| 10 | let passed = false | |
| 10 | 11 | try { |
| 11 | 12 | const config = join(directory, 'bunfig.toml'); await writeFile(config, 'preload=[]\n') |
| 12 | 13 | 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 | ||
| 16 | 17 | const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout as ReadableStream).text(), new Response(child.stderr as ReadableStream).text()]) |
| 17 | 18 | expect(code, `${stdout}\n${stderr}`).toBe(0) |
| 18 | 19 | expect(stdout).toContain('PASS actual execution browser: native live-before-return') |
| 20 | passed = true | |
| 19 | 21 | } |
| 20 | 22 | finally { clearTimeout(timeout) } |
| 21 | 23 | } |
| 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 | } | |
| 23 | 29 | }, 110000) |
| 24 | 30 | |
| 25 | 31 | test('execution browser fixture refuses an unguarded directory before database or application writes', async () => { |
| @@ -8,6 +8,7 @@ import { spawnWorkspaceFixture } from '../helpers/workspace-process' | ||
| 8 | 8 | test.skipIf(process.env.WORKSPACE_BROWSER_QA !== 'true')('actual rendered workspace with native credentials and routes', async () => { |
| 9 | 9 | const directory = await mkdtemp(join(tmpdir(), 'drone-incident-access-')) |
| 10 | 10 | let owned: ReturnType<typeof spawnWorkspaceFixture> | undefined |
| 11 | let passed = false | |
| 11 | 12 | try { |
| 12 | 13 | const config = join(directory, 'bunfig.toml'); await writeFile(config, 'preload=[]\n') |
| 13 | 14 | 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 | ||
| 17 | 18 | const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout as ReadableStream).text(), new Response(child.stderr as ReadableStream).text()]) |
| 18 | 19 | expect(code, `${stdout}\n${stderr}`).toBe(0) |
| 19 | 20 | expect(stdout).toContain('PASS actual compiled workspace browser') |
| 21 | passed = true | |
| 20 | 22 | } |
| 21 | 23 | finally { clearTimeout(timeout) } |
| 22 | 24 | } |
| 23 | 25 | finally { |
| 24 | 26 | 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 })) | |
| 26 | 29 | } |
| 27 | 30 | }, 110000) |
| 28 | 31 | |
| @@ -0,0 +1,15 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { buildRuntime } from '../../resources/functions/drone/runtime/build' | |
| 6 | ||
| 7 | test('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) | |
| @@ -0,0 +1,62 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { Buffer } from 'node:buffer' | |
| 3 | import process from 'node:process' | |
| 4 | import { captureColdBuild, coldBuildCaptureBytes, coldBuildQualificationTimeout } from '../helpers/runtime-cold-build-supervisor' | |
| 5 | ||
| 6 | const options = { cwd: process.cwd(), env: process.env, timeoutMs: 15000 } | |
| 7 | const run = (source: string, timeoutMs = options.timeoutMs) => captureColdBuild([process.execPath, '-e', source], { ...options, timeoutMs }) | |
| 8 | function 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 | ||
| 13 | test.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 | ||
| 25 | test.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 | ||
| 36 | test.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 | ||
| 47 | test.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 | ||
| 58 | test('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 | }) | |
| @@ -1,4 +1,4 @@ | ||
| 1 | import { beforeAll, describe, expect, test } from 'bun:test' | |
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | 2 | import { mkdtemp, rm, writeFile } from 'node:fs/promises' |
| 3 | 3 | import { tmpdir } from 'node:os' |
| 4 | 4 | import { join } from 'node:path' |
| @@ -82,8 +82,8 @@ describe('Pi bench session authority', () => { | ||
| 82 | 82 | }) |
| 83 | 83 | }) |
| 84 | 84 | |
| 85 | let artifact: string | |
| 86 | beforeAll(async () => { artifact = (await buildRuntime()).artifact }, 20000) | |
| 85 | // Build setup uses the build API's bounded command lifecycle, before test timers start. | |
| 86 | const { artifact } = await buildRuntime() | |
| 87 | 87 | async function withNative(run: (harness: ReturnType<typeof openRuntimeHarness>) => Promise<void>) { |
| 88 | 88 | const harness = openRuntimeHarness(artifact) |
| 89 | 89 | try { await run(harness) } |
| @@ -1,4 +1,4 @@ | ||
| 1 | import { beforeAll, describe, expect, test } from 'bun:test' | |
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | 2 | import { command } from '@stacksjs/testing' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | 4 | import fixture from '../fixtures/sensors/bench-v1.json' |
| @@ -199,8 +199,8 @@ describe('sensor clocks and independent health', () => { | ||
| 199 | 199 | }) |
| 200 | 200 | }) |
| 201 | 201 | |
| 202 | let nativeDirectory: string | |
| 203 | beforeAll(async () => { nativeDirectory = (await buildRuntime()).directory }, 20000) | |
| 202 | // A cold native build must settle before the sensor test's own timing begins. | |
| 203 | const { directory: nativeDirectory } = await buildRuntime() | |
| 204 | 204 | describe('native capture and simulator replay', () => { |
| 205 | 205 | test('the native C++ codec preserves independently encoded records byte-for-byte', async () => { |
| 206 | 206 | const wire = Buffer.concat(['imu', 'barometer', 'gnss'].map(kind => Buffer.from(bytes(kind as SensorKind)))) |