fabro/apps/fabro-web/app/lib/test-utils.tsx
Bryan Helmkamp bc31b8d23a
Serve the run stream as the only run event API
Step 4 of the legacy executor deletion, third commit: the legacy event
API and every reader of it go, so that the next commits can delete the
event log, its reducer and the types beneath them.

The API:
- `GET /runs/{id}/events` pages the run stream only
  (`PaginatedRunStreamList` by `after`); the legacy `since_seq`,
  `before_seq` and `order` cursors, the `oneOf` envelope, the legacy
  `EventEnvelope`, `PaginatedEventList`, `RunEvent`, `EventSeq`,
  `AppendEventResponse` and `RunEventDetailResponse` schemas,
  `POST /runs/{id}/events`, `GET /runs/{id}/events/{seq}` and
  `GET /runs/{id}/stages/{stageId}/events` are deleted. `GET
  /runs/{id}/attach` and `GET /attach` frame `RunStreamItem`s only.
- The Rust and TypeScript clients regenerate; the removed models leave
  the TypeScript package.

The readers:
- `fabro-client` drops the legacy run event listing, tail and attach
  methods and `RunEventStream`; `list_run_stream_until` bounds a stream
  read.
- `fabro-tool`'s `fabro_run_events` lists, searches and details the run
  stream: `after` is the exclusive `stream_seq` cursor, `event_id` the
  item's id, filters match the item's name and `recorded_at`.
- `fabro-dump` writes the stream to `events.jsonl`; `fabro dump` reads
  it.
- The CLI's progress renderer keeps only what the run stream drives:
  the legacy event conversion, the sandbox and setup displays and their
  styles go. `fabro system events` prints stream items.
- The server's demo mode folds its agent fixture straight into the
  session projection and answers the attach stub with a stream item;
  the demo stage events endpoint is gone.
- The web app: every run is a Petri run. The legacy event hooks,
  renderer props, stage popover summary, run phases derivation and
  live-event payload handling are deleted or ported to `RunStreamItem`;
  toasts and board refreshes read the stream's platform records.
- Tests: the legacy API round trips and pagination tests are deleted;
  the CLI's MCP, attach and system event mocks serve stream pages; the
  CLI test helpers read stream items.

Still failing until the later commits: the CLI tests seeded through
`POST /runs/{id}/events`, the server tests over the legacy store, and
the legacy type tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:36:00 -04:00

203 lines
5.7 KiB
TypeScript

