clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

fix: harden the Layer 1 evidence gate

Dami committed 2ee4179
commit
2ee41790fc6530b5926b819c4a7bc9c056a7bc3d
parent
b13c4245c832c18628b2ff9175dbba3bef992795
author
Dami <dami.etoile@gmail.com>
8 files changed, +385 -103
M.github/workflows/m2-layer1.yml8+
Mapps/reference/src/islands/ChecklistIsland.tsx17+-
Aapps/reference/src/ui-contract.ts10+
Mdeno.json2+-
Mtools/check_migrations.ts18+++
Mtools/golden_path.ts129+++++++++++--------
Mtools/golden_path_core.ts205++++++++++++++++++++++++------
Mtools/golden_path_core_test.ts99++++++++++++++
M .github/workflows/m2-layer1.yml +8 -0
M apps/reference/src/islands/ChecklistIsland.tsx +9 -8
@@ -2,6 +2,7 @@ import { useEffect, useState } from "preact/hooks";
22 import type { ChecklistTask } from "../_lofi/checklist-store.ts";33 import { settleUiMutation } from "../_lofi/ui-mutation.ts";44 import { useChecklist } from "../_lofi/use-checklist.ts";5+import { checklistUi } from "../ui-contract.ts";56 67 export interface ChecklistIslandProps {78 label: string;
@@ -29,7 +30,7 @@ function ChecklistRow({ task, update, setCompleted, remove }: ChecklistRowProps)
2930 <input3031 type="checkbox"3132 checked={task.completed}32- aria-label={`Complete ${task.text}`}33+ aria-label={checklistUi.complete(task.text)}3334 onChange={(event) =>3435 void settleUiMutation(setCompleted(task.id, event.currentTarget.checked))}3536 />
@@ -47,14 +48,14 @@ function ChecklistRow({ task, update, setCompleted, remove }: ChecklistRowProps)
4748 void settleUiMutation(update(task.id, next));4849 }}4950 >50- <label class="visually-hidden" for={editId}>Edit {task.text}</label>51+ <label class="visually-hidden" for={editId}>{checklistUi.edit(task.text)}</label>5152 <input5253 id={editId}5354 value={draft}5455 onInput={(event) => setDraft(event.currentTarget.value)}5556 autocomplete="off"5657 />57- <button type="submit" aria-label={`Save ${task.text}`}>Save</button>58+ <button type="submit" aria-label={checklistUi.save(task.text)}>Save</button>5859 <button type="button" onClick={() => setEditing(false)}>Cancel</button>5960 </form>6061 )
@@ -62,7 +63,7 @@ function ChecklistRow({ task, update, setCompleted, remove }: ChecklistRowProps)
6263 <div class="task-actions">6364 <button6465 type="button"65- aria-label={`Edit ${task.text}`}66+ aria-label={checklistUi.edit(task.text)}6667 onClick={() => setEditing(true)}6768 >6869 Edit
@@ -70,7 +71,7 @@ function ChecklistRow({ task, update, setCompleted, remove }: ChecklistRowProps)
7071 <button7172 type="button"7273 class="button-danger"73- aria-label={`Delete ${task.text}`}74+ aria-label={checklistUi.delete(task.text)}7475 onClick={() => void settleUiMutation(remove(task.id))}7576 >7677 Delete
@@ -101,7 +102,7 @@ export default function ChecklistIsland({ label }: ChecklistIslandProps) {
101102 void settleUiMutation(checklist.create(next));102103 }}103104 >104- <label for={inputId}>New item</label>105+ <label for={inputId}>{checklistUi.newItem}</label>105106 <div class="composer">106107 <input107108 id={inputId}
@@ -109,12 +110,12 @@ export default function ChecklistIsland({ label }: ChecklistIslandProps) {
109110 onInput={(event) => setText(event.currentTarget.value)}110111 autocomplete="off"111112 />112- <button type="submit">Add from {label}</button>113+ <button type="submit">{checklistUi.addFrom(label)}</button>113114 </div>114115 </form>115116 <p class="state" role="status">116117 {checklist.status === "loading" && "Opening persistent storage…"}117- {checklist.status === "error" && `Write failed: ${checklist.error}`}118+ {checklist.status === "error" && `${checklistUi.writeFailed} ${checklist.error}`}118119 {checklist.status === "ready" &&119120 `${checklist.tasks.length} item(s) · last write ${checklist.durability}`}120121 </p>
A apps/reference/src/ui-contract.ts +10 -0
M deno.json +1 -1
M tools/check_migrations.ts +18 -0
M tools/golden_path.ts +76 -53
@@ -11,6 +11,7 @@ import {
1111 type JourneyReport,1212 type JourneySource,1313 materializeCleanProject,14+ probeHttpReady,1415 reserveLocalPort,1516 runCapturedCommand,1617 safeChildEnvironment,
@@ -19,6 +20,7 @@ import {
1920 writeJourneyReport,2021 } from "./golden_path_core.ts";2122 import { loadEnvironment } from "./load_env.ts";23+import { checklistUi } from "../apps/reference/src/ui-contract.ts";2224 2325 export type GoldenPathCommands = {2426 dev: string;
@@ -86,7 +88,7 @@ async function waitForItem(
8688 const island = page.locator("[data-island]").filter({ hasText: label });8789 const item = island.getByRole("listitem").filter({ hasText: body });8890 if (index === 0) {89- const failure = island.getByRole("status").filter({ hasText: "Write failed:" });91+ const failure = island.getByRole("status").filter({ hasText: checklistUi.writeFailed });9092 await item.or(failure).waitFor({ state: "visible" });9193 if (await failure.isVisible()) {9294 throw new Error((await failure.textContent()) ?? "Write failed");
@@ -110,22 +112,24 @@ async function waitForItemAbsent(
110112 111113 async function addItem(page: Page, island: string, body: string): Promise<void> {112114 const section = page.locator("[data-island]").filter({ hasText: island });113- const textbox = section.getByRole("textbox", { name: "New item" });115+ const textbox = section.getByRole("textbox", { name: checklistUi.newItem });114116 try {115117 await textbox.waitFor({ state: "visible" });116118 } catch (error) {117119 throw new Error(118- `reference UI is missing accessible textbox "New item" in ${island}; #9 must expose CRUD controls`,120+ `reference UI is missing accessible textbox "${checklistUi.newItem}" in ${island}; #9 must expose CRUD controls`,119121 { cause: error },120122 );121123 }122124 await textbox.fill(body);123- const add = section.getByRole("button", { name: `Add from ${island}` });125+ const add = section.getByRole("button", { name: checklistUi.addFrom(island) });124126 try {125127 await add.waitFor({ state: "visible" });126128 } catch (error) {127129 throw new Error(128- `reference UI is missing accessible button "Add from ${island}"; #9 must expose CRUD controls`,130+ `reference UI is missing accessible button "${131+ checklistUi.addFrom(island)132+ }"; #9 must expose CRUD controls`,129133 { cause: error },130134 );131135 }
@@ -133,11 +137,11 @@ async function addItem(page: Page, island: string, body: string): Promise<void>
133137 }134138 135139 async function waitForLocalDurability(page: Page): Promise<void> {136- await page.getByRole("status").filter({ hasText: /last write (?:local|global)/ }).first().waitFor(137- {138- state: "visible",139- },140- );140+ await page.getByRole("status").filter({141+ hasText: new RegExp(142+ `(?:${escaped(checklistUi.lastWrite("local"))}|${escaped(checklistUi.lastWrite("global"))})`,143+ ),144+ }).first().waitFor({ state: "visible" });141145 }142146 143147 async function assertRuntimeCardinality(
@@ -185,9 +189,19 @@ async function observeReconnectSettlement(
185189 timeoutMs: number,186190 required: boolean,187191 ): Promise<JourneyAssertion> {192+ if (!required) {193+ return {194+ name: "development reconnect settlement",195+ status: "blocked",196+ detail:197+ "Local-only mode has no global durability endpoint; offline retention passed, but this run makes no transport convergence claim.",198+ };199+ }188200 try {189- const global = page.getByRole("status").filter({ hasText: /last write global/ }).first();190- const failure = page.getByRole("status").filter({ hasText: "Write failed:" }).first();201+ const global = page.getByRole("status").filter({202+ hasText: checklistUi.lastWrite("global"),203+ }).first();204+ const failure = page.getByRole("status").filter({ hasText: checklistUi.writeFailed }).first();191205 await global.or(failure).waitFor({192206 state: "visible",193207 timeout: Math.min(timeoutMs, 10_000),
@@ -201,17 +215,9 @@ async function observeReconnectSettlement(
201215 detail: "The configured development server exposed global settlement after network return.",202216 };203217 } catch {204- if (required) {205- throw new Error(206- "cloud evidence failed: reconnect did not expose global settlement before the readiness deadline",207- );208- }209- return {210- name: "development reconnect settlement",211- status: "blocked",212- detail:213- "The local managed server did not expose global settlement before the readiness deadline; offline retention passed, but this run makes no transport convergence claim.",214- };218+ throw new Error(219+ "cloud evidence failed: reconnect did not expose global settlement before the readiness deadline",220+ );215221 }216222 }217223
@@ -237,50 +243,53 @@ async function journeyEnvironment(
237243 }238244 239245 async function updateItem(page: Page, body: string, nextBody: string): Promise<void> {240- const edit = page.getByRole("button", { name: `Edit ${body}`, exact: true }).first();246+ const edit = page.getByRole("button", { name: checklistUi.edit(body), exact: true }).first();241247 try {242248 await edit.click();243249 } catch (error) {244- throw new Error(`reference UI is missing accessible button "Edit ${body}"`, { cause: error });250+ throw new Error(`reference UI is missing accessible button "${checklistUi.edit(body)}"`, {251+ cause: error,252+ });245253 }246- const textbox = page.getByRole("textbox", { name: `Edit ${body}`, exact: true }).first();254+ const textbox = page.getByRole("textbox", { name: checklistUi.edit(body), exact: true }).first();247255 try {248256 await textbox.fill(nextBody);249257 } catch (error) {250- throw new Error(`reference UI is missing accessible textbox "Edit ${body}"`, { cause: error });258+ throw new Error(`reference UI is missing accessible textbox "${checklistUi.edit(body)}"`, {259+ cause: error,260+ });251261 }252- const save = page.getByRole("button", { name: `Save ${body}`, exact: true }).first();262+ const save = page.getByRole("button", { name: checklistUi.save(body), exact: true }).first();253263 try {254264 await save.click();255265 } catch (error) {256- throw new Error(`reference UI is missing accessible button "Save ${body}"`, { cause: error });266+ throw new Error(`reference UI is missing accessible button "${checklistUi.save(body)}"`, {267+ cause: error,268+ });257269 }258270 }259271 260272 async function completeItem(page: Page, body: string): Promise<void> {261- const name = new RegExp(`^(?:Complete ${escaped(body)}|Mark ${escaped(body)} complete)$`);273+ const name = checklistUi.complete(body);262274 const checkboxes = page.getByRole("checkbox", { name });263275 try {264276 await checkboxes.first().waitFor({ state: "visible" });265277 } catch (error) {266278 throw new Error(267- `reference UI needs checkbox "Complete ${body}" or "Mark ${body} complete"`,279+ `reference UI needs checkbox "${checklistUi.complete(body)}"`,268280 { cause: error },269281 );270282 }271283 await checkboxes.first().click();272284 await page.waitForFunction((accessibleName) => {273285 const matches = [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]274- .filter((element) =>275- element.getAttribute("aria-label") === accessibleName[0] ||276- element.getAttribute("aria-label") === accessibleName[1]277- );286+ .filter((element) => element.getAttribute("aria-label") === accessibleName);278287 return matches.length === 2 && matches.every((element) => element.checked);279- }, [`Complete ${body}`, `Mark ${body} complete`]);288+ }, checklistUi.complete(body));280289 }281290 282291 async function assertCompleted(page: Page, body: string): Promise<void> {283- const name = new RegExp(`^(?:Complete ${escaped(body)}|Mark ${escaped(body)} complete)$`);292+ const name = checklistUi.complete(body);284293 const checkboxes = page.getByRole("checkbox", { name });285294 await checkboxes.first().waitFor({ state: "visible" });286295 assert(await checkboxes.count() === 2, `expected two completion controls for ${body}`);
@@ -293,11 +302,13 @@ async function assertCompleted(page: Page, body: string): Promise<void> {
293302 }294303 295304 async function deleteItem(page: Page, body: string): Promise<void> {296- const button = page.getByRole("button", { name: `Delete ${body}`, exact: true }).first();305+ const button = page.getByRole("button", { name: checklistUi.delete(body), exact: true }).first();297306 try {298307 await button.click();299308 } catch (error) {300- throw new Error(`reference UI is missing accessible button "Delete ${body}"`, { cause: error });309+ throw new Error(`reference UI is missing accessible button "${checklistUi.delete(body)}"`, {310+ cause: error,311+ });301312 }302313 }303314
@@ -332,6 +343,21 @@ function commandFailure(record: CommandRecord): Error {
332343 );333344 }334345 346+async function sourceRevision(347+ sourceRoot: string,348+ environment: Record<string, string>,349+): Promise<string> {350+ const output = await new Deno.Command("git", {351+ args: ["rev-parse", "HEAD"],352+ cwd: sourceRoot,353+ clearEnv: true,354+ env: environment,355+ stdout: "piped",356+ stderr: "null",357+ }).output();358+ return output.success ? new TextDecoder().decode(output.stdout).trim() : "unavailable";359+}360+335361 export async function runJourney(options: GoldenPathOptions): Promise<JourneyReport> {336362 const sourceRoot = resolve(options.projectRoot ?? Deno.cwd());337363 const environmentMode = options.environmentMode ?? "isolated-local";
@@ -357,6 +383,7 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
357383 const islandLabels = options.islandLabels ?? ["North island", "South island"];358384 const environment = await journeyEnvironment(sourceRoot, environmentMode, artifacts.root);359385 const redactValues = environmentNames.map((name) => environment[name]).filter(Boolean);386+ const commit = await sourceRevision(sourceRoot, environment);360387 const commandRecords: CommandRecord[] = [];361388 const assertions: JourneyAssertion[] = [];362389 let projectRoot = sourceRoot;
@@ -384,10 +411,11 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
384411 os: Deno.build.os,385412 arch: Deno.build.arch,386413 browser: browserVersion,414+ commit,387415 },388416 cacheMode: options.cacheMode ?? "existing",389417 measurements: {390- developerCommandCount: options.source === "create" ? 3 : 3,418+ developerCommandCount: 3,391419 },392420 commands: commandRecords,393421 assertions,
@@ -398,7 +426,9 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
398426 try {399427 if (options.source === "checkout") {400428 projectRoot = await materializeCleanProject(sourceRoot);401- commandRecords.push(...await initializeFixtureGit(projectRoot, environment, artifacts.root));429+ commandRecords.push(430+ ...await initializeFixtureGit(projectRoot, environment, artifacts.root, redactValues),431+ );402432 }403433 404434 report.authorBoundaryViolations = await scanAuthorBoundary(
@@ -443,13 +473,16 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
443473 artifactRoot: artifacts.root,444474 name: "dev",445475 readyPattern: /(http:\/\/(?:127\.0\.0\.1|localhost):\d+\/?)/,476+ readyCheck: probeHttpReady,446477 timeoutMs,447478 redactValues,448479 });449480 const devUrl = await dev.ready;450481 devReadyMs = Math.round(performance.now() - devStarted);451482 await page.goto(devUrl, { waitUntil: "domcontentloaded", timeout: timeoutMs });452- await page.getByRole("textbox", { name: "New item" }).first().waitFor({ state: "visible" });483+ await page.getByRole("textbox", { name: checklistUi.newItem }).first().waitFor({484+ state: "visible",485+ });453486 await assertRuntimeCardinality(page, islandLabels);454487 await page.getByRole("status").filter({ hasText: /\d+ item\(s\)/ }).first().waitFor({455488 state: "visible",
@@ -543,6 +576,7 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
543576 artifactRoot: artifacts.root,544577 name: "preview",545578 readyPattern: /(http:\/\/127\.0\.0\.1:\d+\/)/,579+ readyCheck: probeHttpReady,546580 timeoutMs,547581 redactValues,548582 });
@@ -631,14 +665,3 @@ export async function runJourney(options: GoldenPathOptions): Promise<JourneyRep
631665 if (failure) throw failure;632666 return report;633667 }634-635-if (import.meta.main) {636- try {637- const report = await runJourney({ source: "checkout" });638- console.log(`lofi golden path: passed (${report.artifacts.report})`);639- } catch (error) {640- console.error(`lofi golden path: failed: ${error instanceof Error ? error.message : error}`);641- console.error("rerun: deno task test:golden:checkout");642- Deno.exit(1);643- }644-}
M tools/golden_path_core.ts +164 -41
@@ -1,4 +1,5 @@
11 import { basename, dirname, join, relative, resolve } from "node:path";2+import { environmentNames } from "./env_contract.ts";23 34 export type JourneySource = "checkout" | "create";45
@@ -38,6 +39,7 @@ export type JourneyReport = {
3839 os: string;3940 arch: string;4041 browser: string;42+ commit: string;4143 };4244 cacheMode: "existing" | "cold";4345 measurements: {
@@ -184,10 +186,7 @@ export function safeChildEnvironment(
184186 environment.CI = "1";185187 environment.NO_COLOR = "1";186188 // Explicit empties prevent a parent shell's sync credentials from influencing local mode.187- environment.JAZZ_APP_ID = "";188- environment.JAZZ_SERVER_URL = "";189- environment.JAZZ_ADMIN_SECRET = "";190- environment.BACKEND_SECRET = "";189+ for (const name of environmentNames) environment[name] = "";191190 return environment;192191 }193192
@@ -299,7 +298,7 @@ async function captureLines(
299298 stream: ReadableStream<Uint8Array>,300299 path: string,301300 onLine: (line: string) => void,302- redactValues: readonly string[] = [],301+ redact: (text: string) => string,303302 ): Promise<void> {304303 const file = await Deno.open(path, { create: true, truncate: true, write: true });305304 const writer = file.writable.getWriter();
@@ -312,14 +311,14 @@ async function captureLines(
312311 const lines = pending.split(/\r?\n/);313312 pending = lines.pop() ?? "";314313 for (const line of lines) {315- const redacted = redactText(line, redactValues);314+ const redacted = redact(line);316315 await writer.write(encoder.encode(`${redacted}\n`));317316 onLine(redacted);318317 }319318 }320319 pending += decoder.decode();321320 if (pending) {322- const redacted = redactText(pending, redactValues);321+ const redacted = redact(pending);323322 await writer.write(encoder.encode(redacted));324323 onLine(redacted);325324 }
@@ -329,11 +328,83 @@ async function captureLines(
329328 }330329 331330 export function redactText(text: string, values: readonly string[]): string {332- let redacted = text;331+ return createRedactor(values)(text);332+}333+334+function createRedactor(values: readonly string[]): (text: string) => string {333335 const candidates = [...new Set(values.map((value) => value.trim()).filter(Boolean))]334336 .sort((left, right) => right.length - left.length);335- for (const value of candidates) redacted = redacted.split(value).join("[redacted]");336- return redacted;337+ return (text) => {338+ let redacted = text;339+ for (const value of candidates) redacted = redacted.split(value).join("[redacted]");340+ return redacted;341+ };342+}343+344+function delay(milliseconds: number): Promise<void> {345+ return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));346+}347+348+export async function probeHttpReady(url: string): Promise<boolean> {349+ try {350+ const response = await fetch(url, { signal: AbortSignal.timeout(1_000) });351+ await response.body?.cancel();352+ return response.status >= 200 && response.status < 400;353+ } catch {354+ return false;355+ }356+}357+358+async function descendantProcessIds(rootPid: number): Promise<number[]> {359+ if (Deno.build.os === "windows") return [];360+ const output = await new Deno.Command("ps", {361+ args: ["-axo", "pid=,ppid="],362+ stdout: "piped",363+ stderr: "null",364+ }).output();365+ if (!output.success) return [];366+ const children = new Map<number, number[]>();367+ for (const line of new TextDecoder().decode(output.stdout).split("\n")) {368+ const [pidText, parentText] = line.trim().split(/\s+/);369+ const pid = Number(pidText);370+ const parent = Number(parentText);371+ if (!Number.isInteger(pid) || !Number.isInteger(parent)) continue;372+ const siblings = children.get(parent) ?? [];373+ siblings.push(pid);374+ children.set(parent, siblings);375+ }376+ const descendants: number[] = [];377+ const visit = (pid: number) => {378+ for (const child of children.get(pid) ?? []) visit(child);379+ if (pid !== rootPid) descendants.push(pid);380+ };381+ visit(rootPid);382+ return descendants;383+}384+385+function signalProcess(pid: number, signal: Deno.Signal): void {386+ try {387+ Deno.kill(pid, signal);388+ } catch (error) {389+ if (!isAlreadyTerminated(error)) throw error;390+ }391+}392+393+async function waitForPortRelease(url: string, timeoutMs = 2_000): Promise<void> {394+ const parsed = new URL(url);395+ const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80));396+ const hostname = parsed.hostname;397+ const started = performance.now();398+ while (performance.now() - started < timeoutMs) {399+ try {400+ const connection = await Deno.connect({ hostname, port });401+ connection.close();402+ await delay(50);403+ } catch {404+ return;405+ }406+ }407+ throw new Error(`process shutdown left ${parsed.origin} accepting connections`);337408 }338409 339410 export function startReadyProcess(options: {
@@ -344,11 +415,13 @@ export function startReadyProcess(options: {
344415 artifactRoot: string;345416 name: string;346417 readyPattern: RegExp;418+ readyCheck?: (value: string) => Promise<boolean>;347419 timeoutMs: number;348420 redactValues?: readonly string[];349421 }): CapturedProcess {350422 const stdoutPath = join(options.artifactRoot, `${options.name}.stdout.log`);351423 const stderrPath = join(options.artifactRoot, `${options.name}.stderr.log`);424+ const redact = createRedactor(options.redactValues ?? []);352425 const child = new Deno.Command(options.executable ?? Deno.execPath(), {353426 args: options.args,354427 cwd: options.cwd,
@@ -362,31 +435,87 @@ export function startReadyProcess(options: {
362435 let resolveReady!: (value: string) => void;363436 let rejectReady!: (error: Error) => void;364437 let settled = false;438+ let advertisedValue: string | null = null;439+ let terminalStatus: Deno.CommandStatus | null = null;440+ const stderrTail: string[] = [];441+ const knownDescendants = new Set<number>();442+ const rememberDescendants = async () => {443+ for (const pid of await descendantProcessIds(child.pid)) knownDescendants.add(pid);444+ };445+ const diagnostics = (reason: string, status = terminalStatus): Error => {446+ const processState = status447+ ? `exited with ${status.code}${status.signal ? ` (${status.signal})` : ""}`448+ : "is still running";449+ const tail = stderrTail.length > 0 ? stderrTail.join("\n") : "(empty)";450+ return new Error(451+ `${options.name} ${reason}; process ${processState}; stderr tail:\n${tail}\nretained stderr: ${stderrPath}`,452+ );453+ };365454 const readiness = new Promise<string>((resolveReadyPromise, rejectReadyPromise) => {366455 resolveReady = resolveReadyPromise;367456 rejectReady = rejectReadyPromise;368457 });458+ const verifyAdvertisedValue = async (value: string) => {459+ if (!options.readyCheck) {460+ if (!settled) {461+ settled = true;462+ resolveReady(value);463+ }464+ return;465+ }466+ while (!settled) {467+ if (await options.readyCheck(value)) {468+ settled = true;469+ resolveReady(value);470+ return;471+ }472+ await delay(50);473+ }474+ };369475 const inspectLine = (line: string) => {370476 options.readyPattern.lastIndex = 0;371477 const match = options.readyPattern.exec(line);372- if (match && !settled) {373- settled = true;374- resolveReady(match[1] ?? match[0]);478+ if (match && !advertisedValue) {479+ advertisedValue = match[1] ?? match[0];480+ void rememberDescendants();481+ void verifyAdvertisedValue(advertisedValue).catch((error) => {482+ if (!settled) {483+ settled = true;484+ rejectReady(error instanceof Error ? error : new Error(String(error)));485+ }486+ });375487 }376488 };377- const stdout = captureLines(child.stdout, stdoutPath, inspectLine, options.redactValues);378- const stderr = captureLines(child.stderr, stderrPath, inspectLine, options.redactValues);489+ const stdout = captureLines(child.stdout, stdoutPath, inspectLine, redact);490+ const stderr = captureLines(child.stderr, stderrPath, (line) => {491+ stderrTail.push(line);492+ if (stderrTail.length > 20) stderrTail.shift();493+ inspectLine(line);494+ }, redact);379495 const completed = (async () => {380- const [status] = await Promise.all([child.status, stdout, stderr]);496+ const status = await child.status;497+ terminalStatus = status;381498 if (!settled) {499+ // Give the capture loops one turn to drain buffered stderr without letting an500+ // inherited grandchild pipe delay the exit diagnosis indefinitely.501+ await Promise.race([Promise.all([stdout, stderr]), delay(100)]);382502 settled = true;383- rejectReady(384- new Error(`${options.name} exited with ${status.code} before printing its usable URL`),385- );503+ rejectReady(diagnostics("exited before its advertised URL accepted requests", status));386504 }505+ await Promise.all([stdout, stderr]);387506 return status;388507 })();389- const ready = withDeadline(readiness, options.timeoutMs, `${options.name} URL`);508+ const ready = withDeadline(readiness, options.timeoutMs, `${options.name} URL`).catch((error) => {509+ if (!settled) settled = true;510+ if (error instanceof Error && error.message.includes("did not become ready")) {511+ throw diagnostics(512+ advertisedValue513+ ? "printed a URL that never accepted HTTP requests before the readiness deadline"514+ : "did not print a usable URL before the readiness deadline",515+ );516+ }517+ throw error;518+ });390519 391520 return {392521 child,
@@ -395,6 +524,8 @@ export function startReadyProcess(options: {
395524 stdoutPath,396525 stderrPath,397526 async stop() {527+ await rememberDescendants();528+ for (const pid of knownDescendants) signalProcess(pid, "SIGTERM");398529 try {399530 child.kill("SIGTERM");400531 } catch (error) {
@@ -403,6 +534,8 @@ export function startReadyProcess(options: {
403534 try {404535 await withDeadline(completed, 5_000, `${options.name} shutdown`);405536 } catch {537+ await rememberDescendants();538+ for (const pid of knownDescendants) signalProcess(pid, "SIGKILL");406539 try {407540 child.kill("SIGKILL");408541 } catch (error) {
@@ -410,6 +543,9 @@ export function startReadyProcess(options: {
410543 }411544 await completed;412545 }546+ if (advertisedValue?.startsWith("http://") || advertisedValue?.startsWith("https://")) {547+ await waitForPortRelease(advertisedValue);548+ }413549 },414550 };415551 }
@@ -460,6 +596,7 @@ export async function initializeFixtureGit(
460596 projectRoot: string,461597 environment: Record<string, string>,462598 artifactRoot: string,599+ redactValues: readonly string[] = [],463600 ): Promise<CommandRecord[]> {464601 // Deno does not proxy arbitrary git subcommands. Execute git directly, but retain the same465602 // structured evidence as every public Deno command.
@@ -484,31 +621,17 @@ export async function initializeFixtureGit(
484621 ],485622 ] as const486623 ) {487- const stdoutPath = join(artifactRoot, `${name}.stdout.log`);488- const stderrPath = join(artifactRoot, `${name}.stderr.log`);489- const started = performance.now();490- const output = await new Deno.Command("git", {624+ const record = await runCapturedCommand({625+ executable: "git",491626 args: [...args],492627 cwd: projectRoot,493- clearEnv: true,494- env: environment,495- stdout: "piped",496- stderr: "piped",497- }).output();498- await Promise.all([499- Deno.writeFile(stdoutPath, output.stdout),500- Deno.writeFile(stderrPath, output.stderr),501- ]);502- const record = {503628 name,504- args: ["git", ...args],505- durationMs: Math.round(performance.now() - started),506- exitCode: output.code,507- stdoutPath,508- stderrPath,509- };629+ environment,630+ artifactRoot,631+ redactValues,632+ });510633 records.push(record);511- if (!output.success) throw new Error(`${name} failed; see ${stderrPath}`);634+ if (record.exitCode !== 0) throw new Error(`${name} failed; see ${record.stderrPath}`);512635 }513636 return records;514637 }
M tools/golden_path_core_test.ts +99 -0
@@ -2,7 +2,9 @@ import { join, resolve } from "node:path";
22 import {33 installNodeSentinel,44 materializeCleanProject,5+ probeHttpReady,56 redactText,7+ reserveLocalPort,68 runCapturedCommand,79 safeChildEnvironment,810 scanAuthorBoundary,
@@ -192,6 +194,103 @@ Deno.test("process readiness resolves from output rather than a fixed sleep", as
192194 }193195 });194196 197+Deno.test("HTTP readiness waits until an early advertised URL accepts requests", async () => {198+ const artifacts = await workspaceTemp("golden-http-ready");199+ const port = reserveLocalPort();200+ const url = `http://127.0.0.1:${port}/`;201+ const started = performance.now();202+ const process = startReadyProcess({203+ executable: Deno.execPath(),204+ cwd: Deno.cwd(),205+ args: [206+ "eval",207+ `console.log("READY ${url}"); await new Promise((resolve) => setTimeout(resolve, 200)); Deno.serve({ hostname: "127.0.0.1", port: ${port} }, () => new Response("ready")); await new Promise(() => {});`,208+ ],209+ environment: safeChildEnvironment({}),210+ artifactRoot: artifacts,211+ name: "early-banner",212+ readyPattern: /READY (http:\/\/127\.0\.0\.1:\d+\/)/,213+ readyCheck: probeHttpReady,214+ timeoutMs: 5_000,215+ });216+ try {217+ assert(await process.ready === url, "the accepting URL should satisfy readiness");218+ assert(219+ performance.now() - started >= 150,220+ "a printed banner must not resolve before the listener accepts requests",221+ );222+ } finally {223+ await process.stop();224+ await removeWorkspaceTemp(artifacts);225+ }226+});227+228+Deno.test("post-banner exit rejects with retained diagnostics", async () => {229+ const artifacts = await workspaceTemp("golden-http-exit");230+ const port = reserveLocalPort();231+ const process = startReadyProcess({232+ executable: Deno.execPath(),233+ cwd: Deno.cwd(),234+ args: [235+ "eval",236+ `console.log("READY http://127.0.0.1:${port}/"); console.error("unique post-banner failure"); Deno.exit(42);`,237+ ],238+ environment: safeChildEnvironment({}),239+ artifactRoot: artifacts,240+ name: "post-banner-exit",241+ readyPattern: /READY (http:\/\/127\.0\.0\.1:\d+\/)/,242+ readyCheck: probeHttpReady,243+ timeoutMs: 5_000,244+ });245+ try {246+ let message = "";247+ try {248+ await process.ready;249+ } catch (error) {250+ message = error instanceof Error ? error.message : String(error);251+ }252+ assert(message.includes("exited with 42"), "readiness must report the process exit code");253+ assert(message.includes("unique post-banner failure"), "readiness must include stderr tail");254+ assert(message.includes(process.stderrPath), "readiness must point to retained stderr");255+ await process.completed;256+ } finally {257+ await process.stop();258+ await removeWorkspaceTemp(artifacts);259+ }260+});261+262+Deno.test("stopping a ready wrapper terminates its serving grandchild", async () => {263+ if (Deno.build.os === "windows") return;264+ const artifacts = await workspaceTemp("golden-process-tree");265+ const port = reserveLocalPort();266+ const url = `http://127.0.0.1:${port}/`;267+ const serverSource =268+ `Deno.serve({ hostname: "127.0.0.1", port: ${port} }, () => new Response("child")); await new Promise(() => {});`;269+ const wrapperSource = `const child = new Deno.Command(Deno.execPath(), { args: ["eval", ${270+ JSON.stringify(serverSource)271+ }], stdout: "inherit", stderr: "inherit" }).spawn(); console.log("READY ${url}"); await child.status;`;272+ const process = startReadyProcess({273+ executable: Deno.execPath(),274+ cwd: Deno.cwd(),275+ args: ["eval", wrapperSource],276+ environment: safeChildEnvironment({}),277+ artifactRoot: artifacts,278+ name: "wrapper-tree",279+ readyPattern: /READY (http:\/\/127\.0\.0\.1:\d+\/)/,280+ readyCheck: probeHttpReady,281+ timeoutMs: 5_000,282+ });283+ try {284+ await process.ready;285+ await process.stop();286+ const rebound = Deno.listen({ hostname: "127.0.0.1", port });287+ rebound.close();288+ } finally {289+ await process.stop().catch(() => undefined);290+ await removeWorkspaceTemp(artifacts);291+ }292+});293+195294 Deno.test("captured commands retain output and duration", async () => {196295 const artifacts = await workspaceTemp("golden-command");197296 try {