test(web): Run Files UI + pierre smoke test coverage (P2-10, P2-12)

Adds four test files for the Run Files route:

- placeholders.test.tsx: validates pickPlaceholder priority order
  (sensitive > binary > symlink/submodule > truncated) and
  bannerCopyForReason copy distinctness + unknown-reason fallback
- states.test.tsx: full deriveEmptyKind decision table (R4a
  starting, R4b no_changes, R4c1 failed_before_checkpoint, R4c2
  diff_lost, unknown) plus component rendering assertions for
  EmptyState, LoadingSkeleton, InlineErrorBanner onRetry wiring,
  and Toast aria-live
- keyboard.test.ts: isEditableElement correctness across
  input/textarea/select/contenteditable/null/case variants
- pierre-smoke.test.tsx: asserts MultiFileDiff, PatchDiff, and
  Virtualizer remain exported as callable components after the 1.0
  -> 1.1 upgrade (a full mount-under-test hits pierre's
  useLayoutEffect teardown path that's incompatible with
  react-test-renderer under React 19; functional mount coverage
  lives in the dev-server smoke flow)

All rendering tests wrap TestRenderer.create in TestRenderer.act to
keep React 19 from synchronously unmounting before assertions run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-19 17:48:20 -04:00
parent 244b0eccce
commit 695a6ca536
No known key found for this signature in database
4 changed files with 364 additions and 0 deletions

View file

@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";
import { isEditableElement } from "./keyboard";
// Lightweight stand-in for the DOM `Element` API. We only need `tagName`
// and `isContentEditable` for `isEditableElement`.
function mk(tagName: string, isContentEditable = false): Element {
return {
tagName: tagName.toUpperCase(),
isContentEditable,
} as unknown as Element;
}
describe("isEditableElement", () => {
test("flags input / textarea / select as editable", () => {
expect(isEditableElement(mk("input"))).toBe(true);
expect(isEditableElement(mk("textarea"))).toBe(true);
expect(isEditableElement(mk("select"))).toBe(true);
});
test("flags contenteditable elements as editable", () => {
expect(isEditableElement(mk("div", true))).toBe(true);
expect(isEditableElement(mk("span", true))).toBe(true);
});
test("non-editable elements are left alone", () => {
expect(isEditableElement(mk("div"))).toBe(false);
expect(isEditableElement(mk("button"))).toBe(false);
expect(isEditableElement(mk("a"))).toBe(false);
});
test("null never claims editable", () => {
expect(isEditableElement(null)).toBe(false);
});
test("case-insensitive on tag name", () => {
expect(isEditableElement(mk("Input"))).toBe(true);
expect(isEditableElement(mk("TEXTAREA"))).toBe(true);
});
});

View file

@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test";
import { MultiFileDiff, PatchDiff, Virtualizer } from "@pierre/diffs/react";
// Regression coverage for the @pierre/diffs 1.0 -> 1.1 upgrade. We assert
// only that the public React components the Run Files route uses remain
// exported as callable function components — a full mount-under-test hits
// pierre's useLayoutEffect teardown path, which is incompatible with
// react-test-renderer under React 19. Functional mount coverage lives in
// the dev-server smoke check.
describe("@pierre/diffs public API", () => {
test("MultiFileDiff is a callable component export", () => {
expect(typeof MultiFileDiff).toBe("function");
});
test("PatchDiff is a callable component export", () => {
expect(typeof PatchDiff).toBe("function");
});
test("Virtualizer is a callable component export", () => {
expect(typeof Virtualizer).toBe("function");
});
});

View file

