From 7258f43ec3a46054fb5182b4abed28689565d25d Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:54:45 +0800 Subject: [PATCH] test(workspace): preserve fixture failures and observe paged history Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../managed-agent-sessions.mjs | 241 +++++++++--------- .../managed-fixture-process.mjs | 65 +++++ .../managed-fixture-process.test.mjs | 77 ++++++ 3 files changed, 257 insertions(+), 126 deletions(-) create mode 100644 examples/personal-workspace-browser/managed-fixture-process.mjs create mode 100644 examples/personal-workspace-browser/managed-fixture-process.test.mjs diff --git a/examples/personal-workspace-browser/managed-agent-sessions.mjs b/examples/personal-workspace-browser/managed-agent-sessions.mjs index e5f9078d67..3d60a9c779 100644 --- a/examples/personal-workspace-browser/managed-agent-sessions.mjs +++ b/examples/personal-workspace-browser/managed-agent-sessions.mjs @@ -1,11 +1,10 @@ import assert from "node:assert/strict"; import {spawn} from "node:child_process"; -import {once} from "node:events"; -import {createInterface} from "node:readline"; import {resolve} from "node:path"; import {repoRoot, outputDir} from "./fixture.mjs"; import {openWorkspacePage} from "./scenario-context.mjs"; import {resolveTestPython} from "../../scripts/test-python.mjs"; +import {withManagedFixture} from "./managed-fixture-process.mjs"; async function verifyManagedTurnObservers({page, origin, sessions, panel, chooser, composer, control}) { const {session_id: sessionId} = await control("observer-session"); @@ -24,7 +23,8 @@ async function verifyManagedTurnObservers({page, origin, sessions, panel, choose async function afterHistoryPoll(turnId) { // Observe a real history refresh while the synthetic native Turn remains // active, then cross another five-second poll. No model timing decides it. - await page.waitForResponse(async response => response.url() === `${origin}/api/chat/sessions/${sessionId}` + await page.waitForResponse(async response => new URL(response.url()).origin === origin + && new URL(response.url()).pathname === `/api/chat/sessions/${sessionId}` && (await response.json()).session?.active_turn_id === turnId, {timeout: 12_000}); await page.waitForTimeout(5500); } @@ -89,131 +89,120 @@ export const managedAgentSessionsScenario = { async run({browser}) { const child = spawn(resolveTestPython({repoRoot}), ["-u", "examples/personal-workspace-browser/managed_agent_fixture.py"], {cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"]}); - const lines = createInterface({input: child.stdout})[Symbol.asyncIterator](); - let stderr = "", context; - child.stderr.on("data", chunk => {stderr += chunk;}); - async function next() { - let timer; + return withManagedFixture(child, async fixture => { + const {next, control} = fixture; + let context; try { - for (;;) { - const line = await Promise.race([lines.next(), new Promise((_, reject) => {timer = setTimeout(() => reject(new Error(stderr || "Managed fixture timed out")), 20_000);})]); - clearTimeout(timer); - if (line.done) throw new Error(stderr || "Managed fixture closed"); - if (line.value.startsWith("{")) return JSON.parse(line.value); - } - } finally {clearTimeout(timer);} - } - async function control(command) {child.stdin.write(`${command}\n`); return next();} - try { - const {origin, sessions, threads, legacy} = await next(); - assert.equal(new Set(threads).size, 3, "Each managed Session retains an independent original native thread"); - const url = `${origin}/chat/?goalId=product-release&view=conversation&workAgent=worker&statusUrl=%2Fstatus.json`; - context = await openWorkspacePage(browser, url, {gotoWaitUntil: "domcontentloaded", viewport: {width: 1440, height: 900}, - apiOptions: {fixturePort: new URL(origin).port}, beforeGoto: async (api, page) => { - api.transformStatus = fixture => { - const goal = fixture.run_history.goals.find(row => row.id === "product-release"); - goal.coordination = {registered_agents: ["worker", "peer"], thread_agent_bindings: []}; - }; - await page.route("**/api/chat/sessions**", route => route.continue()); - await page.route("**/api/chat/capabilities", route => route.continue()); - }}); - const {page} = context; - const panel = page.getByRole("region", {name: "Agent 工作会话", exact: true}); - const chooser = panel.getByRole("combobox", {name: "工作会话", exact: true}); - const composer = page.getByRole("textbox", {name: "向 LoopX 发送消息", exact: true}); - await chooser.selectOption(`managed:${sessions[0]}`); - await panel.getByText("Original managed question 1", {exact: true}).waitFor(); - await composer.waitFor({state: "visible"}); - assert.equal(await page.getByRole("textbox").filter({visible: true}).count(), 1); - assert.equal(await panel.getByText("Unmapped ordinary Goal history stays separate.", {exact: true}).count(), 0); - assert.ok(!(await control("inspect")).calls_after_setup.some(row => ["thread/start", "thread/resume", "turn/start"].includes(row.method)), "Browsing neither opens an adapter nor executes a model"); - await composer.fill("Retained draft for first Session"); - await chooser.selectOption(`managed:${sessions[1]}`); - await panel.getByText("Original managed question 2", {exact: true}).waitFor(); - assert.equal(await composer.inputValue(), ""); - assert.equal(await panel.getByText("Original managed question 1", {exact: true}).count(), 0); - await composer.fill("Retained draft for second Session"); - await page.reload({waitUntil: "domcontentloaded"}); - await panel.getByText("Original managed question 2", {exact: true}).waitFor(); - await page.waitForFunction(() => document.querySelector('[aria-label="向 LoopX 发送消息"]')?.value === "Retained draft for second Session"); - await chooser.selectOption(`managed:${sessions[0]}`); - await panel.getByText("Original managed question 1", {exact: true}).waitFor(); - assert.equal(await composer.inputValue(), "Retained draft for first Session"); - await chooser.selectOption(`managed:${sessions[1]}`); - await panel.getByText("Original managed question 2", {exact: true}).waitFor(); - // A late snapshot for a previously selected Session must not cross the - // exact route, replace the current transcript or overwrite its draft. - await control("append"); - let observed, release; - const started = new Promise(resolve => {observed = resolve;}); - const held = new Promise(resolve => {release = resolve;}); - const delay = async route => {const response = await route.fetch(); observed(); await held; await route.fulfill({response});}; - await page.route(`**/api/chat/sessions/${sessions[0]}`, delay); - await chooser.selectOption(`managed:${sessions[0]}`); - await Promise.race([started, new Promise((_, reject) => setTimeout(() => reject(new Error("Changed original Session snapshot was not requested")), 8000))]); - await chooser.selectOption(`managed:${sessions[1]}`); - await panel.getByText("Original managed question 2", {exact: true}).waitFor(); - release(); - await page.unroute(`**/api/chat/sessions/${sessions[0]}`, delay); - await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); - assert.equal(await panel.getByText("Original managed question 1", {exact: true}).count(), 0); - assert.equal(await composer.inputValue(), "Retained draft for second Session"); - await composer.fill("Owner correction only to second Session"); - await page.waitForFunction(() => !document.querySelector('button[aria-label="发送"]')?.disabled); - await composer.press("Enter"); - await panel.getByText("Runtime response.", {exact: true}).waitFor().catch(async error => { - console.log(JSON.stringify({diagnostic: await control("inspect"), body: await page.locator("body").innerText(), ui: await composer.evaluate(node => ({value: node.value, disabled: node.disabled, readOnly: node.readOnly, form: node.closest("form")?.outerHTML.slice(-1000)})), errors: context.errors, stderr})); - throw error; - }); - const accepted = await control("inspect"); - assert.equal(accepted.sessions, 4, "Continuing uses the original Session, without replacements"); - assert.ok(!accepted.messages[sessions[0]].some(row => row.text === "Owner correction only to second Session")); - assert.equal(accepted.messages[sessions[1]].filter(row => row.text === "Owner correction only to second Session").length, 1); - assert.equal(accepted.calls_after_setup.filter(row => row.method === "turn/start").length, 1); - assert.ok(!accepted.calls_after_setup.some(row => row.method === "thread/start")); - const scroller = page.locator(".personal-channel-scroll"); - await scroller.hover(); await page.mouse.wheel(0, -3000); - await chooser.waitFor({state: "visible"}); - await page.screenshot({path: resolve(outputDir, "managed-agent-desktop.png")}); - await page.setViewportSize({width: 390, height: 844}); - assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); - assert.equal(await panel.evaluate(node => node.scrollWidth > node.clientWidth), false); - await scroller.hover(); await page.mouse.wheel(0, -3000); - await chooser.waitFor({state: "visible"}); - await page.screenshot({path: resolve(outputDir, "managed-agent-narrow.png")}); - await scroller.hover(); await page.mouse.wheel(0, 600); - await page.waitForFunction(() => document.querySelector(".personal-channel-scroll").scrollTop > 0); - assert.notEqual(await scroller.evaluate(node => getComputedStyle(node).overflowY), "hidden", "Original managed conversation scrolls naturally"); - await composer.fill("Retained while original registration is unavailable"); - await page.waitForFunction(() => !document.querySelector('button[aria-label="发送"]')?.disabled); - await control("revoke"); - await page.waitForFunction(() => document.querySelector('button[aria-label="发送"]')?.disabled === true, null, {timeout: 12_000}); - assert.equal(await page.getByRole("button", {name: "发送", exact: true}).isDisabled(), true, "Revocation holds the original composer"); - assert.equal(await composer.inputValue(), "Retained while original registration is unavailable"); - const final = await control("inspect"); - assert.equal(final.calls_after_setup.filter(row => row.method === "turn/start").length, 1); - assert.ok(final.messages[sessions[0]].length === 3 && legacy); - await control("recover"); - const directory = await (await page.request.get(`${origin}/api/chat/sessions?goal_id=product-release&managed_agent_id=worker`)).json(); - for (const invalid of [{ok: true, sessions: [{session_id: legacy, agent_id: "codex"}]}, {...directory, ok: false}, {...directory, schema_version: "unknown"}]) { - const ignoredSelector = route => route.fulfill({json: invalid}); - await page.route("**/api/chat/sessions?*managed_agent_id=*", ignoredSelector); - await page.reload({waitUntil: "domcontentloaded"}); - await panel.getByRole("alert").waitFor(); + const {origin, sessions, threads, legacy} = await next(); + assert.equal(new Set(threads).size, 3, "Each managed Session retains an independent original native thread"); + const url = `${origin}/chat/?goalId=product-release&view=conversation&workAgent=worker&statusUrl=%2Fstatus.json`; + context = await openWorkspacePage(browser, url, {gotoWaitUntil: "domcontentloaded", viewport: {width: 1440, height: 900}, + apiOptions: {fixturePort: new URL(origin).port}, beforeGoto: async (api, page) => { + api.transformStatus = fixture => { + const goal = fixture.run_history.goals.find(row => row.id === "product-release"); + goal.coordination = {registered_agents: ["worker", "peer"], thread_agent_bindings: []}; + }; + await page.route("**/api/chat/sessions**", route => route.continue()); + await page.route("**/api/chat/capabilities", route => route.continue()); + }}); + const {page} = context; + const panel = page.getByRole("region", {name: "Agent 工作会话", exact: true}); + const chooser = panel.getByRole("combobox", {name: "工作会话", exact: true}); + const composer = page.getByRole("textbox", {name: "向 LoopX 发送消息", exact: true}); + await chooser.selectOption(`managed:${sessions[0]}`); + await panel.getByText("Original managed question 1", {exact: true}).waitFor(); + await composer.waitFor({state: "visible"}); + assert.equal(await page.getByRole("textbox").filter({visible: true}).count(), 1); assert.equal(await panel.getByText("Unmapped ordinary Goal history stays separate.", {exact: true}).count(), 0); - assert.equal(await page.getByRole("button", {name: "发送", exact: true}).isDisabled(), true); - assert.equal((await control("inspect")).calls_after_setup.filter(row => row.method === "turn/start").length, 1); - await page.unroute("**/api/chat/sessions?*managed_agent_id=*", ignoredSelector); + assert.ok(!(await control("inspect")).calls_after_setup.some(row => ["thread/start", "thread/resume", "turn/start"].includes(row.method)), "Browsing neither opens an adapter nor executes a model"); + await composer.fill("Retained draft for first Session"); + await chooser.selectOption(`managed:${sessions[1]}`); + await panel.getByText("Original managed question 2", {exact: true}).waitFor(); + assert.equal(await composer.inputValue(), ""); + assert.equal(await panel.getByText("Original managed question 1", {exact: true}).count(), 0); + await composer.fill("Retained draft for second Session"); + await page.reload({waitUntil: "domcontentloaded"}); + await panel.getByText("Original managed question 2", {exact: true}).waitFor(); + await page.waitForFunction(() => document.querySelector('[aria-label="向 LoopX 发送消息"]')?.value === "Retained draft for second Session"); + await chooser.selectOption(`managed:${sessions[0]}`); + await panel.getByText("Original managed question 1", {exact: true}).waitFor(); + assert.equal(await composer.inputValue(), "Retained draft for first Session"); + await chooser.selectOption(`managed:${sessions[1]}`); + await panel.getByText("Original managed question 2", {exact: true}).waitFor(); + // A late snapshot for a previously selected Session must not cross the + // exact route, replace the current transcript or overwrite its draft. + await control("append"); + let observed, release; + const started = new Promise(resolve => {observed = resolve;}); + const held = new Promise(resolve => {release = resolve;}); + const delay = async route => {const response = await route.fetch(); observed(); await held; await route.fulfill({response});}; + // Production history hydration adds pagination query parameters. Delay + // this exact original Session's snapshot, with or without those cursors. + const delayedSnapshot = url => url.origin === origin && url.pathname === `/api/chat/sessions/${sessions[0]}`; + await page.route(delayedSnapshot, delay); + await chooser.selectOption(`managed:${sessions[0]}`); + await Promise.race([started, new Promise((_, reject) => setTimeout(() => reject(new Error("Changed original Session snapshot was not requested")), 8000))]); + await chooser.selectOption(`managed:${sessions[1]}`); + await panel.getByText("Original managed question 2", {exact: true}).waitFor(); + release(); + await page.unroute(delayedSnapshot, delay); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + assert.equal(await panel.getByText("Original managed question 1", {exact: true}).count(), 0); + assert.equal(await composer.inputValue(), "Retained draft for second Session"); + await composer.fill("Owner correction only to second Session"); + await page.waitForFunction(() => !document.querySelector('button[aria-label="发送"]')?.disabled); + await composer.press("Enter"); + await panel.getByText("Runtime response.", {exact: true}).waitFor().catch(async error => { + console.log(JSON.stringify({diagnostic: await control("inspect"), body: await page.locator("body").innerText(), ui: await composer.evaluate(node => ({value: node.value, disabled: node.disabled, readOnly: node.readOnly, form: node.closest("form")?.outerHTML.slice(-1000)})), errors: context.errors, stderr: fixture.stderr})); + throw error; + }); + const accepted = await control("inspect"); + assert.equal(accepted.sessions, 4, "Continuing uses the original Session, without replacements"); + assert.ok(!accepted.messages[sessions[0]].some(row => row.text === "Owner correction only to second Session")); + assert.equal(accepted.messages[sessions[1]].filter(row => row.text === "Owner correction only to second Session").length, 1); + assert.equal(accepted.calls_after_setup.filter(row => row.method === "turn/start").length, 1); + assert.ok(!accepted.calls_after_setup.some(row => row.method === "thread/start")); + const scroller = page.locator(".personal-channel-scroll"); + await scroller.hover(); await page.mouse.wheel(0, -3000); + await chooser.waitFor({state: "visible"}); + await page.screenshot({path: resolve(outputDir, "managed-agent-desktop.png")}); + await page.setViewportSize({width: 390, height: 844}); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); + assert.equal(await panel.evaluate(node => node.scrollWidth > node.clientWidth), false); + await scroller.hover(); await page.mouse.wheel(0, -3000); + await chooser.waitFor({state: "visible"}); + await page.screenshot({path: resolve(outputDir, "managed-agent-narrow.png")}); + await scroller.hover(); await page.mouse.wheel(0, 600); + await page.waitForFunction(() => document.querySelector(".personal-channel-scroll").scrollTop > 0); + assert.notEqual(await scroller.evaluate(node => getComputedStyle(node).overflowY), "hidden", "Original managed conversation scrolls naturally"); + await composer.fill("Retained while original registration is unavailable"); + await page.waitForFunction(() => !document.querySelector('button[aria-label="发送"]')?.disabled); + await control("revoke"); + await page.waitForFunction(() => document.querySelector('button[aria-label="发送"]')?.disabled === true, null, {timeout: 12_000}); + assert.equal(await page.getByRole("button", {name: "发送", exact: true}).isDisabled(), true, "Revocation holds the original composer"); + assert.equal(await composer.inputValue(), "Retained while original registration is unavailable"); + const final = await control("inspect"); + assert.equal(final.calls_after_setup.filter(row => row.method === "turn/start").length, 1); + assert.ok(final.messages[sessions[0]].length === 3 && legacy); + await control("recover"); + const directory = await (await page.request.get(`${origin}/api/chat/sessions?goal_id=product-release&managed_agent_id=worker`)).json(); + for (const invalid of [{ok: true, sessions: [{session_id: legacy, agent_id: "codex"}]}, {...directory, ok: false}, {...directory, schema_version: "unknown"}]) { + const ignoredSelector = route => route.fulfill({json: invalid}); + await page.route("**/api/chat/sessions?*managed_agent_id=*", ignoredSelector); + await page.reload({waitUntil: "domcontentloaded"}); + await panel.getByRole("alert").waitFor(); + assert.equal(await panel.getByText("Unmapped ordinary Goal history stays separate.", {exact: true}).count(), 0); + assert.equal(await page.getByRole("button", {name: "发送", exact: true}).isDisabled(), true); + assert.equal((await control("inspect")).calls_after_setup.filter(row => row.method === "turn/start").length, 1); + await page.unroute("**/api/chat/sessions?*managed_agent_id=*", ignoredSelector); + } + const errorsBeforeObservation = context.errors.length; + await verifyManagedTurnObservers({page, origin, sessions, panel, chooser, composer, control}); + assert.deepEqual(context.errors.slice(errorsBeforeObservation), [], "Turn observation adds no browser errors after source recovery"); + return {coverageEntries: await context.close(), note: "Real SQLite/Core/Chat and synthetic native provider: exact managed Session discovery, original-thread continuation, scope-specific drafts and refresh, no-model browsing and revocation; one live observer and pending reply across real history polling, external-Turn recovery and exact interruption."}; + } finally { + await context?.close().catch(() => {}); } - const errorsBeforeObservation = context.errors.length; - await verifyManagedTurnObservers({page, origin, sessions, panel, chooser, composer, control}); - assert.deepEqual(context.errors.slice(errorsBeforeObservation), [], "Turn observation adds no browser errors after source recovery"); - return {coverageEntries: await context.close(), note: "Real SQLite/Core/Chat and synthetic native provider: exact managed Session discovery, original-thread continuation, scope-specific drafts and refresh, no-model browsing and revocation; one live observer and pending reply across real history polling, external-Turn recovery and exact interruption."}; - } finally { - await context?.close().catch(() => {}); - child.stdin.end("stop\n"); - await Promise.race([once(child, "exit"), new Promise(resolve => setTimeout(resolve, 2000))]); - if (child.exitCode === null) child.kill("SIGTERM"); - } + }); }, }; diff --git a/examples/personal-workspace-browser/managed-fixture-process.mjs b/examples/personal-workspace-browser/managed-fixture-process.mjs new file mode 100644 index 0000000000..d16500663c --- /dev/null +++ b/examples/personal-workspace-browser/managed-fixture-process.mjs @@ -0,0 +1,65 @@ +import {createInterface} from "node:readline"; + +// Own the fixture's pipes until close, including when startup or the browser +// fails. A cleanup write must never replace the original scenario failure. +export async function withManagedFixture(child, action, {readTimeoutMs = 20_000, stopTimeoutMs = 2000} = {}) { + let stderr = "", processError, pipeError, actionFailed = false; + let failed; + const failure = new Promise(resolve => {failed = resolve;}); + const closed = new Promise(resolve => child.once("close", resolve)); + child.on("error", error => {processError = error; failed(error);}); + child.stdin.on("error", error => {pipeError = error; failed(error);}); + child.stderr.on("data", chunk => {stderr += chunk;}); + const lines = createInterface({input: child.stdout}); + const iterator = lines[Symbol.asyncIterator](); + const diagnostic = (message, cause) => new Error( + `${message} (exit=${child.exitCode}, signal=${child.signalCode})${stderr ? `:\n${stderr}` : ""}`, + {cause: cause ?? processError ?? pipeError}, + ); + async function next() { + let timer; + try { + return await Promise.race([ + (async () => { + for (;;) { + const line = await iterator.next(); + if (line.done) {await closed; throw diagnostic("Managed fixture closed");} + if (line.value.startsWith("{")) return JSON.parse(line.value); + } + })(), + failure.then(error => {throw diagnostic("Managed fixture transport failed", error);}), + new Promise((_, reject) => {timer = setTimeout(() => reject(diagnostic("Managed fixture timed out")), readTimeoutMs);}), + ]); + } finally {clearTimeout(timer);} + } + async function control(command) { + try { + await new Promise((resolve, reject) => child.stdin.write(`${command}\n`, error => error ? reject(error) : resolve())); + } catch (error) {throw diagnostic("Managed fixture control failed", error);} + return next(); + } + async function waitForClose() { + let timer; + try { + return await Promise.race([closed.then(() => true), new Promise(resolve => {timer = setTimeout(() => resolve(false), stopTimeoutMs);})]); + } finally {clearTimeout(timer);} + } + try { + return await action({next, control, get stderr() {return stderr;}}); + } catch (error) {actionFailed = true; throw error;} + finally { + // Register close/error observers before any write; exit may race shutdown. + if (child.exitCode === null && child.signalCode === null && !processError) { + if (!child.stdin.destroyed && !child.stdin.writableEnded) child.stdin.end("stop\n"); + if (!(await waitForClose())) { + child.kill("SIGTERM"); + if (!(await waitForClose())) child.kill("SIGKILL"); + } + } + await closed; + lines.close(); + if (!actionFailed && (processError || pipeError || child.exitCode !== 0)) { + throw diagnostic("Managed fixture shutdown failed"); + } + } +} diff --git a/examples/personal-workspace-browser/managed-fixture-process.test.mjs b/examples/personal-workspace-browser/managed-fixture-process.test.mjs new file mode 100644 index 0000000000..5e325fe159 --- /dev/null +++ b/examples/personal-workspace-browser/managed-fixture-process.test.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import {spawn} from "node:child_process"; +import {once} from "node:events"; +import test from "node:test"; +import {withManagedFixture} from "./managed-fixture-process.mjs"; + +const child = source => spawn(process.execPath, ["--input-type=module", "-e", source], {stdio: ["pipe", "pipe", "pipe"]}); +const options = {readTimeoutMs: 3000, stopTimeoutMs: 100}; + +test("normal JSON control and stop close the real child and drain diagnostics", async () => { + const process = child(` + import {createInterface} from "node:readline"; + console.log(JSON.stringify({ready: true})); + for await (const command of createInterface({input: process.stdin})) { + if (command === "stop") {process.stderr.write("normal teardown\\n"); break;} + console.log(JSON.stringify({command})); + } + `); + const value = await withManagedFixture(process, async fixture => { + assert.deepEqual(await fixture.next(), {ready: true}); + assert.deepEqual(await fixture.control("inspect"), {command: "inspect"}); + return "complete"; + }, options); + assert.equal(value, "complete"); + assert.equal(process.exitCode, 0); + assert(process.stdout.destroyed && process.stderr.destroyed); +}); + +test("startup exit retains the original stderr and exit code", async () => { + const process = child('process.stderr.write("original startup failure: 缺少资源\\n"); process.exitCode = 17;'); + await assert.rejects(withManagedFixture(process, fixture => fixture.next(), options), error => { + assert.match(error.message, /exit=17/); + assert.match(error.message, /original startup failure: 缺少资源/); + assert.doesNotMatch(error.message, /shutdown failed/); + return true; + }); + assert.equal(process.exitCode, 17); +}); + +test("a browser failure survives a closed-pipe shutdown race", async () => { + const process = child('console.log(JSON.stringify({ready: true}));'); + const original = new Error("original browser assertion"); + await assert.rejects(withManagedFixture(process, async fixture => { + await fixture.next(); + if (process.exitCode === null) await once(process, "exit"); + throw original; + }, options), error => error === original); + assert.equal(process.exitCode, 0); +}); + +test("a broken control pipe rejects instead of crashing or waiting for read timeout", async () => { + const process = child('import {closeSync} from "node:fs"; closeSync(0); console.log(JSON.stringify({ready: true})); setInterval(() => {}, 1000);'); + await assert.rejects(withManagedFixture(process, async fixture => { + await fixture.next(); + await fixture.control("inspect"); + }, options), /Managed fixture (control|transport) failed/); + assert.notEqual(process.signalCode, null, "Cleanup terminates the real child after failed control"); +}); + +test("spawn failure rejects with its cause and still closes the child", async () => { + const process = spawn("loopx-missing-synthetic-fixture-executable", [], {stdio: ["pipe", "pipe", "pipe"]}); + await assert.rejects(withManagedFixture(process, fixture => fixture.next(), options), error => { + assert.equal(error.cause.code, "ENOENT"); + return true; + }); + assert(process.stdout.destroyed && process.stderr.destroyed); +}); + +test("an unresponsive fixture is reaped without replacing the scenario failure", async () => { + const process = child('process.on("SIGTERM", () => {}); console.log(JSON.stringify({ready: true})); setInterval(() => {}, 1000);'); + const original = new Error("original failure before teardown"); + await assert.rejects(withManagedFixture(process, async fixture => { + await fixture.next(); + throw original; + }, options), error => error === original); + assert.equal(process.signalCode, "SIGKILL"); +});