diff --git a/canvas/src/components/tabs/chat/__tests__/AttachmentTextPreview.test.tsx b/canvas/src/components/tabs/chat/__tests__/AttachmentTextPreview.test.tsx new file mode 100644 index 00000000..b29ae975 --- /dev/null +++ b/canvas/src/components/tabs/chat/__tests__/AttachmentTextPreview.test.tsx @@ -0,0 +1,299 @@ +// @vitest-environment jsdom +/** + * Tests for AttachmentTextPreview — inline
text file renderer.
+ *
+ * Per RFC #2991 PR-3. Manages its own fetch cycle (idle → loading →
+ * ready/error). Covers: loading skeleton, render, chip error
+ * fallback, "Show all N lines" expand button, truncated state, download
+ * buttons, tone=user/agent styling, cleanup on unmount.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
+import React from "react";
+
+import { AttachmentTextPreview } from "../AttachmentTextPreview";
+import type { ChatAttachment } from "../types";
+
+// ─── Setup ────────────────────────────────────────────────────────────────────
+
+vi.stubEnv("NEXT_PUBLIC_ADMIN_TOKEN", "test-token");
+
+const fetchMock = vi.fn();
+beforeEach(() => {
+ fetchMock.mockReset();
+ vi.stubGlobal("fetch", fetchMock);
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+// ─── Fixtures ────────────────────────────────────────────────────────────────
+
+const makeAtt = (name = "log.txt"): ChatAttachment =>
+ ({ name, uri: "workspace:/workspace/tmp/" + name });
+
+function renderTextPreview(
+ att: ChatAttachment,
+ tone: "user" | "agent" = "agent",
+) {
+ return render(
+ ,
+ );
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+describe("AttachmentTextPreview", () => {
+ // ── idle / loading ───────────────────────────────────────────────────────
+
+ it("renders loading skeleton (idle state)", () => {
+ fetchMock.mockReturnValue(new Promise(() => {})); // hangs forever
+ renderTextPreview(makeAtt());
+ const skeleton = screen.getByLabelText(/Loading log\.txt/i);
+ expect(skeleton).toBeTruthy();
+ expect(skeleton.className).toContain("animate-pulse");
+ });
+
+ it("renders loading skeleton (loading state)", async () => {
+ // Never-resolving fetch → stays in loading state.
+ fetchMock.mockReturnValue(new Promise(() => {}));
+ renderTextPreview(makeAtt("data.json"));
+ await waitFor(() => {
+ expect(screen.getByLabelText(/Loading data\.json/i)).toBeTruthy();
+ });
+ });
+
+ // ── error fallback ───────────────────────────────────────────────────────
+
+ it("renders AttachmentChip when fetch fails (404)", async () => {
+ fetchMock.mockResolvedValue({ ok: false, status: 404 });
+ renderTextPreview(makeAtt("missing.txt"));
+ await waitFor(() => {
+ expect(screen.getByTitle(/Download missing\.txt/i)).toBeTruthy();
+ });
+ // must NOT appear — proved we fell back to the chip.
+ expect(document.querySelector("pre")).toBeNull();
+ });
+
+ it("renders chip on network error", async () => {
+ fetchMock.mockRejectedValue(new Error("network down"));
+ renderTextPreview(makeAtt("offline.json"));
+ await waitFor(() => {
+ expect(screen.getByTitle(/Download offline\.json/i)).toBeTruthy();
+ });
+ });
+
+ // ── ready / ──────────────────────────────────────────────────
+
+ it("renders with text content when fetch succeeds", async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ body: null,
+ text: async () => "line1\nline2\nline3",
+ });
+ renderTextPreview(makeAtt("report.txt"));
+ await waitFor(() => {
+ const code = document.querySelector("pre code");
+ expect(code).not.toBeNull();
+ expect(code?.textContent).toBe("line1\nline2\nline3");
+ });
+ });
+
+ it("renders filename header span", async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ body: null,
+ text: async () => "hello",
+ });
+ renderTextPreview(makeAtt("notes.md"));
+ await waitFor(() => {
+ expect(screen.getByText("notes.md")).toBeTruthy();
+ });
+ });
+
+ it("renders exactly one element when ready", async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ body: null,
+ text: async () => "content",
+ });
+ renderTextPreview(makeAtt("code.js"));
+ await waitFor(() => {
+ expect(document.querySelectorAll("pre")).toHaveLength(1);
+ });
+ });
+
+ // ── show all lines button ─────────────────────────────────────────────────
+
+ it("shows 'Show all N lines' button when file has >10 lines", async () => {
+ const body = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join("\n");
+ fetchMock.mockResolvedValue({
+ ok: true,
+ body: null,
+ text: async () => body,
+ });
+ renderTextPreview(makeAtt("big.log"));
+ await waitFor(() => {
+ expect(
+ screen.getByRole("button", { name: /show all 25 lines/i }),
+ ).toBeTruthy();
+ });
+ // First 10 lines only in preview
+ const code = document.querySelector("pre code");
+ expect(code?.textContent).toContain("line 10");
+ expect(code?.textContent).not.toContain("line 11");
+ });
+
+ it("expand button is NOT shown when file has ≤10 lines", async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ body: null,
+ text: async () => "a\nb\nc",
+ });
+ renderTextPreview(makeAtt("short.txt"));
+ await waitFor(() => {
+ expect(document.querySelector("pre code")).not.toBeNull();
+ });
+ expect(screen.queryByRole("button", { name: /show all/i })).toBeNull();
+ });
+
+ it("clicking 'Show all' expands to full content", async () => {
+ const body = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join("\n");
+ fetchMock.mockResolvedValue({
+ ok: true,
+ body: null,
+ text: async () => body,
+ });
+ renderTextPreview(makeAtt("expand.txt"));
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /show all 25 lines/i })).toBeTruthy();
+ });
+ fireEvent.click(screen.getByRole("button", { name: /show all 25 lines/i }));
+ const code = document.querySelector("pre code");
+ expect(code?.textContent).toContain("line 25");
+ });
+
+ // ── download buttons ──────────────────────────────────────────────────────
+
+ it("header download button fires onDownload with attachment", async () => {
+ const onDownload = vi.fn();
+ fetchMock.mockResolvedValue({
+ ok: true,
+ body: null,
+ text: async () => "hello",
+ });
+ const { rerender } = render(
+ ,
+ );
+ await waitFor(() => {
+ expect(document.querySelector("pre code")).not.toBeNull();
+ });
+ const downloadBtn = screen.getByLabelText(/download readme\.md/i);
+ downloadBtn.click();
+ expect(onDownload).toHaveBeenCalledWith(
+ expect.objectContaining({ name: "readme.md" }),
+ );
+ });
+
+ it("onDownload is NOT called during loading or ready states", async () => {
+ const onDownload = vi.fn();
+ fetchMock.mockResolvedValue({
+ ok: true,
+ body: null,
+ text: async () => "hello world",
+ });
+ render(
+ ,
+ );
+ await waitFor(() => {
+ expect(document.querySelector("pre code")).not.toBeNull();
+ });
+ expect(onDownload).not.toHaveBeenCalled();
+ });
+
+ // ── tone styling ─────────────────────────────────────────────────────────
+
+ it("tone=user applies blue border class on container", async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ body: null,
+ text: async () => "hello",
+ });
+ const { container } = render(
+ ,
+ );
+ await waitFor(() => {
+ expect(document.querySelector("pre code")).not.toBeNull();
+ });
+ const blueDiv = Array.from(container.querySelectorAll("div")).find((d) =>
+ d.className.includes("blue-400"),
+ );
+ expect(blueDiv).toBeTruthy();
+ });
+
+ it("tone=agent does not apply blue border class", async () => {
+ fetchMock.mockResolvedValue({
+ ok: true,
+ body: null,
+ text: async () => "hello",
+ });
+ const { container } = render(
+ ,
+ );
+ await waitFor(() => {
+ expect(document.querySelector("pre code")).not.toBeNull();
+ });
+ const blueDivs = Array.from(container.querySelectorAll("div")).filter((d) =>
+ d.className.includes("blue-400"),
+ );
+ expect(blueDivs).toHaveLength(0);
+ });
+
+ // ── cleanup ─────────────────────────────────────────────────────────────
+
+ it("no state update after unmount (cancelled flag prevents setState)", async () => {
+ // The component sets cancelled=true in cleanup, which prevents setState
+ // from firing after the pending read() resolves. We verify no crash
+ // and no error element appears (since the pending read eventually resolves
+ // but the component ignores it due to cancelled=true).
+ fetchMock.mockResolvedValue({
+ ok: true,
+ body: null,
+ text: () => new Promise((resolve) => setTimeout(() => resolve("delayed"), 100)),
+ });
+ const { unmount } = renderTextPreview(makeAtt("cleanup.txt"));
+ await act(async () => {
+ unmount();
+ });
+ // No crash, no error state rendered (chip would appear on error)
+ expect(document.querySelector("pre code")).toBeNull();
+ expect(document.querySelector('[aria-label*="Download cleanup.txt"]')).toBeNull();
+ });
+});