@ -0,0 +1,154 @@
import { describe, expect, test } from "bun:test";
import TestRenderer from "react-test-renderer";
import {
bannerCopyForReason,
BinaryPlaceholder,
DegradedBanner,
pickPlaceholder,
SensitivePlaceholder,
SymlinkOrSubmodulePlaceholder,
TruncatedPlaceholder,
} from "./placeholders";
import type { FileDiff as ApiFileDiff } from "@qltysh/fabro-api-client";
/**
* Render the element and return a flat string of every text leaf the tree
* produced, for substring assertions. Wraps in `TestRenderer.act` to keep
* React 19 from unmounting the tree before we can walk it.
*/
function renderedText(element: React.ReactElement): string {
let tree: TestRenderer.ReactTestRenderer | undefined;
TestRenderer.act(() => {
tree = TestRenderer.create(element);
});
const parts: string[] = [];
tree!.root.findAll((node) => {
const children = Array.isArray(node.children) ? node.children : [];
for (const child of children) {
if (typeof child === "string") parts.push(child);
}
return false;
});
return parts.join(" ");
}
function baseFile(overrides: Partial<ApiFileDiff> = {}): ApiFileDiff {
return {
old_file: { name: "src/foo.rs", contents: "" },
new_file: { name: "src/foo.rs", contents: "" },
change_kind: "modified",
...overrides,
} as ApiFileDiff;
}
describe("pickPlaceholder priority", () => {
test("sensitive > binary > symlink > truncated", () => {
const all = baseFile({
sensitive: true,
binary: true,
change_kind: "symlink",
truncated: true,
});
const text = renderedText(pickPlaceholder(all)!);
expect(text).toContain("sensitive");
expect(text).not.toContain("binary");
});
test("binary flags take priority over symlink/submodule and truncated", () => {
const bin = baseFile({
binary: true,
change_kind: "symlink",
truncated: true,
});
const text = renderedText(pickPlaceholder(bin)!);
expect(text).toContain("binary");
expect(text).not.toContain("symlink");
});
test("symlink/submodule take priority over truncated", () => {
const link = baseFile({ change_kind: "symlink", truncated: true });
const text = renderedText(pickPlaceholder(link)!);
expect(text).toContain("symlink");
expect(text).not.toContain("too large");
});
test("truncated fires last and respects reason", () => {
const tooLarge = baseFile({
truncated: true,
truncation_reason: "file_too_large",
});
expect(renderedText(pickPlaceholder(tooLarge)!)).toContain("too large");
const budget = baseFile({
truncated: true,
truncation_reason: "budget_exhausted",
});
expect(renderedText(pickPlaceholder(budget)!)).toContain("too many files");
});
test("plain modified file gets no placeholder", () => {
expect(pickPlaceholder(baseFile())).toBeNull();
});
});
describe("bannerCopyForReason", () => {
test("each known reason gets distinct copy", () => {
const a = bannerCopyForReason("sandbox_gone");
const b = bannerCopyForReason("provider_unsupported");
const c = bannerCopyForReason("sandbox_unreachable");
expect(a).not.toBe(b);
expect(b).not.toBe(c);
expect(a).toContain("cleaned up");
expect(b).toContain("provider");
expect(c).toContain("refresh");
});
test("unknown / undefined reason falls back to sandbox_unreachable copy", () => {
expect(bannerCopyForReason(undefined)).toBe(
bannerCopyForReason("sandbox_unreachable"),
);
expect(bannerCopyForReason("made-up")).toBe(
bannerCopyForReason("sandbox_unreachable"),
);
});
});
describe("placeholder rendering", () => {
test("SensitivePlaceholder includes file name", () => {
expect(
renderedText(<SensitivePlaceholder name=".env.production" />),
).toContain(".env.production");
});
test("BinaryPlaceholder includes file name", () => {
const text = renderedText(<BinaryPlaceholder name="assets/logo.png" />);
expect(text).toContain("assets/logo.png");
expect(text).toContain("binary");
});
test("TruncatedPlaceholder maps reason to copy", () => {
expect(
renderedText(
<TruncatedPlaceholder name="big.rs" reason="budget_exhausted" />,
),
).toContain("too many files");
expect(renderedText(<TruncatedPlaceholder name="big.rs" />)).toContain(
"too large",
);
});
test("SymlinkOrSubmodulePlaceholder shows the kind", () => {
expect(
renderedText(
<SymlinkOrSubmodulePlaceholder name="lnk" kind="submodule" />,
),
).toContain("submodule");
});
test("DegradedBanner picks copy based on reason", () => {
expect(renderedText(<DegradedBanner reason="sandbox_gone" />)).toContain(
"cleaned up",
);
});
});

