mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
test(desktop): split the work-block suite and prune the conversation harness
`AgentSessionWorkBlock.test.mjs` landed at 1,146 lines. Under today's rule tables that passes, but only because the desktop ratchet's script roots allowlist `.ts`/`.tsx` and silently skip `.mjs` — the gap #6736 (`ce1f1d427`) closes. Measured rather than assumed: with that rule table cherry-picked, this file is the one remaining violation on C, and because `allowedLineCount` grandfathers a base that already exceeds the max, whatever count C lands with becomes the file's permanent ceiling on main. 1,146 is not a ceiling worth inheriting for a file that exists to cover a single component. Split on the seam the file already had, at `// -- Orphaned work --`, where the subject changes from live-vs-finished *policy* to what an individual *row* is: - `AgentSessionWorkBlockTestRig.mjs` (311) — jsdom lifecycle, the item fixtures, `settle()`, `renderBlock()`. - `AgentSessionWorkBlock.test.mjs` (352) — Live, Finished, fold animation, reader choice, rail glyph states. - `AgentSessionWorkBlock.orphaned.test.mjs` (528) — orphaned work, the per-kind rail presentation, streaming re-render cost. One rig, not a copy per file. The two suites run in separate processes (node's runner is one process per file), so a second copy of the jsdom and `matchMedia` setup could not *collide* — it would drift, and a drifted ambient pin fails a fixture for a reason that has nothing to do with the markup under test. That trap already cost this suite family two commits (`2b7b0baf6`, `c91819706`). The split forces one real behavioural change. `prefersReducedMotion` was a module-level `let` the reduced-motion test assigned directly; ESM bindings are read-only in importers, so once it lives in the rig that assignment cannot work. It goes through `setPrefersReducedMotion(value)`, with the rig's `afterEach` still resetting it. Proved non-vacuous by stubbing the setter to a no-op: that fails exactly one test — the reduced-motion one — and restoring it passes. Without that check the test would have been asserting against a flag nothing sets, which is precisely the way a split can silently disarm a test. Behaviour is otherwise preserved, checked rather than assumed: the 30 test titles across the two files are an exact set match with the 30 in the single file, and every body is byte-identical apart from the setter call. Harness prune, in the same revision because it is the same debt: with the four `<details>` disclosure tests gone, `export const domWindow = dom.window` had no consumer anywhere in `desktop/src` — it existed only for `new domWindow.Event("toggle")`. Dropping just the `export` leaves an unused local that Biome flags (`lint/correctness/noUnusedVariables`), so the declaration and its now-false doc comment both go (576 -> 571). `TRIGGER_TITLE` only loses its `export`; it is still used inside the harness. Ratchet with #6736's rules cherry-picked, run in the same shell as `git rev-parse HEAD`: - base `fd2e01799` (the current PR base, which is what CI resolves — `resolveBaseRef` returns `HEAD^1` under `GITHUB_ACTIONS`): exit 0. - base `merge-base(origin/main)` = `db5617dd1`, which is what it resolves to locally and would resolve to if C retargets: exit 0. - negative control at the pre-split tree, same rules, same base: exit 1, `AgentSessionWorkBlock.test.mjs: new -> 1146 lines (allowed 1000)`. The gate is measuring the thing this commit fixes. Both suites green together, 30/30. `conversation` + `conversationChrome` still green. `tsc --noEmit` clean, `pnpm check` findings identical to main's baseline. The split shape, the seam and the reduced-motion proof are dev-01's, handed over as a verified patch (`OUTBOX/C_WORKBLOCK_SPLIT_HARNESS_PRUNE.patch`, sha256 6cfbccc9…) rather than as a commit in this checkout. I read the seam, reconstructed the tree from `fe57b7ac7` + that patch to confirm it is exactly what was proposed, and re-ran the title/body comparison, the setter mutant, both ratchet bases and the negative control here. Co-authored-by: Bradley Axen <baxen@squareup.com> Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
@@ -191,7 +191,7 @@ export const AUTHOR_TRUNCATED = `${AUTHOR.slice(0, 8)}…${AUTHOR.slice(-4)}`;
|
||||
* (`agentSessionTranscriptHelpers.ts` `parsePromptText`). The author row must
|
||||
* never display this as a name.
|
||||
*/
|
||||
export const TRIGGER_TITLE = "@Mention";
|
||||
const TRIGGER_TITLE = "@Mention";
|
||||
export const AUTHOR_PROFILES = {
|
||||
[AUTHOR]: {
|
||||
displayName: "Ada Lovelace",
|
||||
@@ -569,8 +569,3 @@ afterEach(() => {
|
||||
resetActiveAgentTurnsStore?.();
|
||||
});
|
||||
after(() => dom.window.close());
|
||||
/**
|
||||
* The jsdom window itself. Exported for the few tests that must construct a
|
||||
* real DOM event (`new domWindow.Event("toggle")`) to simulate a browser echo.
|
||||
*/
|
||||
export const domWindow = dom.window;
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* Work-block behaviour for work that was abandoned mid-flight, the rail's
|
||||
* per-kind presentation, and the re-render cost a streaming block must not pay.
|
||||
* The live/finished contracts live in `AgentSessionWorkBlock.test.mjs`; the
|
||||
* shared rig is `AgentSessionWorkBlockTestRig.mjs`.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
noteStep,
|
||||
relayStep,
|
||||
renderBlock,
|
||||
step,
|
||||
thoughtStep,
|
||||
} from "./AgentSessionWorkBlockTestRig.mjs";
|
||||
|
||||
// ── Orphaned work ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reopened history must not present as work in progress.
|
||||
*
|
||||
* A tool's `executing` status is written when the step starts and never revised
|
||||
* if the agent dies first, so an abandoned step keeps it forever. In this block
|
||||
* that status is not a per-row detail — one `running` entry makes the whole
|
||||
* block active, which suppresses the folded summary line, keeps the rail
|
||||
* expanded and pulses a bullet. Scrolling back to a crashed turn therefore
|
||||
* showed the reader live work indefinitely (Codex P2 on #6536).
|
||||
*/
|
||||
test("a block whose running step has no live session folds like finished work", async () => {
|
||||
const view = await renderBlock(
|
||||
[step("a"), step("b", { status: "executing", completedAt: null })],
|
||||
{ liveTurnId: null, streamingItemId: null },
|
||||
);
|
||||
|
||||
const summary = view.summary();
|
||||
assert.ok(
|
||||
summary,
|
||||
"history gets its folded summary line — the orphaned status must not suppress it",
|
||||
);
|
||||
assert.match(
|
||||
summary.textContent,
|
||||
/2 steps$/,
|
||||
"an abandoned step is not known to have failed, so the count stays neutral",
|
||||
);
|
||||
assert.equal(await view.settleToStepCount(0), 0, "the rail folds away");
|
||||
assert.equal(
|
||||
view.qa(".animate-pulse").length,
|
||||
0,
|
||||
"nothing pulses when nothing is running",
|
||||
);
|
||||
});
|
||||
|
||||
test("the same items still hold the block open while the turn is live", async () => {
|
||||
// The paired half: this is what makes the gate meaningful rather than a
|
||||
// blanket "never trust executing". Identical items, live turn.
|
||||
const view = await renderBlock(
|
||||
[step("a"), step("b", { status: "executing", completedAt: null })],
|
||||
{ liveTurnId: "turn-1", streamingItemId: null },
|
||||
);
|
||||
|
||||
assert.equal(view.summary(), null, "a live block has no folded line");
|
||||
assert.equal(view.stepCount(), 2, "the rail stays open");
|
||||
assert.deepEqual(view.glyphStates(), ["settled", "running"]);
|
||||
assert.equal(view.qa(".animate-pulse").length, 1, "live work pulses");
|
||||
});
|
||||
|
||||
test("an agent live on a later turn does not resurrect an earlier turn's abandoned step", async () => {
|
||||
// The reason the signal is a turn id and not a boolean: a restarted agent is
|
||||
// live, but not on this turn, and a global flag would keep this step spinning.
|
||||
const view = await renderBlock(
|
||||
[step("a"), step("b", { status: "executing", completedAt: null })],
|
||||
{ liveTurnId: "turn-2", streamingItemId: null },
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
view.summary(),
|
||||
"this turn is history even though the agent is busy",
|
||||
);
|
||||
assert.equal(await view.settleToStepCount(0), 0);
|
||||
assert.equal(view.qa(".animate-pulse").length, 0);
|
||||
});
|
||||
|
||||
test("a block live when the session ends folds instead of spinning forever", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
// The bug as the reader meets it live: the agent dies mid-step, so the item
|
||||
// never reaches a terminal status and the only thing that changes is that no
|
||||
// turn is live any more.
|
||||
const live = [
|
||||
step("a"),
|
||||
step("b", { status: "executing", completedAt: null }),
|
||||
];
|
||||
const view = await renderBlock(live, {
|
||||
liveTurnId: "turn-1",
|
||||
streamingItemId: "b",
|
||||
});
|
||||
assert.equal(view.stepCount(), 2, "live: the rail is open");
|
||||
assert.equal(view.summary(), null);
|
||||
|
||||
// Session gone. The items are BYTE-IDENTICAL — only liveness changed.
|
||||
await act(async () => {
|
||||
view.stream(live, null, null);
|
||||
});
|
||||
|
||||
assert.ok(view.summary(), "the block settles when its session goes away");
|
||||
assert.match(view.summary().textContent, /2 steps$/);
|
||||
assert.equal(await view.settleToStepCount(0), 0);
|
||||
assert.equal(view.qa(".animate-pulse").length, 0);
|
||||
});
|
||||
|
||||
test("an orphaned step keeps its own row detail rather than gaining an interrupted marker", async () => {
|
||||
// Deliberate: we do not know what happened to the step, so it renders as the
|
||||
// neutral step it is with whatever it recorded. A visible "interrupted"
|
||||
// treatment would be a design addition, not part of this fix.
|
||||
const view = await renderBlock(
|
||||
[step("a"), step("b", { status: "executing", completedAt: null })],
|
||||
{ liveTurnId: null, streamingItemId: null },
|
||||
);
|
||||
await view.expand();
|
||||
await view.settleToStepCount(2);
|
||||
|
||||
assert.deepEqual(
|
||||
view.glyphStates(),
|
||||
["settled", "settled"],
|
||||
"no third state was invented for an abandoned step",
|
||||
);
|
||||
assert.equal(
|
||||
view.qa('[data-testid="transcript-tool-item"]').length,
|
||||
2,
|
||||
"both steps still render through the normal tool presenter",
|
||||
);
|
||||
});
|
||||
|
||||
test("the rail bullet masks the spine with the drawer surface colour", async () => {
|
||||
// The bullet has to mask the spine passing behind it, and the mask must match
|
||||
// the surface the transcript is drawn on. A mask in any other colour shows as
|
||||
// a disc of the wrong shade around every bullet (berd's BOT-1599).
|
||||
const view = await renderBlock([step("a"), step("b")]);
|
||||
await view.expand();
|
||||
const bullet = view.q("[data-step-state]");
|
||||
assert.match(bullet.className, /\bbg-background\b/);
|
||||
assert.match(bullet.className, /\bring-background\b/);
|
||||
assert.match(bullet.className, /\brounded-full\b/);
|
||||
});
|
||||
|
||||
test("the spine is drawn for every step except the last", async () => {
|
||||
const view = await renderBlock([step("a"), step("b"), step("c")]);
|
||||
await view.expand();
|
||||
const spines = view.qa(".w-px");
|
||||
assert.equal(
|
||||
spines.length,
|
||||
2,
|
||||
"three steps means two connecting segments; a trailing spine would dangle",
|
||||
);
|
||||
});
|
||||
|
||||
test("thinking renders as a rail row with its own glyph, not a nested disclosure", async () => {
|
||||
const view = await renderBlock([thoughtStep("thought:1"), step("a")]);
|
||||
await view.expand();
|
||||
const thought = view.q('[data-testid="transcript-work-block-thought"]');
|
||||
assert.ok(thought, "reasoning renders on the rail");
|
||||
assert.match(thought.textContent, /weighing the options/);
|
||||
assert.equal(
|
||||
view.q('[data-testid="transcript-thought-disclosure"]'),
|
||||
null,
|
||||
"the block is already one disclosure — a thought must not add a second",
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* An interim note is progress, not a second reply.
|
||||
*
|
||||
* #6720 gives every conversation-variant assistant message a 20px avatar + name
|
||||
* identity row, which is right for the turn's answer. A rail note is the same
|
||||
* item type, so routing it through that presenter would render a fully
|
||||
* attributed agent turn nested inside a muted step row — the reader would see
|
||||
* the agent apparently reply twice, once inside the work it was doing. berd
|
||||
* draws the same line: its `progress` entry is a plain rail row.
|
||||
*
|
||||
* The suppression is done on this side (a dedicated prose body) rather than by
|
||||
* reaching into the message presenter, so #6720 keeps one rule for what a
|
||||
* message looks like.
|
||||
*/
|
||||
test("an interim note renders as rail prose with no identity row", async () => {
|
||||
const view = await renderBlock([
|
||||
step("a"),
|
||||
noteStep("msg:interim", "checked the three call sites"),
|
||||
]);
|
||||
await view.expand();
|
||||
|
||||
const note = view.q('[data-testid="transcript-work-block-note"]');
|
||||
assert.ok(note, "the note renders on the rail");
|
||||
assert.match(note.textContent, /checked the three call sites/);
|
||||
|
||||
assert.ok(
|
||||
view.q('[data-testid="transcript-assistant-identity"]') === null,
|
||||
"an avatar + name row inside a muted step reads as a second reply",
|
||||
);
|
||||
assert.ok(
|
||||
view.q('[data-testid="transcript-assistant-message"]') === null,
|
||||
"the note must not go through the message presenter at all",
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A relay post is a step, not a reply — the same rule as an interim note,
|
||||
* reached by a different route.
|
||||
*
|
||||
* A note is an assistant *message* the block re-presents as prose. A relay post
|
||||
* is a *tool call* that classifies as `renderClass: "message"`, so it renders
|
||||
* through `CompactMessageSummary`: 28px avatar, bordered speech bubble,
|
||||
* timestamp, delivery-receipt button. That is right in the activity feed, where
|
||||
* a posted message is a destination to open; on the rail it makes the agent
|
||||
* appear to reply in the middle of its own work — and it did, in the seeded
|
||||
* browser preview, which is where this was caught.
|
||||
*
|
||||
* Suppressing it needs the presentation signal rather than the transcript
|
||||
* variant: the same relay step OUTSIDE a block in this variant keeps its
|
||||
* bubble, which the next test pins.
|
||||
*/
|
||||
test("a relay post on the rail is a plain step, with no bubble or avatar", async () => {
|
||||
const view = await renderBlock([step("a"), relayStep("relay:1")]);
|
||||
await view.expand();
|
||||
|
||||
assert.equal(
|
||||
view.stepCount(),
|
||||
2,
|
||||
"the relay post takes its own rail row, like any other step",
|
||||
);
|
||||
assert.equal(
|
||||
view.qa('[data-work-block-entry="tool"]').length,
|
||||
2,
|
||||
"a relay post is a tool step — it is something the agent did",
|
||||
);
|
||||
assert.equal(
|
||||
view.q('[data-testid="transcript-tool-message-preview"]'),
|
||||
null,
|
||||
"a speech bubble inside a muted step reads as the agent replying mid-work",
|
||||
);
|
||||
assert.equal(
|
||||
view.q('[data-testid="transcript-agent-sent-avatar"]'),
|
||||
null,
|
||||
"no identity avatar on the rail",
|
||||
);
|
||||
assert.equal(
|
||||
view.q('[data-testid="transcript-sent-message-context-button"]'),
|
||||
null,
|
||||
"no delivery receipt on the rail",
|
||||
);
|
||||
|
||||
// It is still a real, expandable tool row carrying its command.
|
||||
const rows = view.qa('[data-testid="transcript-tool-item"]');
|
||||
assert.equal(rows.length, 2, "both steps render as tool rows");
|
||||
assert.ok(
|
||||
rows[1].querySelector("details"),
|
||||
"the relay step keeps the ordinary step disclosure so its args stay reachable",
|
||||
);
|
||||
assert.match(
|
||||
rows[1].textContent,
|
||||
/Sent|posted the findings/,
|
||||
"the row still says what the step was",
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The other half of the branch: outside a block the bubble is correct and must
|
||||
* survive. Without this, suppressing the bubble everywhere in the conversation
|
||||
* variant would pass the test above.
|
||||
*/
|
||||
test("the same relay post outside a work block keeps its message bubble", async () => {
|
||||
const { createElement } = await import("react");
|
||||
const { render } = await import("@testing-library/react");
|
||||
const { QueryClient, QueryClientProvider } = await import(
|
||||
"@tanstack/react-query"
|
||||
);
|
||||
const { createMemoryHistory, createRootRoute, createRouter, RouterProvider } =
|
||||
await import("@tanstack/react-router");
|
||||
const { AgentSessionTranscriptVariantProvider } = await import(
|
||||
"./agentSessionTranscriptContext.ts"
|
||||
);
|
||||
const { TranscriptActivityItem } = await import(
|
||||
"./activityRenderClasses/TranscriptActivityItem.tsx"
|
||||
);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const rootRoute = createRootRoute({
|
||||
component: () =>
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
createElement(
|
||||
AgentSessionTranscriptVariantProvider,
|
||||
{ value: "conversation" },
|
||||
createElement(TranscriptActivityItem, {
|
||||
agentAvatarUrl: null,
|
||||
agentName: "Agent",
|
||||
agentPubkey: "pk",
|
||||
item: relayStep("relay:1"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory({ initialEntries: ["/"] }),
|
||||
routeTree: rootRoute,
|
||||
});
|
||||
await router.load();
|
||||
const view = render(createElement(RouterProvider, { router }));
|
||||
|
||||
assert.ok(
|
||||
view.container.querySelector(
|
||||
'[data-testid="transcript-tool-message-preview"]',
|
||||
),
|
||||
"outside a block a posted message is a destination the reader can open — the bubble stays",
|
||||
);
|
||||
});
|
||||
|
||||
test("the rail glyph is chosen by kind, and prose kinds share the speech bubble", async () => {
|
||||
// The exhaustive switch is the point: a note that fell through to the tool
|
||||
// branch would wear a wrench and read as something the agent ran.
|
||||
const view = await renderBlock([
|
||||
thoughtStep("thought:1"),
|
||||
noteStep("msg:interim"),
|
||||
step("a"),
|
||||
step("b", { isError: true, status: "failed" }),
|
||||
]);
|
||||
await view.expand();
|
||||
|
||||
const glyphClass = (kind, index = 0) => {
|
||||
const rows = view.qa(`[data-work-block-entry="${kind}"]`);
|
||||
const icon = rows[index].querySelector("svg");
|
||||
return icon.getAttribute("class") ?? "";
|
||||
};
|
||||
|
||||
// lucide stamps each icon with a `lucide-<kebab-name>` class, so the glyph
|
||||
// identity is readable from the DOM without reaching into the icon modules.
|
||||
assert.match(glyphClass("thought"), /lucide-message-circle/);
|
||||
assert.match(
|
||||
glyphClass("note"),
|
||||
/lucide-message-circle/,
|
||||
"prose is the agent talking, whether it is reasoning or a note",
|
||||
);
|
||||
assert.match(glyphClass("tool", 0), /lucide-wrench/);
|
||||
assert.match(
|
||||
glyphClass("tool", 1),
|
||||
/lucide-circle/,
|
||||
"a failed step is a filled dot, not a wrench",
|
||||
);
|
||||
});
|
||||
|
||||
test("the rail bullet is never red, whatever the step's outcome", async () => {
|
||||
// A failure is carried by glyph shape and by the folded line's count. Tinting
|
||||
// the bullet would make one bad step read as an alarm across the whole run.
|
||||
//
|
||||
// The running step keeps this block live, so the rail is already open — which
|
||||
// is also the only state in which a running bullet can be observed at all.
|
||||
const view = await renderBlock(
|
||||
[
|
||||
step("a"),
|
||||
step("b", { isError: true, status: "failed" }),
|
||||
step("c", { status: "executing", completedAt: null }),
|
||||
],
|
||||
{ streamingItemId: "c" },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
view.glyphStates(),
|
||||
["settled", "failed", "running"],
|
||||
"all three outcomes are on screen",
|
||||
);
|
||||
for (const bullet of view.qa("[data-step-state]")) {
|
||||
assert.ok(
|
||||
!/\b(text|bg|ring)-(destructive|red)/.test(bullet.className),
|
||||
`rail bullet for ${bullet.getAttribute("data-step-state")} must stay muted`,
|
||||
);
|
||||
assert.match(bullet.className, /\btext-muted-foreground\b/);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* berd brightens rail prose with `usePrimaryText={open}`. Here the brightening
|
||||
* is unconditional, and this test records why that is not a divergence: a closed
|
||||
* block unmounts its rows rather than dimming them, so there is no state in
|
||||
* which rail prose is on screen and NOT in an open block. A `primaryText` prop
|
||||
* would have an unreachable false branch.
|
||||
*/
|
||||
test("rail prose is primary text, and a closed block has no prose on screen at all", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const live = [
|
||||
thoughtStep("thought:1"),
|
||||
noteStep("msg:interim"),
|
||||
step("b", { status: "executing", completedAt: null }),
|
||||
];
|
||||
const view = await renderBlock(live, { streamingItemId: "b" });
|
||||
|
||||
const prose = () =>
|
||||
view.qa(
|
||||
'[data-testid="transcript-work-block-thought"],[data-testid="transcript-work-block-note"]',
|
||||
);
|
||||
|
||||
assert.equal(prose().length, 2, "both prose rows are on the live rail");
|
||||
for (const node of prose()) {
|
||||
assert.match(
|
||||
node.className,
|
||||
/\btext-foreground\b/,
|
||||
"prose the reader can see is primary, not muted",
|
||||
);
|
||||
assert.ok(
|
||||
!/\btext-muted-foreground\b/.test(node.className),
|
||||
"the row must not carry both colours",
|
||||
);
|
||||
}
|
||||
|
||||
// Finish the turn: the block folds and takes its prose with it.
|
||||
await act(async () => {
|
||||
view.stream([thoughtStep("thought:1"), noteStep("msg:interim"), step("b")]);
|
||||
});
|
||||
assert.equal(await view.settleToStepCount(0), 0, "it folded");
|
||||
assert.equal(
|
||||
prose().length,
|
||||
0,
|
||||
"a folded block renders no prose, so there is no dimmed state to test",
|
||||
);
|
||||
|
||||
// And the reader reopening it brings the same primary prose back.
|
||||
await act(async () => {
|
||||
view.summary().click();
|
||||
});
|
||||
assert.equal(await view.settleToStepCount(3), 3, "the reader reopened it");
|
||||
assert.equal(prose().length, 2);
|
||||
for (const node of prose()) {
|
||||
assert.match(node.className, /\btext-foreground\b/);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Streaming cost ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A block re-renders on every append while work streams. Unchanged steps must
|
||||
* not re-render with it: each step's presenter rebuilds compact tool summaries,
|
||||
* parses diffs and renders markdown/images, so an unmemoized step row makes a
|
||||
* long block cost O(n) of that work per appended step.
|
||||
*
|
||||
* Counted at the presenter boundary — `TranscriptActivityItem` looks its
|
||||
* presenter up in `ACTIVITY_RENDER_CLASS_PRESENTERS` on every render, so
|
||||
* swapping in a counting presenter observes exactly the work a step row
|
||||
* triggers, without reaching into React internals.
|
||||
*/
|
||||
async function countStepRenders(initialItems, nextItems) {
|
||||
const { createElement } = await import("react");
|
||||
const { render } = await import("@testing-library/react");
|
||||
const { ACTIVITY_RENDER_CLASS_PRESENTERS } = await import(
|
||||
"./activityRenderClasses/TranscriptActivityItem.tsx"
|
||||
);
|
||||
const { AgentSessionTranscriptTurnMetaProvider } = await import(
|
||||
"./agentSessionTranscriptContext.ts"
|
||||
);
|
||||
const { AgentSessionWorkBlockSegment } = await import(
|
||||
"./AgentSessionWorkBlock.tsx"
|
||||
);
|
||||
|
||||
const renders = [];
|
||||
const original = ACTIVITY_RENDER_CLASS_PRESENTERS.shell;
|
||||
ACTIVITY_RENDER_CLASS_PRESENTERS.shell = function CountingPresenter(props) {
|
||||
renders.push(props.item.id);
|
||||
return createElement("div", null, props.item.id);
|
||||
};
|
||||
|
||||
try {
|
||||
const element = (items) =>
|
||||
createElement(
|
||||
AgentSessionTranscriptTurnMetaProvider,
|
||||
{
|
||||
value: {
|
||||
liveTurnId: items[items.length - 1].turnId,
|
||||
streamingItemId: items[items.length - 1].id,
|
||||
},
|
||||
},
|
||||
createElement(AgentSessionWorkBlockSegment, {
|
||||
agentAvatarUrl: null,
|
||||
agentName: "Agent",
|
||||
agentPubkey: "pk",
|
||||
block: {
|
||||
id: "work-block:a",
|
||||
items,
|
||||
timestamp: items[0].timestamp,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const view = render(element(initialItems));
|
||||
renders.length = 0;
|
||||
view.rerender(element(nextItems));
|
||||
return renders;
|
||||
} finally {
|
||||
ACTIVITY_RENDER_CLASS_PRESENTERS.shell = original;
|
||||
}
|
||||
}
|
||||
|
||||
test("appending a step does not re-render the steps already on the rail", async () => {
|
||||
// The block is expanded (a live block with ≤3 steps shows them all), and the
|
||||
// prior steps are the SAME objects across both renders, as the transcript
|
||||
// store replaces items rather than mutating them.
|
||||
const settled = [step("a"), step("b")];
|
||||
const appended = [
|
||||
...settled,
|
||||
step("c", { status: "executing", completedAt: null }),
|
||||
];
|
||||
|
||||
const rendered = await countStepRenders(settled, appended);
|
||||
|
||||
assert.deepEqual(rendered, ["c"]);
|
||||
});
|
||||
|
||||
test("a step that actually changed does re-render", async () => {
|
||||
// Guards the memo from being too aggressive: an executing step settling is a
|
||||
// new object for that id, and it must re-render to drop its running glyph.
|
||||
const a = step("a");
|
||||
const executing = step("b", { status: "executing", completedAt: null });
|
||||
const settled = step("b");
|
||||
|
||||
const rendered = await countStepRenders([a, executing], [a, settled]);
|
||||
|
||||
assert.deepEqual(rendered, ["b"]);
|
||||
});
|
||||
@@ -1,301 +1,19 @@
|
||||
/**
|
||||
* Work-block rendering: the live rail, the finished fold, the rail glyphs, and
|
||||
* the two costs a streaming block must not pay (re-rendering settled steps, and
|
||||
* losing the reader's disclosure choice to a programmatic toggle echo).
|
||||
* Work-block rendering while a turn is live and when it finishes: the rail, the
|
||||
* live window, the fold animation, and the reader's disclosure choice.
|
||||
* Orphaned work and streaming cost live in
|
||||
* `AgentSessionWorkBlock.orphaned.test.mjs`; the shared rig is
|
||||
* `AgentSessionWorkBlockTestRig.mjs`.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { after, afterEach, before, test } from "node:test";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
class NoopObserver {
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
}
|
||||
|
||||
let prefersReducedMotion = false;
|
||||
|
||||
before(() => {
|
||||
Object.assign(globalThis, {
|
||||
document: dom.window.document,
|
||||
Element: dom.window.Element,
|
||||
Event: dom.window.Event,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
IntersectionObserver: NoopObserver,
|
||||
MutationObserver: dom.window.MutationObserver,
|
||||
Node: dom.window.Node,
|
||||
ResizeObserver: NoopObserver,
|
||||
getComputedStyle: (...args) => dom.window.getComputedStyle(...args),
|
||||
localStorage: dom.window.localStorage,
|
||||
self: dom.window,
|
||||
window: dom.window,
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: dom.window.navigator,
|
||||
writable: true,
|
||||
});
|
||||
// `motion`'s useReducedMotion reads this query, so the reduced-motion test
|
||||
// drives it through the same surface the component does.
|
||||
dom.window.matchMedia = (query) => ({
|
||||
matches:
|
||||
prefersReducedMotion && String(query).includes("prefers-reduced-motion"),
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
});
|
||||
globalThis.matchMedia = dom.window.matchMedia;
|
||||
dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0);
|
||||
dom.window.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame;
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
prefersReducedMotion = false;
|
||||
const { cleanup } = await import("@testing-library/react");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
after(() => dom.window.close());
|
||||
|
||||
const START = "2026-06-18T00:00:00.000Z";
|
||||
const SHARED = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" };
|
||||
|
||||
function step(id, overrides = {}) {
|
||||
return {
|
||||
...SHARED,
|
||||
id,
|
||||
type: "tool",
|
||||
renderClass: "shell",
|
||||
descriptor: {
|
||||
renderClass: "shell",
|
||||
label: "Ran command",
|
||||
preview: id,
|
||||
source: "shell",
|
||||
groupKey: "shell:command",
|
||||
},
|
||||
title: id,
|
||||
toolName: "shell",
|
||||
buzzToolName: null,
|
||||
status: "completed",
|
||||
args: {},
|
||||
result: "ok",
|
||||
isError: false,
|
||||
timestamp: START,
|
||||
startedAt: START,
|
||||
completedAt: "2026-06-18T00:00:01.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function thoughtStep(id) {
|
||||
return {
|
||||
...SHARED,
|
||||
id,
|
||||
type: "thought",
|
||||
renderClass: "thought",
|
||||
title: "Thinking",
|
||||
text: "weighing the options",
|
||||
timestamp: START,
|
||||
};
|
||||
}
|
||||
|
||||
function noteStep(id, text = "posted the summary to the channel") {
|
||||
return {
|
||||
...SHARED,
|
||||
id,
|
||||
type: "message",
|
||||
renderClass: "message",
|
||||
role: "assistant",
|
||||
title: "Agent",
|
||||
text,
|
||||
timestamp: START,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A relay post the agent made mid-turn.
|
||||
*
|
||||
* The distinguishing property is `descriptor.renderClass: "message"`, which is
|
||||
* what `buildCompactToolSummary` turns into `presentation: "message"` and what
|
||||
* routes the item to the avatar + speech-bubble presenter outside a block. The
|
||||
* result carries an `event_id` so `getSentMessageLink` resolves too — that is
|
||||
* the fully-featured shape (bubble, timestamp, delivery receipt), i.e. the one
|
||||
* that goes most wrong on a muted rail.
|
||||
*/
|
||||
function relayStep(id, overrides = {}) {
|
||||
return step(id, {
|
||||
renderClass: "message",
|
||||
descriptor: {
|
||||
renderClass: "message",
|
||||
label: "Send Message",
|
||||
preview: "posted the findings to the channel",
|
||||
action: { verb: "Sent", object: "posted the findings to the channel" },
|
||||
source: "shell",
|
||||
groupKey: "buzz-cli:messages.send",
|
||||
operation: "messages.send",
|
||||
},
|
||||
title: "Send Message",
|
||||
toolName: "buzz_dev_mcp__shell",
|
||||
args: {
|
||||
command:
|
||||
"buzz messages send --channel chan-1 --content 'posted the findings to the channel'",
|
||||
},
|
||||
result: '{"event_id":"ev-1","accepted":true}',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Let every pending animation finish and the DOM settle.
|
||||
*
|
||||
* A collapsing block keeps its rail mounted until the height animation's exit
|
||||
* completes, which is the point of the fold — so "has it closed yet?" can only
|
||||
* be asked after the exit runs, not on the commit that started it. Polling to a
|
||||
* stable answer keeps these assertions about the end state the reader sees
|
||||
* rather than about motion's internal frame schedule.
|
||||
*/
|
||||
async function settle(read, expected, { timeout = 2000 } = {}) {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const deadline = Date.now() + timeout;
|
||||
let last = read();
|
||||
while (Date.now() < deadline) {
|
||||
if (last === expected) return last;
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
});
|
||||
last = read();
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a work block under a router.
|
||||
*
|
||||
* Rail rows render agent content through `Markdown`, which resolves in-app links
|
||||
* via `useAppNavigation` and therefore needs router context — the same reason
|
||||
* `AgentSessionTranscriptList.conversation.test.mjs` mounts through a memory
|
||||
* router. Without it a thought row throws while a tool row happens not to.
|
||||
*
|
||||
* `liveTurnId` defaults to the fixtures' own turn, i.e. "the agent is working on
|
||||
* this turn right now", because that is the situation nearly every case here is
|
||||
* about. Pass `liveTurnId: null` for reopened history — see the orphaned-step
|
||||
* tests, where the same items must read as finished.
|
||||
*/
|
||||
async function renderBlock(
|
||||
items,
|
||||
{ liveTurnId = SHARED.turnId, streamingItemId = null } = {},
|
||||
) {
|
||||
const { createElement, useState } = await import("react");
|
||||
const { render } = await import("@testing-library/react");
|
||||
const { QueryClient, QueryClientProvider } = await import(
|
||||
"@tanstack/react-query"
|
||||
);
|
||||
const { createMemoryHistory, createRootRoute, createRouter, RouterProvider } =
|
||||
await import("@tanstack/react-router");
|
||||
const { AgentSessionTranscriptTurnMetaProvider } = await import(
|
||||
"./agentSessionTranscriptContext.ts"
|
||||
);
|
||||
const { AgentSessionWorkBlockSegment } = await import(
|
||||
"./AgentSessionWorkBlock.tsx"
|
||||
);
|
||||
|
||||
const element = (blockItems, streaming, live) =>
|
||||
createElement(
|
||||
AgentSessionTranscriptTurnMetaProvider,
|
||||
{
|
||||
value: {
|
||||
liveTurnId: live,
|
||||
streamingItemId: streaming,
|
||||
},
|
||||
},
|
||||
createElement(AgentSessionWorkBlockSegment, {
|
||||
agentAvatarUrl: null,
|
||||
agentName: "Agent",
|
||||
agentPubkey: "pk",
|
||||
block: {
|
||||
id: `work-block:${blockItems[0].id}`,
|
||||
items: blockItems,
|
||||
timestamp: blockItems[0].timestamp,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let applyState;
|
||||
const Harness = () => {
|
||||
const [state, setState] = useState({ items, liveTurnId, streamingItemId });
|
||||
applyState = setState;
|
||||
return element(state.items, state.streamingItemId, state.liveTurnId);
|
||||
};
|
||||
// The bubble presenter reads the posted message through `useQuery`. Provided
|
||||
// unconditionally so that if a rail step ever DID reach that presenter, the
|
||||
// relay test below would fail on its bubble assertion rather than on a
|
||||
// missing provider — a test must fail for the reason it claims.
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const rootRoute = createRootRoute({
|
||||
component: () =>
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
createElement(Harness),
|
||||
),
|
||||
});
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory({ initialEntries: ["/"] }),
|
||||
routeTree: rootRoute,
|
||||
});
|
||||
await router.load();
|
||||
|
||||
const view = render(createElement(RouterProvider, { router }));
|
||||
const q = (selector) => view.container.querySelector(selector);
|
||||
const qa = (selector) => [...view.container.querySelectorAll(selector)];
|
||||
const stepCount = () =>
|
||||
qa('[data-testid="transcript-work-block-step"]').length;
|
||||
return {
|
||||
...view,
|
||||
// Re-render the SAME block id, exactly as the transcript does as work
|
||||
// streams and then finishes. `liveTurnId` is carried through so a test can
|
||||
// model the session going away under a still-running step (agent death),
|
||||
// not just the step reaching a terminal status.
|
||||
stream: (nextItems, streaming = null, live = liveTurnId) =>
|
||||
applyState({
|
||||
items: nextItems,
|
||||
liveTurnId: live,
|
||||
streamingItemId: streaming,
|
||||
}),
|
||||
summary: () => q('[data-testid="transcript-work-block-summary"]'),
|
||||
previousSteps: () =>
|
||||
q('[data-testid="transcript-work-block-previous-steps"]'),
|
||||
stepCount,
|
||||
// A finished block renders folded, so any assertion about what is ON the
|
||||
// rail has to open it first — exactly as a reader would.
|
||||
expand: async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
await act(async () => {
|
||||
q('[data-testid="transcript-work-block-summary"]').click();
|
||||
});
|
||||
},
|
||||
// Wait for the rail to reach `expected` rows once animations have run.
|
||||
settleToStepCount: (expected) => settle(stepCount, expected),
|
||||
glyphStates: () =>
|
||||
qa("[data-step-state]").map((node) =>
|
||||
node.getAttribute("data-step-state"),
|
||||
),
|
||||
q,
|
||||
qa,
|
||||
};
|
||||
}
|
||||
import {
|
||||
renderBlock,
|
||||
setPrefersReducedMotion,
|
||||
step,
|
||||
} from "./AgentSessionWorkBlockTestRig.mjs";
|
||||
|
||||
// ── Live ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -444,7 +162,7 @@ test("a block that finishes while mounted stays open for a paint so the collapse
|
||||
|
||||
test("under reduced motion a finishing block folds immediately, with no settle frames", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
prefersReducedMotion = true;
|
||||
setPrefersReducedMotion(true);
|
||||
|
||||
const view = await renderBlock([
|
||||
step("a"),
|
||||
@@ -632,514 +350,3 @@ test("a running step pulses and a failed one does not", async () => {
|
||||
"a settled failure is not in flight",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Orphaned work ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reopened history must not present as work in progress.
|
||||
*
|
||||
* A tool's `executing` status is written when the step starts and never revised
|
||||
* if the agent dies first, so an abandoned step keeps it forever. In this block
|
||||
* that status is not a per-row detail — one `running` entry makes the whole
|
||||
* block active, which suppresses the folded summary line, keeps the rail
|
||||
* expanded and pulses a bullet. Scrolling back to a crashed turn therefore
|
||||
* showed the reader live work indefinitely (Codex P2 on #6536).
|
||||
*/
|
||||
test("a block whose running step has no live session folds like finished work", async () => {
|
||||
const view = await renderBlock(
|
||||
[step("a"), step("b", { status: "executing", completedAt: null })],
|
||||
{ liveTurnId: null, streamingItemId: null },
|
||||
);
|
||||
|
||||
const summary = view.summary();
|
||||
assert.ok(
|
||||
summary,
|
||||
"history gets its folded summary line — the orphaned status must not suppress it",
|
||||
);
|
||||
assert.match(
|
||||
summary.textContent,
|
||||
/2 steps$/,
|
||||
"an abandoned step is not known to have failed, so the count stays neutral",
|
||||
);
|
||||
assert.equal(await view.settleToStepCount(0), 0, "the rail folds away");
|
||||
assert.equal(
|
||||
view.qa(".animate-pulse").length,
|
||||
0,
|
||||
"nothing pulses when nothing is running",
|
||||
);
|
||||
});
|
||||
|
||||
test("the same items still hold the block open while the turn is live", async () => {
|
||||
// The paired half: this is what makes the gate meaningful rather than a
|
||||
// blanket "never trust executing". Identical items, live turn.
|
||||
const view = await renderBlock(
|
||||
[step("a"), step("b", { status: "executing", completedAt: null })],
|
||||
{ liveTurnId: "turn-1", streamingItemId: null },
|
||||
);
|
||||
|
||||
assert.equal(view.summary(), null, "a live block has no folded line");
|
||||
assert.equal(view.stepCount(), 2, "the rail stays open");
|
||||
assert.deepEqual(view.glyphStates(), ["settled", "running"]);
|
||||
assert.equal(view.qa(".animate-pulse").length, 1, "live work pulses");
|
||||
});
|
||||
|
||||
test("an agent live on a later turn does not resurrect an earlier turn's abandoned step", async () => {
|
||||
// The reason the signal is a turn id and not a boolean: a restarted agent is
|
||||
// live, but not on this turn, and a global flag would keep this step spinning.
|
||||
const view = await renderBlock(
|
||||
[step("a"), step("b", { status: "executing", completedAt: null })],
|
||||
{ liveTurnId: "turn-2", streamingItemId: null },
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
view.summary(),
|
||||
"this turn is history even though the agent is busy",
|
||||
);
|
||||
assert.equal(await view.settleToStepCount(0), 0);
|
||||
assert.equal(view.qa(".animate-pulse").length, 0);
|
||||
});
|
||||
|
||||
test("a block live when the session ends folds instead of spinning forever", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
// The bug as the reader meets it live: the agent dies mid-step, so the item
|
||||
// never reaches a terminal status and the only thing that changes is that no
|
||||
// turn is live any more.
|
||||
const live = [
|
||||
step("a"),
|
||||
step("b", { status: "executing", completedAt: null }),
|
||||
];
|
||||
const view = await renderBlock(live, {
|
||||
liveTurnId: "turn-1",
|
||||
streamingItemId: "b",
|
||||
});
|
||||
assert.equal(view.stepCount(), 2, "live: the rail is open");
|
||||
assert.equal(view.summary(), null);
|
||||
|
||||
// Session gone. The items are BYTE-IDENTICAL — only liveness changed.
|
||||
await act(async () => {
|
||||
view.stream(live, null, null);
|
||||
});
|
||||
|
||||
assert.ok(view.summary(), "the block settles when its session goes away");
|
||||
assert.match(view.summary().textContent, /2 steps$/);
|
||||
assert.equal(await view.settleToStepCount(0), 0);
|
||||
assert.equal(view.qa(".animate-pulse").length, 0);
|
||||
});
|
||||
|
||||
test("an orphaned step keeps its own row detail rather than gaining an interrupted marker", async () => {
|
||||
// Deliberate: we do not know what happened to the step, so it renders as the
|
||||
// neutral step it is with whatever it recorded. A visible "interrupted"
|
||||
// treatment would be a design addition, not part of this fix.
|
||||
const view = await renderBlock(
|
||||
[step("a"), step("b", { status: "executing", completedAt: null })],
|
||||
{ liveTurnId: null, streamingItemId: null },
|
||||
);
|
||||
await view.expand();
|
||||
await view.settleToStepCount(2);
|
||||
|
||||
assert.deepEqual(
|
||||
view.glyphStates(),
|
||||
["settled", "settled"],
|
||||
"no third state was invented for an abandoned step",
|
||||
);
|
||||
assert.equal(
|
||||
view.qa('[data-testid="transcript-tool-item"]').length,
|
||||
2,
|
||||
"both steps still render through the normal tool presenter",
|
||||
);
|
||||
});
|
||||
|
||||
test("the rail bullet masks the spine with the drawer surface colour", async () => {
|
||||
// The bullet has to mask the spine passing behind it, and the mask must match
|
||||
// the surface the transcript is drawn on. A mask in any other colour shows as
|
||||
// a disc of the wrong shade around every bullet (berd's BOT-1599).
|
||||
const view = await renderBlock([step("a"), step("b")]);
|
||||
await view.expand();
|
||||
const bullet = view.q("[data-step-state]");
|
||||
assert.match(bullet.className, /\bbg-background\b/);
|
||||
assert.match(bullet.className, /\bring-background\b/);
|
||||
assert.match(bullet.className, /\brounded-full\b/);
|
||||
});
|
||||
|
||||
test("the spine is drawn for every step except the last", async () => {
|
||||
const view = await renderBlock([step("a"), step("b"), step("c")]);
|
||||
await view.expand();
|
||||
const spines = view.qa(".w-px");
|
||||
assert.equal(
|
||||
spines.length,
|
||||
2,
|
||||
"three steps means two connecting segments; a trailing spine would dangle",
|
||||
);
|
||||
});
|
||||
|
||||
test("thinking renders as a rail row with its own glyph, not a nested disclosure", async () => {
|
||||
const view = await renderBlock([thoughtStep("thought:1"), step("a")]);
|
||||
await view.expand();
|
||||
const thought = view.q('[data-testid="transcript-work-block-thought"]');
|
||||
assert.ok(thought, "reasoning renders on the rail");
|
||||
assert.match(thought.textContent, /weighing the options/);
|
||||
assert.equal(
|
||||
view.q('[data-testid="transcript-thought-disclosure"]'),
|
||||
null,
|
||||
"the block is already one disclosure — a thought must not add a second",
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* An interim note is progress, not a second reply.
|
||||
*
|
||||
* #6720 gives every conversation-variant assistant message a 20px avatar + name
|
||||
* identity row, which is right for the turn's answer. A rail note is the same
|
||||
* item type, so routing it through that presenter would render a fully
|
||||
* attributed agent turn nested inside a muted step row — the reader would see
|
||||
* the agent apparently reply twice, once inside the work it was doing. berd
|
||||
* draws the same line: its `progress` entry is a plain rail row.
|
||||
*
|
||||
* The suppression is done on this side (a dedicated prose body) rather than by
|
||||
* reaching into the message presenter, so #6720 keeps one rule for what a
|
||||
* message looks like.
|
||||
*/
|
||||
test("an interim note renders as rail prose with no identity row", async () => {
|
||||
const view = await renderBlock([
|
||||
step("a"),
|
||||
noteStep("msg:interim", "checked the three call sites"),
|
||||
]);
|
||||
await view.expand();
|
||||
|
||||
const note = view.q('[data-testid="transcript-work-block-note"]');
|
||||
assert.ok(note, "the note renders on the rail");
|
||||
assert.match(note.textContent, /checked the three call sites/);
|
||||
|
||||
assert.ok(
|
||||
view.q('[data-testid="transcript-assistant-identity"]') === null,
|
||||
"an avatar + name row inside a muted step reads as a second reply",
|
||||
);
|
||||
assert.ok(
|
||||
view.q('[data-testid="transcript-assistant-message"]') === null,
|
||||
"the note must not go through the message presenter at all",
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A relay post is a step, not a reply — the same rule as an interim note,
|
||||
* reached by a different route.
|
||||
*
|
||||
* A note is an assistant *message* the block re-presents as prose. A relay post
|
||||
* is a *tool call* that classifies as `renderClass: "message"`, so it renders
|
||||
* through `CompactMessageSummary`: 28px avatar, bordered speech bubble,
|
||||
* timestamp, delivery-receipt button. That is right in the activity feed, where
|
||||
* a posted message is a destination to open; on the rail it makes the agent
|
||||
* appear to reply in the middle of its own work — and it did, in the seeded
|
||||
* browser preview, which is where this was caught.
|
||||
*
|
||||
* Suppressing it needs the presentation signal rather than the transcript
|
||||
* variant: the same relay step OUTSIDE a block in this variant keeps its
|
||||
* bubble, which the next test pins.
|
||||
*/
|
||||
test("a relay post on the rail is a plain step, with no bubble or avatar", async () => {
|
||||
const view = await renderBlock([step("a"), relayStep("relay:1")]);
|
||||
await view.expand();
|
||||
|
||||
assert.equal(
|
||||
view.stepCount(),
|
||||
2,
|
||||
"the relay post takes its own rail row, like any other step",
|
||||
);
|
||||
assert.equal(
|
||||
view.qa('[data-work-block-entry="tool"]').length,
|
||||
2,
|
||||
"a relay post is a tool step — it is something the agent did",
|
||||
);
|
||||
assert.equal(
|
||||
view.q('[data-testid="transcript-tool-message-preview"]'),
|
||||
null,
|
||||
"a speech bubble inside a muted step reads as the agent replying mid-work",
|
||||
);
|
||||
assert.equal(
|
||||
view.q('[data-testid="transcript-agent-sent-avatar"]'),
|
||||
null,
|
||||
"no identity avatar on the rail",
|
||||
);
|
||||
assert.equal(
|
||||
view.q('[data-testid="transcript-sent-message-context-button"]'),
|
||||
null,
|
||||
"no delivery receipt on the rail",
|
||||
);
|
||||
|
||||
// It is still a real, expandable tool row carrying its command.
|
||||
const rows = view.qa('[data-testid="transcript-tool-item"]');
|
||||
assert.equal(rows.length, 2, "both steps render as tool rows");
|
||||
assert.ok(
|
||||
rows[1].querySelector("details"),
|
||||
"the relay step keeps the ordinary step disclosure so its args stay reachable",
|
||||
);
|
||||
assert.match(
|
||||
rows[1].textContent,
|
||||
/Sent|posted the findings/,
|
||||
"the row still says what the step was",
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The other half of the branch: outside a block the bubble is correct and must
|
||||
* survive. Without this, suppressing the bubble everywhere in the conversation
|
||||
* variant would pass the test above.
|
||||
*/
|
||||
test("the same relay post outside a work block keeps its message bubble", async () => {
|
||||
const { createElement } = await import("react");
|
||||
const { render } = await import("@testing-library/react");
|
||||
const { QueryClient, QueryClientProvider } = await import(
|
||||
"@tanstack/react-query"
|
||||
);
|
||||
const { createMemoryHistory, createRootRoute, createRouter, RouterProvider } =
|
||||
await import("@tanstack/react-router");
|
||||
const { AgentSessionTranscriptVariantProvider } = await import(
|
||||
"./agentSessionTranscriptContext.ts"
|
||||
);
|
||||
const { TranscriptActivityItem } = await import(
|
||||
"./activityRenderClasses/TranscriptActivityItem.tsx"
|
||||
);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const rootRoute = createRootRoute({
|
||||
component: () =>
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
createElement(
|
||||
AgentSessionTranscriptVariantProvider,
|
||||
{ value: "conversation" },
|
||||
createElement(TranscriptActivityItem, {
|
||||
agentAvatarUrl: null,
|
||||
agentName: "Agent",
|
||||
agentPubkey: "pk",
|
||||
item: relayStep("relay:1"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory({ initialEntries: ["/"] }),
|
||||
routeTree: rootRoute,
|
||||
});
|
||||
await router.load();
|
||||
const view = render(createElement(RouterProvider, { router }));
|
||||
|
||||
assert.ok(
|
||||
view.container.querySelector(
|
||||
'[data-testid="transcript-tool-message-preview"]',
|
||||
),
|
||||
"outside a block a posted message is a destination the reader can open — the bubble stays",
|
||||
);
|
||||
});
|
||||
|
||||
test("the rail glyph is chosen by kind, and prose kinds share the speech bubble", async () => {
|
||||
// The exhaustive switch is the point: a note that fell through to the tool
|
||||
// branch would wear a wrench and read as something the agent ran.
|
||||
const view = await renderBlock([
|
||||
thoughtStep("thought:1"),
|
||||
noteStep("msg:interim"),
|
||||
step("a"),
|
||||
step("b", { isError: true, status: "failed" }),
|
||||
]);
|
||||
await view.expand();
|
||||
|
||||
const glyphClass = (kind, index = 0) => {
|
||||
const rows = view.qa(`[data-work-block-entry="${kind}"]`);
|
||||
const icon = rows[index].querySelector("svg");
|
||||
return icon.getAttribute("class") ?? "";
|
||||
};
|
||||
|
||||
// lucide stamps each icon with a `lucide-<kebab-name>` class, so the glyph
|
||||
// identity is readable from the DOM without reaching into the icon modules.
|
||||
assert.match(glyphClass("thought"), /lucide-message-circle/);
|
||||
assert.match(
|
||||
glyphClass("note"),
|
||||
/lucide-message-circle/,
|
||||
"prose is the agent talking, whether it is reasoning or a note",
|
||||
);
|
||||
assert.match(glyphClass("tool", 0), /lucide-wrench/);
|
||||
assert.match(
|
||||
glyphClass("tool", 1),
|
||||
/lucide-circle/,
|
||||
"a failed step is a filled dot, not a wrench",
|
||||
);
|
||||
});
|
||||
|
||||
test("the rail bullet is never red, whatever the step's outcome", async () => {
|
||||
// A failure is carried by glyph shape and by the folded line's count. Tinting
|
||||
// the bullet would make one bad step read as an alarm across the whole run.
|
||||
//
|
||||
// The running step keeps this block live, so the rail is already open — which
|
||||
// is also the only state in which a running bullet can be observed at all.
|
||||
const view = await renderBlock(
|
||||
[
|
||||
step("a"),
|
||||
step("b", { isError: true, status: "failed" }),
|
||||
step("c", { status: "executing", completedAt: null }),
|
||||
],
|
||||
{ streamingItemId: "c" },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
view.glyphStates(),
|
||||
["settled", "failed", "running"],
|
||||
"all three outcomes are on screen",
|
||||
);
|
||||
for (const bullet of view.qa("[data-step-state]")) {
|
||||
assert.ok(
|
||||
!/\b(text|bg|ring)-(destructive|red)/.test(bullet.className),
|
||||
`rail bullet for ${bullet.getAttribute("data-step-state")} must stay muted`,
|
||||
);
|
||||
assert.match(bullet.className, /\btext-muted-foreground\b/);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* berd brightens rail prose with `usePrimaryText={open}`. Here the brightening
|
||||
* is unconditional, and this test records why that is not a divergence: a closed
|
||||
* block unmounts its rows rather than dimming them, so there is no state in
|
||||
* which rail prose is on screen and NOT in an open block. A `primaryText` prop
|
||||
* would have an unreachable false branch.
|
||||
*/
|
||||
test("rail prose is primary text, and a closed block has no prose on screen at all", async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const live = [
|
||||
thoughtStep("thought:1"),
|
||||
noteStep("msg:interim"),
|
||||
step("b", { status: "executing", completedAt: null }),
|
||||
];
|
||||
const view = await renderBlock(live, { streamingItemId: "b" });
|
||||
|
||||
const prose = () =>
|
||||
view.qa(
|
||||
'[data-testid="transcript-work-block-thought"],[data-testid="transcript-work-block-note"]',
|
||||
);
|
||||
|
||||
assert.equal(prose().length, 2, "both prose rows are on the live rail");
|
||||
for (const node of prose()) {
|
||||
assert.match(
|
||||
node.className,
|
||||
/\btext-foreground\b/,
|
||||
"prose the reader can see is primary, not muted",
|
||||
);
|
||||
assert.ok(
|
||||
!/\btext-muted-foreground\b/.test(node.className),
|
||||
"the row must not carry both colours",
|
||||
);
|
||||
}
|
||||
|
||||
// Finish the turn: the block folds and takes its prose with it.
|
||||
await act(async () => {
|
||||
view.stream([thoughtStep("thought:1"), noteStep("msg:interim"), step("b")]);
|
||||
});
|
||||
assert.equal(await view.settleToStepCount(0), 0, "it folded");
|
||||
assert.equal(
|
||||
prose().length,
|
||||
0,
|
||||
"a folded block renders no prose, so there is no dimmed state to test",
|
||||
);
|
||||
|
||||
// And the reader reopening it brings the same primary prose back.
|
||||
await act(async () => {
|
||||
view.summary().click();
|
||||
});
|
||||
assert.equal(await view.settleToStepCount(3), 3, "the reader reopened it");
|
||||
assert.equal(prose().length, 2);
|
||||
for (const node of prose()) {
|
||||
assert.match(node.className, /\btext-foreground\b/);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Streaming cost ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A block re-renders on every append while work streams. Unchanged steps must
|
||||
* not re-render with it: each step's presenter rebuilds compact tool summaries,
|
||||
* parses diffs and renders markdown/images, so an unmemoized step row makes a
|
||||
* long block cost O(n) of that work per appended step.
|
||||
*
|
||||
* Counted at the presenter boundary — `TranscriptActivityItem` looks its
|
||||
* presenter up in `ACTIVITY_RENDER_CLASS_PRESENTERS` on every render, so
|
||||
* swapping in a counting presenter observes exactly the work a step row
|
||||
* triggers, without reaching into React internals.
|
||||
*/
|
||||
async function countStepRenders(initialItems, nextItems) {
|
||||
const { createElement } = await import("react");
|
||||
const { render } = await import("@testing-library/react");
|
||||
const { ACTIVITY_RENDER_CLASS_PRESENTERS } = await import(
|
||||
"./activityRenderClasses/TranscriptActivityItem.tsx"
|
||||
);
|
||||
const { AgentSessionTranscriptTurnMetaProvider } = await import(
|
||||
"./agentSessionTranscriptContext.ts"
|
||||
);
|
||||
const { AgentSessionWorkBlockSegment } = await import(
|
||||
"./AgentSessionWorkBlock.tsx"
|
||||
);
|
||||
|
||||
const renders = [];
|
||||
const original = ACTIVITY_RENDER_CLASS_PRESENTERS.shell;
|
||||
ACTIVITY_RENDER_CLASS_PRESENTERS.shell = function CountingPresenter(props) {
|
||||
renders.push(props.item.id);
|
||||
return createElement("div", null, props.item.id);
|
||||
};
|
||||
|
||||
try {
|
||||
const element = (items) =>
|
||||
createElement(
|
||||
AgentSessionTranscriptTurnMetaProvider,
|
||||
{
|
||||
value: {
|
||||
liveTurnId: items[items.length - 1].turnId,
|
||||
streamingItemId: items[items.length - 1].id,
|
||||
},
|
||||
},
|
||||
createElement(AgentSessionWorkBlockSegment, {
|
||||
agentAvatarUrl: null,
|
||||
agentName: "Agent",
|
||||
agentPubkey: "pk",
|
||||
block: {
|
||||
id: "work-block:a",
|
||||
items,
|
||||
timestamp: items[0].timestamp,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const view = render(element(initialItems));
|
||||
renders.length = 0;
|
||||
view.rerender(element(nextItems));
|
||||
return renders;
|
||||
} finally {
|
||||
ACTIVITY_RENDER_CLASS_PRESENTERS.shell = original;
|
||||
}
|
||||
}
|
||||
|
||||
test("appending a step does not re-render the steps already on the rail", async () => {
|
||||
// The block is expanded (a live block with ≤3 steps shows them all), and the
|
||||
// prior steps are the SAME objects across both renders, as the transcript
|
||||
// store replaces items rather than mutating them.
|
||||
const settled = [step("a"), step("b")];
|
||||
const appended = [
|
||||
...settled,
|
||||
step("c", { status: "executing", completedAt: null }),
|
||||
];
|
||||
|
||||
const rendered = await countStepRenders(settled, appended);
|
||||
|
||||
assert.deepEqual(rendered, ["c"]);
|
||||
});
|
||||
|
||||
test("a step that actually changed does re-render", async () => {
|
||||
// Guards the memo from being too aggressive: an executing step settling is a
|
||||
// new object for that id, and it must re-render to drop its running glyph.
|
||||
const a = step("a");
|
||||
const executing = step("b", { status: "executing", completedAt: null });
|
||||
const settled = step("b");
|
||||
|
||||
const rendered = await countStepRenders([a, executing], [a, settled]);
|
||||
|
||||
assert.deepEqual(rendered, ["b"]);
|
||||
});
|
||||
|
||||
311
desktop/src/features/agents/ui/AgentSessionWorkBlockTestRig.mjs
Normal file
311
desktop/src/features/agents/ui/AgentSessionWorkBlockTestRig.mjs
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Shared jsdom lifecycle, item fixtures and the `renderBlock` view helper for
|
||||
* the work-block suites. One owner for the order-sensitive globals: the two
|
||||
* suites run in separate processes, so a second copy would not collide -- it
|
||||
* would drift, which is the failure this rig exists to prevent.
|
||||
*/
|
||||
|
||||
import { after, afterEach, before } from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
class NoopObserver {
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
}
|
||||
|
||||
let prefersReducedMotion = false;
|
||||
|
||||
/**
|
||||
* ESM bindings are read-only in importers, so the reduced-motion test cannot
|
||||
* assign this directly -- it goes through the setter, and `afterEach` below
|
||||
* resets it for every file that imports this rig.
|
||||
*/
|
||||
export function setPrefersReducedMotion(value) {
|
||||
prefersReducedMotion = value;
|
||||
}
|
||||
|
||||
before(() => {
|
||||
Object.assign(globalThis, {
|
||||
document: dom.window.document,
|
||||
Element: dom.window.Element,
|
||||
Event: dom.window.Event,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
IntersectionObserver: NoopObserver,
|
||||
MutationObserver: dom.window.MutationObserver,
|
||||
Node: dom.window.Node,
|
||||
ResizeObserver: NoopObserver,
|
||||
getComputedStyle: (...args) => dom.window.getComputedStyle(...args),
|
||||
localStorage: dom.window.localStorage,
|
||||
self: dom.window,
|
||||
window: dom.window,
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: dom.window.navigator,
|
||||
writable: true,
|
||||
});
|
||||
// `motion`'s useReducedMotion reads this query, so the reduced-motion test
|
||||
// drives it through the same surface the component does.
|
||||
dom.window.matchMedia = (query) => ({
|
||||
matches:
|
||||
prefersReducedMotion && String(query).includes("prefers-reduced-motion"),
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
});
|
||||
globalThis.matchMedia = dom.window.matchMedia;
|
||||
dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0);
|
||||
dom.window.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame;
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
prefersReducedMotion = false;
|
||||
const { cleanup } = await import("@testing-library/react");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
after(() => dom.window.close());
|
||||
|
||||
export const START = "2026-06-18T00:00:00.000Z";
|
||||
export const SHARED = {
|
||||
channelId: "chan-1",
|
||||
sessionId: "sess-1",
|
||||
turnId: "turn-1",
|
||||
};
|
||||
|
||||
export function step(id, overrides = {}) {
|
||||
return {
|
||||
...SHARED,
|
||||
id,
|
||||
type: "tool",
|
||||
renderClass: "shell",
|
||||
descriptor: {
|
||||
renderClass: "shell",
|
||||
label: "Ran command",
|
||||
preview: id,
|
||||
source: "shell",
|
||||
groupKey: "shell:command",
|
||||
},
|
||||
title: id,
|
||||
toolName: "shell",
|
||||
buzzToolName: null,
|
||||
status: "completed",
|
||||
args: {},
|
||||
result: "ok",
|
||||
isError: false,
|
||||
timestamp: START,
|
||||
startedAt: START,
|
||||
completedAt: "2026-06-18T00:00:01.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function thoughtStep(id) {
|
||||
return {
|
||||
...SHARED,
|
||||
id,
|
||||
type: "thought",
|
||||
renderClass: "thought",
|
||||
title: "Thinking",
|
||||
text: "weighing the options",
|
||||
timestamp: START,
|
||||
};
|
||||
}
|
||||
|
||||
export function noteStep(id, text = "posted the summary to the channel") {
|
||||
return {
|
||||
...SHARED,
|
||||
id,
|
||||
type: "message",
|
||||
renderClass: "message",
|
||||
role: "assistant",
|
||||
title: "Agent",
|
||||
text,
|
||||
timestamp: START,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A relay post the agent made mid-turn.
|
||||
*
|
||||
* The distinguishing property is `descriptor.renderClass: "message"`, which is
|
||||
* what `buildCompactToolSummary` turns into `presentation: "message"` and what
|
||||
* routes the item to the avatar + speech-bubble presenter outside a block. The
|
||||
* result carries an `event_id` so `getSentMessageLink` resolves too — that is
|
||||
* the fully-featured shape (bubble, timestamp, delivery receipt), i.e. the one
|
||||
* that goes most wrong on a muted rail.
|
||||
*/
|
||||
export function relayStep(id, overrides = {}) {
|
||||
return step(id, {
|
||||
renderClass: "message",
|
||||
descriptor: {
|
||||
renderClass: "message",
|
||||
label: "Send Message",
|
||||
preview: "posted the findings to the channel",
|
||||
action: { verb: "Sent", object: "posted the findings to the channel" },
|
||||
source: "shell",
|
||||
groupKey: "buzz-cli:messages.send",
|
||||
operation: "messages.send",
|
||||
},
|
||||
title: "Send Message",
|
||||
toolName: "buzz_dev_mcp__shell",
|
||||
args: {
|
||||
command:
|
||||
"buzz messages send --channel chan-1 --content 'posted the findings to the channel'",
|
||||
},
|
||||
result: '{"event_id":"ev-1","accepted":true}',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Let every pending animation finish and the DOM settle.
|
||||
*
|
||||
* A collapsing block keeps its rail mounted until the height animation's exit
|
||||
* completes, which is the point of the fold — so "has it closed yet?" can only
|
||||
* be asked after the exit runs, not on the commit that started it. Polling to a
|
||||
* stable answer keeps these assertions about the end state the reader sees
|
||||
* rather than about motion's internal frame schedule.
|
||||
*/
|
||||
export async function settle(read, expected, { timeout = 2000 } = {}) {
|
||||
const { act } = await import("@testing-library/react");
|
||||
const deadline = Date.now() + timeout;
|
||||
let last = read();
|
||||
while (Date.now() < deadline) {
|
||||
if (last === expected) return last;
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
});
|
||||
last = read();
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a work block under a router.
|
||||
*
|
||||
* Rail rows render agent content through `Markdown`, which resolves in-app links
|
||||
* via `useAppNavigation` and therefore needs router context — the same reason
|
||||
* `AgentSessionTranscriptList.conversation.test.mjs` mounts through a memory
|
||||
* router. Without it a thought row throws while a tool row happens not to.
|
||||
*
|
||||
* `liveTurnId` defaults to the fixtures' own turn, i.e. "the agent is working on
|
||||
* this turn right now", because that is the situation nearly every case here is
|
||||
* about. Pass `liveTurnId: null` for reopened history — see the orphaned-step
|
||||
* tests, where the same items must read as finished.
|
||||
*/
|
||||
export async function renderBlock(
|
||||
items,
|
||||
{ liveTurnId = SHARED.turnId, streamingItemId = null } = {},
|
||||
) {
|
||||
const { createElement, useState } = await import("react");
|
||||
const { render } = await import("@testing-library/react");
|
||||
const { QueryClient, QueryClientProvider } = await import(
|
||||
"@tanstack/react-query"
|
||||
);
|
||||
const { createMemoryHistory, createRootRoute, createRouter, RouterProvider } =
|
||||
await import("@tanstack/react-router");
|
||||
const { AgentSessionTranscriptTurnMetaProvider } = await import(
|
||||
"./agentSessionTranscriptContext.ts"
|
||||
);
|
||||
const { AgentSessionWorkBlockSegment } = await import(
|
||||
"./AgentSessionWorkBlock.tsx"
|
||||
);
|
||||
|
||||
const element = (blockItems, streaming, live) =>
|
||||
createElement(
|
||||
AgentSessionTranscriptTurnMetaProvider,
|
||||
{
|
||||
value: {
|
||||
liveTurnId: live,
|
||||
streamingItemId: streaming,
|
||||
},
|
||||
},
|
||||
createElement(AgentSessionWorkBlockSegment, {
|
||||
agentAvatarUrl: null,
|
||||
agentName: "Agent",
|
||||
agentPubkey: "pk",
|
||||
block: {
|
||||
id: `work-block:${blockItems[0].id}`,
|
||||
items: blockItems,
|
||||
timestamp: blockItems[0].timestamp,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let applyState;
|
||||
const Harness = () => {
|
||||
const [state, setState] = useState({ items, liveTurnId, streamingItemId });
|
||||
applyState = setState;
|
||||
return element(state.items, state.streamingItemId, state.liveTurnId);
|
||||
};
|
||||
// The bubble presenter reads the posted message through `useQuery`. Provided
|
||||
// unconditionally so that if a rail step ever DID reach that presenter, the
|
||||
// relay test below would fail on its bubble assertion rather than on a
|
||||
// missing provider — a test must fail for the reason it claims.
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const rootRoute = createRootRoute({
|
||||
component: () =>
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
createElement(Harness),
|
||||
),
|
||||
});
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory({ initialEntries: ["/"] }),
|
||||
routeTree: rootRoute,
|
||||
});
|
||||
await router.load();
|
||||
|
||||
const view = render(createElement(RouterProvider, { router }));
|
||||
const q = (selector) => view.container.querySelector(selector);
|
||||
const qa = (selector) => [...view.container.querySelectorAll(selector)];
|
||||
const stepCount = () =>
|
||||
qa('[data-testid="transcript-work-block-step"]').length;
|
||||
return {
|
||||
...view,
|
||||
// Re-render the SAME block id, exactly as the transcript does as work
|
||||
// streams and then finishes. `liveTurnId` is carried through so a test can
|
||||
// model the session going away under a still-running step (agent death),
|
||||
// not just the step reaching a terminal status.
|
||||
stream: (nextItems, streaming = null, live = liveTurnId) =>
|
||||
applyState({
|
||||
items: nextItems,
|
||||
liveTurnId: live,
|
||||
streamingItemId: streaming,
|
||||
}),
|
||||
summary: () => q('[data-testid="transcript-work-block-summary"]'),
|
||||
previousSteps: () =>
|
||||
q('[data-testid="transcript-work-block-previous-steps"]'),
|
||||
stepCount,
|
||||
// A finished block renders folded, so any assertion about what is ON the
|
||||
// rail has to open it first — exactly as a reader would.
|
||||
expand: async () => {
|
||||
const { act } = await import("@testing-library/react");
|
||||
await act(async () => {
|
||||
q('[data-testid="transcript-work-block-summary"]').click();
|
||||
});
|
||||
},
|
||||
// Wait for the rail to reach `expected` rows once animations have run.
|
||||
settleToStepCount: (expected) => settle(stepCount, expected),
|
||||
glyphStates: () =>
|
||||
qa("[data-step-state]").map((node) =>
|
||||
node.getAttribute("data-step-state"),
|
||||
),
|
||||
q,
|
||||
qa,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user