import { createElement, type ReactNode } from "react";
import type { RunStreamItem } from "@qltysh/fabro-api-client";
import TestRenderer, { act } from "react-test-renderer";
import type { Stage } from "./stage-sidebar";
import { makeUsage } from "./test-fixtures";
import type { UnknownRecord } from "./unknown";
const IS_REACT_ACT_ENV = "IS_REACT_ACT_ENVIRONMENT" as const;
/**
* Per-test setup for code that uses react-test-renderer:
* - Sets IS_REACT_ACT_ENVIRONMENT (required by act()).
* - Silences react-test-renderer's deprecation warning.
*
* Returns a teardown function; pair with beforeEach/afterEach so the global
* state is scoped to the test rather than leaking process-wide.
*/
export function setupReactTestEnv(): () => void {
type Globals = { [IS_REACT_ACT_ENV]?: boolean };
const globals = globalThis as Globals;
const hadEnv = IS_REACT_ACT_ENV in globals;
const previousEnv = globals[IS_REACT_ACT_ENV];
globals[IS_REACT_ACT_ENV] = true;
const originalConsoleError = console.error;
console.error = ((...args: unknown[]) => {
if (
typeof args[0] === "string" &&
args[0].startsWith("react-test-renderer is deprecated")
) {
return;
}
originalConsoleError(...args);
}) as typeof console.error;
return () => {
console.error = originalConsoleError;
if (hadEnv) {
globals[IS_REACT_ACT_ENV] = previousEnv;
} else {
delete globals[IS_REACT_ACT_ENV];
}
};
}
const STREAM_EPOCH_MS = Date.parse("2026-04-09T12:00:00Z");
/** The stage a Petri item's subject names: a node, its visit and its firing. */
export interface StreamStage {
name: string;
visit?: number;
firing?: number;
/** The node's `meta.kind`; `agent` unless given. */
kind?: string;
}
/**
* A platform record item of a run's stream, as `GET /runs/{id}/events`
* serves it: `record.kind` names the fact. Recorded one second per `seq`
* after the stream epoch unless `recorded_at` is overridden.
*/
export function makePlatformItem(
seq: number,
record: UnknownRecord & { kind: string },
overrides: Partial<RunStreamItem> = {},
): RunStreamItem {
const recordedAt = overrides.recorded_at ?? STREAM_EPOCH_MS + seq * 1000;
return {
run_id: "run-1",
stream_seq: seq,
kind: "platform",
id: String(seq),
recorded_at: recordedAt,
item: { seq, recorded_at: recordedAt, record },
...overrides,
};
}
/**
* A Petri event item of a run's stream: the recorded `body` (named by its
* `event`) under the stage `subject`, with Petri's `derived` view beside it.
*/
export function makePetriItem(
seq: number,
body: UnknownRecord & { event: string },
{
stage,
derived,
...overrides
}: { stage?: StreamStage; derived?: UnknownRecord } & Partial<RunStreamItem> = {},
): RunStreamItem {
const recordedAt = overrides.recorded_at ?? STREAM_EPOCH_MS + seq * 1000;
const subject = stage
? {
node: { id: 0, name: stage.name, kind: "attractor/stage", meta: { kind: stage.kind ?? "agent" } },
firing: stage.firing ?? stage.visit ?? 1,
visit: stage.visit ?? 1,
attempt: 1,
}
: undefined;
return {
run_id: "run-1",
stream_seq: seq,
kind: "petri",
id: `execution 0/${seq}/0`,
recorded_at: recordedAt,
item: {
id: { log: "execution", execution: 0, seq, index: 0 },
origin: "external",
context: { invocation: 0, execution: 0 },
...(subject ? { subject } : {}),
recorded_at: recordedAt,
record: { seq, origin: "external", recorded_at: recordedAt, body },
...(derived ? { derived } : {}),
},
...overrides,
};
}
/**
* A stage's `step.progress.recorded` carrying one Pebble `CodingAgentEvent`
* envelope: the `variant` (`AssistantMessage`, `ToolCallStarted`, ...) with
* its `payload`, stamped `ts`.
*/
export function makePebbleItem(
seq: number,
ts: string,
stage: StreamStage,
variant: string,
payload: UnknownRecord,
): RunStreamItem {
return makePetriItem(
seq,
{
event: "step.progress.recorded",
firing: stage.firing ?? stage.visit ?? 1,
ev: {
custom: {
kind: "pebble",
node: stage.name,
event: {
seq,
stream_id: "ses_1",
session_id: "ses_1",
timestamp: ts,
event: { [variant]: payload },
},
},
},
},
{ stage, recorded_at: Date.parse(ts) },
);
}
/** Flatten a rendered subtree to its visible text. */
export function textContent(node: TestRenderer.ReactTestInstance): string {
return node.children
.map((child) => (typeof child === "string" ? child : textContent(child)))
.join("");
}
/**
* Build a sidebar `Stage` fixture; override any field via `overrides`. Kept
* here so widening `Stage` updates every fixture at once — test files are
* excluded from typecheck, so a per-file copy silently goes stale instead.
*/
export function makeStage(overrides: Partial<Stage> = {}): Stage {
return {
id: "implement@1",
name: "implement",
handler: "agent",
nodeId: "implement",
visit: 1,
graphVisit: null,
resumedFromStageId: null,
parallelGroupId: null,
parallelBranchIndex: null,
status: "running",
duration: "--",
startedAt: null,
providerUsed: null,
usage: makeUsage(),
...overrides,
};
}
export function renderHook<T>(
hook: () => T,
options: { wrapper: React.ComponentType<{ children: ReactNode }> },
): { result: { current: T } } {
const result = { current: undefined as unknown as T };
function HookHost() {
result.current = hook();
return null;
}
act(() => {
TestRenderer.create(
createElement(options.wrapper, null, createElement(HookHost)),
);
});
return { result };
}