diff --git a/apps/fabro-web/app/routes/run-files/keyboard.test.ts b/apps/fabro-web/app/routes/run-files/keyboard.test.ts new file mode 100644 index 000000000..59abfc33d --- /dev/null +++ b/apps/fabro-web/app/routes/run-files/keyboard.test.ts @@ -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); + }); +}); diff --git a/apps/fabro-web/app/routes/run-files/pierre-smoke.test.tsx b/apps/fabro-web/app/routes/run-files/pierre-smoke.test.tsx new file mode 100644 index 000000000..c7a13d1d0 --- /dev/null +++ b/apps/fabro-web/app/routes/run-files/pierre-smoke.test.tsx @@ -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"); + }); +}); diff --git a/apps/fabro-web/app/routes/run-files/placeholders.test.tsx b/apps/fabro-web/app/routes/run-files/placeholders.test.tsx new file mode 100644 index 000000000..01324d4e9 --- /dev/null +++ b/apps/fabro-web/app/routes/run-files/placeholders.test.tsx @@ -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 { + 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(), + ).toContain(".env.production"); + }); + + test("BinaryPlaceholder includes file name", () => { + const text = renderedText(); + expect(text).toContain("assets/logo.png"); + expect(text).toContain("binary"); + }); + + test("TruncatedPlaceholder maps reason to copy", () => { + expect( + renderedText( + , + ), + ).toContain("too many files"); + expect(renderedText()).toContain( + "too large", + ); + }); + + test("SymlinkOrSubmodulePlaceholder shows the kind", () => { + expect( + renderedText( + , + ), + ).toContain("submodule"); + }); + + test("DegradedBanner picks copy based on reason", () => { + expect(renderedText()).toContain( + "cleaned up", + ); + }); +}); diff --git a/apps/fabro-web/app/routes/run-files/states.test.tsx b/apps/fabro-web/app/routes/run-files/states.test.tsx new file mode 100644 index 000000000..cfaeb71e3 --- /dev/null +++ b/apps/fabro-web/app/routes/run-files/states.test.tsx @@ -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(); + 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(); + }); + 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(); + }); + 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( + (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(hello); + }); + const live = tree!.root.findAll( + (node) => node.props?.["aria-live"] === "polite", + ); + expect(live.length).toBeGreaterThan(0); + }); +});