fabro/apps/fabro-web/app/lib/test-utils.tsx
Bryan Helmkamp 503e21fbc1
Merge origin/main into feat/parallel-branch-live-status
Conflicts were between this branch's parallel-branch identity work and
main's stage billing, review targets, and live stage timing.

- Stage fixtures: main added `billing` to each per-file `makeStage`; this
  branch had hoisted one builder into `lib/test-utils`. Kept the hoisted
  builder and gave it `billing: makeBilledTokenCounts()`, so both intents
  hold and the field list stays in one place. `stage-sidebar.test.ts` also
  builds raw `RunStage` wire payloads, so it keeps importing
  `makeBilledTokenCounts` directly.
- Import lists (`run_projection.rs`, `fabro-api/src/lib.rs`,
  `run_state.rs`, `stage_projection_round_trip.rs`): unioned both sides —
  `ParallelBranchId` alongside `timing`, `ReviewTarget`,
  `ReviewTargetKind`, `AttrValue`, `Node`, and
  `StageToolBatchProjection`.
- `fabro-server` tests: git interleaved two unrelated new tests into one
  body. Split them back into
  `list_run_stages_exposes_parallel_branch_identity` and
  `run_billing_includes_live_stage_timing_in_rows_and_totals`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 17:50:36 -04:00

108 lines
3.1 KiB
TypeScript

import { createElement, type ReactNode } from "react";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import TestRenderer, { act } from "react-test-renderer";
import type { Stage } from "./stage-sidebar";
import { makeBilledTokenCounts } from "./test-fixtures";
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];
}
};
}
/** Build an event envelope fixture; override any field via `partial`. */
export function makeEventEnvelope(
seq: number,
partial: Partial<EventEnvelope>,
): EventEnvelope {
return {
seq,
id: `evt-${seq}`,
ts: `2026-04-09T12:00:0${seq}Z`,
run_id: "run-1",
event: "stage.prompt",
...partial,
} as EventEnvelope;
}
/** 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,
billing: makeBilledTokenCounts(),
...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 };
}