View file

@ -0,0 +1,147 @@
import { describe, expect, test } from "bun:test";
import TestRenderer from "react-test-renderer";
import {
deriveEmptyKind,
emptyStateCopy,
EmptyState,
InlineErrorBanner,
LoadingSkeleton,
Toast,
} from "./states";
function renderToJson(element: React.ReactElement): any {
return TestRenderer.create(element).toJSON();
}
describe("deriveEmptyKind", () => {
test("submitted / starting / queued map to R4(a) 'starting'", () => {
for (const status of ["submitted", "Submitted", "starting", "queued"]) {
expect(
deriveEmptyKind({
runStatus: status,
totalChanged: 0,
degraded: false,
}),
).toBe("starting");
}
});
test("failed run without degraded fallback is R4(c1)", () => {
expect(
deriveEmptyKind({
runStatus: "failed",
totalChanged: 0,
degraded: false,
}),
).toBe("failed_before_checkpoint");
});
test("succeeded run with changes but no data is R4(c2) 'diff_lost'", () => {
expect(
deriveEmptyKind({
runStatus: "succeeded",
totalChanged: 3,
degraded: false,
}),
).toBe("diff_lost");
});
test("succeeded run with no changes is R4(b)", () => {
expect(
deriveEmptyKind({
runStatus: "succeeded",
totalChanged: 0,
degraded: false,
}),
).toBe("no_changes");
});
test("running run with no changes is R4(b)", () => {
expect(
deriveEmptyKind({
runStatus: "running",
totalChanged: 0,
degraded: false,
}),
).toBe("no_changes");
});
test("missing runStatus collapses to 'unknown'", () => {
expect(
deriveEmptyKind({
runStatus: undefined,
totalChanged: 0,
degraded: false,
}),
).toBe("unknown");
});
});
describe("emptyStateCopy", () => {
test("every kind resolves to distinct non-empty copy", () => {
const seen = new Set<string>();
for (const kind of [
"starting",
"no_changes",
"failed_before_checkpoint",
"diff_lost",
"unknown",
] as const) {
const c = emptyStateCopy(kind);
expect(c.length).toBeGreaterThan(0);
expect(seen.has(c)).toBe(false);
seen.add(c);
}
});
});
describe("component rendering", () => {
test("EmptyState wraps message in role=status", () => {
let tree: TestRenderer.ReactTestRenderer | undefined;
TestRenderer.act(() => {
tree = TestRenderer.create(<EmptyState kind="starting" />);
});
const statusEl = tree!.root.findAll(
(node) => node.type === "div" && node.props?.role === "status",
);
expect(statusEl.length).toBeGreaterThan(0);
});
test("LoadingSkeleton has aria-label", () => {
let tree: TestRenderer.ReactTestRenderer | undefined;
TestRenderer.act(() => {
tree = TestRenderer.create(<LoadingSkeleton />);
});
const labeled = tree!.root.findAll(
(node) => node.props?.["aria-label"] === "Loading files",
);
expect(labeled.length).toBeGreaterThan(0);
});
test("InlineErrorBanner fires onRetry when clicked", () => {
let clicked = 0;
let tree: TestRenderer.ReactTestRenderer | undefined;
TestRenderer.act(() => {
tree = TestRenderer.create(
<InlineErrorBanner message="503" onRetry={() => (clicked += 1)} />,
);
});
const button = tree!.root.findByType("button");
TestRenderer.act(() => {
button.props.onClick();
});
expect(clicked).toBe(1);
});
test("Toast renders its children in an aria-live region", () => {
let tree: TestRenderer.ReactTestRenderer | undefined;
TestRenderer.act(() => {
tree = TestRenderer.create(<Toast>hello</Toast>);
});
const live = tree!.root.findAll(
(node) => node.props?.["aria-live"] === "polite",
);
expect(live.length).toBeGreaterThan(0);
});
});