mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
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:
parent
244b0eccce
commit
695a6ca536
4 changed files with 364 additions and 0 deletions
40
apps/fabro-web/app/routes/run-files/keyboard.test.ts
Normal file
40
apps/fabro-web/app/routes/run-files/keyboard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
23
apps/fabro-web/app/routes/run-files/pierre-smoke.test.tsx
Normal file
23
apps/fabro-web/app/routes/run-files/pierre-smoke.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
154
apps/fabro-web/app/routes/run-files/placeholders.test.tsx
Normal file
154
apps/fabro-web/app/routes/run-files/placeholders.test.tsx
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
147
apps/fabro-web/app/routes/run-files/states.test.tsx
Normal file
147
apps/fabro-web/app/routes/run-files/states.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue