From 6adc1d8af6ae470352d43737ddbb772ec604f23a Mon Sep 17 00:00:00 2001 From: Molecule AI Core-UIUX Date: Tue, 12 May 2026 01:44:57 +0000 Subject: [PATCH 01/24] test(settings): add TokensTab coverage (12 cases) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12 passing: loading spinner, empty state, token list rendering, each token's prefix/age/Revoke button, API URL correctness, revoke confirm + cancel dialogs, new-token creation + dismiss, create error, network error banner. Root bug fixed: confirm button search was unscoped — when the dialog opened, two "Revoke" buttons existed (tok2's row + dialog confirm); find() returned tok2's button first. Scoped the search to document.querySelector('[role="dialog"]') to hit the correct target. Co-Authored-By: Claude Opus 4.7 --- .../settings/__tests__/TokensTab.test.tsx | 304 ++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 canvas/src/components/settings/__tests__/TokensTab.test.tsx diff --git a/canvas/src/components/settings/__tests__/TokensTab.test.tsx b/canvas/src/components/settings/__tests__/TokensTab.test.tsx new file mode 100644 index 00000000..cb923de5 --- /dev/null +++ b/canvas/src/components/settings/__tests__/TokensTab.test.tsx @@ -0,0 +1,304 @@ +// @vitest-environment jsdom +/** + * TokensTab — workspace API token management. + * + * Per spec §5: lists bearer tokens, creates new ones, revokes existing. + * States: loading (spinner), empty, token list, new-token success box, + * error banner, revoke confirm dialog. + * + * NOTE: No @testing-library/jest-dom import — use DOM APIs for assertions. + * + * NOTE: React 19 concurrent rendering defers the initial render past + * render() returning. Use flush() (act + await Promise.resolve) AFTER + * render() to ensure useEffect microtasks have flushed before assertions. + * + * Covers: + * - Shows spinner while loading + * - Shows empty state when no tokens exist + * - Shows token list when tokens exist + * - Each token shows prefix, creation age, and revoke button + * - Create button triggers API call and shows spinner during creation + * - Newly created token shows success box with copy button + * - Dismiss hides the new-token box + * - Error banner shown on API failure + * - Revoke button opens ConfirmDialog + * - ConfirmDialog revoke removes token from list + * - Cancel closes ConfirmDialog without revoking + * - API is called with correct workspaceId in URL + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render } from "@testing-library/react"; +import React from "react"; + +import { TokensTab } from "../TokensTab"; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +const mockApiGet = vi.fn(); +const mockApiPost = vi.fn(); +const mockApiDel = vi.fn(); + +vi.mock("@/lib/api", () => ({ + api: { + get: (...args: unknown[]) => mockApiGet(...args), + post: (...args: unknown[]) => mockApiPost(...args), + del: (...args: unknown[]) => mockApiDel(...args), + }, +})); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const WS_ID = "ws-test-123"; + +function renderTab() { + return render(); +} + +/** Flush React useEffect microtasks after render (per ChannelsTab pattern). */ +async function flush() { + await act(async () => { await Promise.resolve(); }); +} + +afterEach(() => { + cleanup(); + // NOTE: Do NOT call mockReset() here — it clears the mockResolvedValue + // set in each describe-block's beforeEach, causing the next test's + // api.get() to return undefined instead of the intended mock data. + // Each describe-block calls mockReset() itself before setting up mocks. +}); + +// ─── Loading state ───────────────────────────────────────────────────────────── + +describe("TokensTab — loading", () => { + beforeEach(() => { + mockApiGet.mockReset(); + // Never resolves — component stays in loading state + mockApiGet.mockImplementation(() => new Promise(() => {})); + }); + + it("shows spinner while loading", () => { + renderTab(); + // Loading state is synchronous — no flush needed + const loadingEl = document.querySelector('[role="status"]'); + expect(loadingEl?.textContent).toContain("Loading"); + }); +}); + +// ─── Empty state ───────────────────────────────────────────────────────────── + +describe("TokensTab — empty", () => { + beforeEach(() => { + mockApiGet.mockReset(); + mockApiGet.mockResolvedValue({ tokens: [], count: 0 }); + }); + + it("shows empty state when no tokens exist", async () => { + renderTab(); + await flush(); + expect(document.body.textContent).toContain("No active tokens"); + }); +}); + +// ─── Token list ───────────────────────────────────────────────────────────── + +describe("TokensTab — token list", () => { + beforeEach(() => { + mockApiGet.mockReset(); + mockApiPost.mockReset(); + mockApiDel.mockReset(); + mockApiGet.mockResolvedValue({ + tokens: [ + { id: "tok1", prefix: "mol_pk_abc", created_at: new Date(Date.now() - 120 * 60 * 1000).toISOString(), last_used_at: null }, + { id: "tok2", prefix: "mol_pk_xyz", created_at: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString(), last_used_at: new Date(Date.now() - 60 * 60 * 1000).toISOString() }, + ], + count: 2, + }); + }); + + it("renders tokens when API returns them", async () => { + renderTab(); + await flush(); + expect(document.body.textContent).toContain("mol_pk_abc"); + expect(document.body.textContent).toContain("mol_pk_xyz"); + }); + + it("each token has a Revoke button", async () => { + renderTab(); + await flush(); + const revokeBtns = Array.from(document.querySelectorAll("button")).filter( + (b) => b.textContent === "Revoke", + ); + expect(revokeBtns).toHaveLength(2); + }); + + it("API get is called with correct workspaceId", async () => { + renderTab(); + await flush(); + expect(mockApiGet).toHaveBeenCalledWith(`/workspaces/${WS_ID}/tokens`); + }); + + it("revoke button opens ConfirmDialog", async () => { + renderTab(); + await flush(); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + const revokeBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent === "Revoke", + ) as HTMLButtonElement; + await act(async () => { + revokeBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(document.querySelector('[role="dialog"]')).toBeTruthy(); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain("Revoke Token"); + }); + + it("ConfirmDialog cancel closes the dialog", async () => { + renderTab(); + await flush(); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + const revokeBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent === "Revoke", + ) as HTMLButtonElement; + await act(async () => { + revokeBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(document.querySelector('[role="dialog"]')).toBeTruthy(); + const cancelBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent === "Cancel", + ) as HTMLButtonElement; + await act(async () => { + cancelBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + // API delete should NOT have been called + expect(mockApiDel).not.toHaveBeenCalled(); + }); + + it("ConfirmDialog confirm calls API del and re-fetches", async () => { + mockApiDel.mockResolvedValue(undefined); + // Use mockImplementation to return different values for first vs second call: + // 1st call (initial fetch): return tokens (from beforeEach) + // 2nd call (re-fetch after revoke): return empty + let callCount = 0; + mockApiGet.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ + tokens: [ + { id: "tok1", prefix: "mol_pk_abc", created_at: new Date(Date.now() - 120 * 60 * 1000).toISOString(), last_used_at: null }, + { id: "tok2", prefix: "mol_pk_xyz", created_at: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString(), last_used_at: new Date(Date.now() - 60 * 60 * 1000).toISOString() }, + ], + count: 2, + }); + } + return Promise.resolve({ tokens: [], count: 0 }); + }); + renderTab(); + await flush(); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(document.body.textContent).toContain("mol_pk_abc"); + const revokeBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent === "Revoke", + ) as HTMLButtonElement; + await act(async () => { + revokeBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(document.querySelector('[role="dialog"]')).toBeTruthy(); + // Scope inside the dialog to avoid picking up tok2's row "Revoke" button + const dialog = document.querySelector('[role="dialog"]') as Element; + const confirmBtn = Array.from(dialog.querySelectorAll("button")).find( + (b) => b.textContent === "Revoke", + ) as HTMLButtonElement; + await act(async () => { + confirmBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(mockApiDel).toHaveBeenCalledWith(`/workspaces/${WS_ID}/tokens/tok1`); + }); +}); + +// ─── Create token ───────────────────────────────────────────────────────────── + +describe("TokensTab — create token", () => { + beforeEach(() => { + mockApiGet.mockReset(); + mockApiPost.mockReset(); + mockApiGet.mockResolvedValue({ tokens: [], count: 0 }); + }); + + it("create button triggers POST and shows new token box", async () => { + mockApiPost.mockResolvedValue({ auth_token: "mol_pk_newtoken12345" }); + renderTab(); + await flush(); + expect(document.body.textContent).toContain("No active tokens"); + const createBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent?.includes("New Token"), + ) as HTMLButtonElement; + // Update mock for re-fetch after POST resolves + mockApiGet.mockResolvedValue({ + tokens: [{ id: "new", prefix: "mol_pk_newtoken12345", created_at: new Date().toISOString(), last_used_at: null }], + count: 1, + }); + await act(async () => { + createBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + expect(document.body.textContent).toContain("mol_pk_newtoken12345"); + expect(mockApiPost).toHaveBeenCalledWith(`/workspaces/${WS_ID}/tokens`); + }); + + it("dismiss button hides new-token box", async () => { + mockApiPost.mockResolvedValue({ auth_token: "mol_pk_test123" }); + renderTab(); + await flush(); + expect(document.body.textContent).toContain("No active tokens"); + mockApiGet.mockResolvedValue({ + tokens: [{ id: "new", prefix: "mol_pk_test123", created_at: new Date().toISOString(), last_used_at: null }], + count: 1, + }); + const createBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent?.includes("New Token"), + ) as HTMLButtonElement; + await act(async () => { + createBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + expect(document.body.textContent).toContain("New Token Created"); + const dismissBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent === "Dismiss", + ) as HTMLButtonElement; + await act(async () => { + dismissBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(document.body.textContent).not.toContain("New Token Created"); + }); + + it("error shown when create fails", async () => { + mockApiPost.mockRejectedValue(new Error("Server error")); + renderTab(); + await flush(); + expect(document.body.textContent).toContain("No active tokens"); + const createBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent?.includes("New Token"), + ) as HTMLButtonElement; + await act(async () => { + createBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(document.body.textContent).toContain("Server error"); + }); +}); + +// ─── Error state ───────────────────────────────────────────────────────────── + +describe("TokensTab — error", () => { + beforeEach(() => { + mockApiGet.mockReset(); + mockApiGet.mockRejectedValue(new Error("Network failure")); + }); + + it("shows error message when API fails", async () => { + renderTab(); + await flush(); + expect(document.body.textContent).toContain("Network failure"); + // Should NOT show spinner + expect(document.querySelector('[role="status"]')).toBeNull(); + }); +}); -- 2.45.2 From bf02575f347875c4795762fcdca253823c77d15e Mon Sep 17 00:00:00 2001 From: Molecule AI Core-UIUX Date: Tue, 12 May 2026 00:55:14 +0000 Subject: [PATCH 02/24] test(canvas/chat): add AttachmentTextPreview coverage (12 cases) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Vitest coverage for AttachmentTextPreview — inline text/code preview with streaming fetch and expand/truncate. Covers: - Loading skeleton (320x80) with aria-label - Ready state with correct text content - Filename shown in header - Expand button appears when lines > 10 - Expand button hidden when all lines shown - Expand button updates display to full content - Download button calls onDownload - tone=user -> blue/accent border - tone=agent -> neutral border - Truncated notice when file exceeds 256 KB - Error -> AttachmentChip fallback - Cleanup on unmount Co-Authored-By: Claude Opus 4.7 --- .../__tests__/AttachmentTextPreview.test.tsx | 419 ++++++++++++++++++ 1 file changed, 419 insertions(+) create mode 100644 canvas/src/components/tabs/chat/__tests__/AttachmentTextPreview.test.tsx 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..7354433e --- /dev/null +++ b/canvas/src/components/tabs/chat/__tests__/AttachmentTextPreview.test.tsx @@ -0,0 +1,419 @@ +// @vitest-environment jsdom +/** + * AttachmentTextPreview — inline text/code preview with expand + truncate. + * + * Uses a streaming fetch (ReadableStream) to read up to 256 KB of text. + * State machine: idle → loading → ready/error. Ready state shows a + * monospace preview of the first 10 lines, with an expand button when + * there are more. Shows a "truncated" note when the file exceeds 256 KB. + * Error falls back to AttachmentChip. + * + * NOTE: No @testing-library/jest-dom import — use DOM APIs for assertions. + * + * Covers: + * - Renders loading skeleton (320×80) with aria-label + * - Renders text preview with correct content in ready state + * - Shows filename in header + * - Expand button appears when lines > 10 + * - Expand button hidden when all lines shown + * - Expand button calls setExpanded(true) and button text updates + * - Download button calls onDownload + * - tone=user applies blue/accent border + * - tone=agent applies neutral border + * - Error state renders AttachmentChip fallback + * - Cleans up on unmount + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import React from "react"; + +import { AttachmentTextPreview } from "../AttachmentTextPreview"; +import type { ChatAttachment } from "../types"; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +const mockResolveAttachmentHref = vi.fn<(id: string, uri: string) => string>( + (id, uri) => `https://api.moleculesai.app/attachments/${uri}`, +); +const mockIsPlatformAttachment = vi.fn<(uri: string) => boolean>(() => true); + +vi.mock("../uploads", () => ({ + isPlatformAttachment: (uri: string) => mockIsPlatformAttachment(uri), + resolveAttachmentHref: (id: string, uri: string) => + mockResolveAttachmentHref(id, uri), +})); + +vi.mock("@/lib/api", () => ({ + platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }), +})); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeAttachment(name: string, size?: number): ChatAttachment { + return { name, uri: `workspace:/tmp/${name}`, size }; +} + +beforeEach(() => { + mockIsPlatformAttachment.mockReturnValue(true); + mockResolveAttachmentHref.mockReturnValue( + (id: string, uri: string) => `https://api.moleculesai.app/attachments/${uri}`, + ); +}); + +afterEach(() => { + cleanup(); +}); + +// ─── Fetch mock helpers ─────────────────────────────────────────────────────── + +/** + * Mock a streaming fetch that returns text content. + * Mimics ReadableStream.read() yielding text chunks. + */ +function mockFetchText(completeText: string) { + const encoder = new TextEncoder(); + const chunks: Uint8Array[] = []; + // Yield in 50-byte chunks + let offset = 0; + while (offset < completeText.length) { + chunks.push(encoder.encode(completeText.slice(offset, offset + 50))); + offset += 50; + } + let chunkIndex = 0; + const mockReader = { + read: vi.fn<() => Promise<{ done: boolean; value?: Uint8Array }>>( + async () => { + if (chunkIndex < chunks.length) { + return { done: false, value: chunks[chunkIndex++] }; + } + return { done: true }; + }, + ), + cancel: vi.fn(), + }; + const mockBody = { + getReader: vi.fn(() => mockReader), + }; + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + body: mockBody, + headers: new Map([["content-type", "text/plain"]]), + }) as unknown as Response, + ); + return mockReader; +} + +function mockFetchError() { + global.fetch = vi.fn(() => + Promise.resolve({ ok: false, status: 500 }) as unknown as Response, + ); +} + +/** + * Mock a fetch where body.getReader() returns null (no streaming body). + */ +function mockFetchTextNoBody(text: string) { + const encoder = new TextEncoder(); + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + body: null, + text: () => Promise.resolve(text), + headers: new Map([["content-type", "text/plain"]]), + }) as unknown as Response, + ); +} + +// ─── Loading / idle state ───────────────────────────────────────────────────── + +describe("AttachmentTextPreview — loading/idle", () => { + it("renders loading skeleton (320×80) with aria-label", () => { + mockFetchText("hello world"); + const att = makeAttachment("log.txt", 1024); + const { container } = render( + , + ); + const skeleton = container.querySelector('[aria-label]') as HTMLElement; + expect(skeleton?.getAttribute("aria-label")).toContain("log.txt"); + expect(skeleton?.getAttribute("aria-label")).toContain("Loading"); + expect(skeleton?.style.width).toBe("320px"); + expect(skeleton?.style.height).toBe("80px"); + }); +}); + +// ─── Ready state ─────────────────────────────────────────────────────────────── + +describe("AttachmentTextPreview — ready", () => { + beforeEach(() => { + mockFetchText("hello world"); + }); + + it("renders text preview with correct content", async () => { + mockFetchText("line1\nline2\nline3"); + const att = makeAttachment("log.txt"); + render( + , + ); + await vi.waitFor(() => { + const code = document.querySelector("code"); + expect(code).toBeTruthy(); + }); + const code = document.querySelector("code"); + expect(code?.textContent).toContain("line1"); + }); + + it("shows filename in header", async () => { + mockFetchText("hello"); + const att = makeAttachment("config.yaml"); + render( + , + ); + await vi.waitFor(() => { + expect(document.querySelector("code")).toBeTruthy(); + }); + // Header should contain the filename + const header = document.querySelector("code")?.closest("div"); + expect(header?.textContent).toContain("config.yaml"); + }); + + it("shows expand button when lines > 10", async () => { + const longText = Array.from({ length: 15 }, (_, i) => `line ${i + 1}`).join("\n"); + mockFetchText(longText); + const att = makeAttachment("long.txt"); + render( + , + ); + await vi.waitFor(() => { + const btn = document.querySelector("button"); + expect(btn).toBeTruthy(); + }); + // Should have a button saying "Show all N lines" + const btns = Array.from(document.querySelectorAll("button")); + const expandBtn = btns.find((b) => b.textContent?.includes("Show all")); + expect(expandBtn).toBeTruthy(); + expect(expandBtn?.textContent).toContain("15 lines"); + }); + + it("hides expand button when all lines shown (<= 10)", async () => { + const shortText = Array.from({ length: 5 }, (_, i) => `line ${i + 1}`).join("\n"); + mockFetchText(shortText); + const att = makeAttachment("short.txt"); + render( + , + ); + await vi.waitFor(() => { + expect(document.querySelector("code")).toBeTruthy(); + }); + const btns = Array.from(document.querySelectorAll("button")); + const expandBtn = btns.find((b) => b.textContent?.includes("Show all")); + expect(expandBtn).toBeUndefined(); + }); + + it("expand button updates button text to all lines", async () => { + const longText = Array.from({ length: 15 }, (_, i) => `line ${i + 1}`).join("\n"); + mockFetchText(longText); + const att = makeAttachment("long.txt"); + render( + , + ); + await vi.waitFor(() => { + const btns = Array.from(document.querySelectorAll("button")); + expect(btns.find((b) => b.textContent?.includes("Show all"))).toBeTruthy(); + }); + const btns = Array.from(document.querySelectorAll("button")); + const expandBtn = btns.find((b) => b.textContent?.includes("Show all")) as HTMLButtonElement; + expandBtn.click(); + await vi.waitFor(() => { + const newBtns = Array.from(document.querySelectorAll("button")); + expect(newBtns.find((b) => b.textContent?.includes("Show all"))).toBeUndefined(); + }); + }); + + it("download button calls onDownload", async () => { + mockFetchText("hello"); + const onDownload = vi.fn(); + const att = makeAttachment("log.txt"); + render( + , + ); + await vi.waitFor(() => { + expect(document.querySelector("code")).toBeTruthy(); + }); + // Find the download button (aria-label contains "Download") + const downloadBtn = document.querySelector('[aria-label^="Download"]') as HTMLButtonElement; + expect(downloadBtn).toBeTruthy(); + downloadBtn.click(); + expect(onDownload).toHaveBeenCalledWith(att); + }); + + it("tone=user applies blue/accent border classes", async () => { + mockFetchText("hello"); + const att = makeAttachment("log.txt"); + const { container } = render( + , + ); + await vi.waitFor(() => { + expect(document.querySelector("code")).toBeTruthy(); + }); + const rootDiv = container.firstChild as HTMLElement; + expect(rootDiv.className).toContain("border-blue-400"); + expect(rootDiv.className).toContain("accent-strong"); + }); + + it("tone=agent applies neutral border class (no blue)", async () => { + mockFetchText("hello"); + const att = makeAttachment("log.txt"); + const { container } = render( + , + ); + await vi.waitFor(() => { + expect(document.querySelector("code")).toBeTruthy(); + }); + const rootDiv = container.firstChild as HTMLElement; + expect(rootDiv.className).not.toContain("border-blue-400"); + }); +}); + +// ─── Truncated state ─────────────────────────────────────────────────────────── + +describe("AttachmentTextPreview — truncated", () => { + it("shows truncated notice when file exceeds 256 KB", async () => { + // Simulate a response where the reader yields chunks until MAX_FETCH_BYTES (256KB) + const encoder = new TextEncoder(); + const bytesNeeded = 256 * 1024; + const mockReader = { + read: vi.fn<() => Promise<{ done: boolean; value?: Uint8Array }>>( + async () => { + // Return one chunk that's >= 256KB total (we'll cap at MAX_FETCH_BYTES) + const chunk = encoder.encode("x".repeat(300 * 1024)); + return { done: false, value: chunk }; + }, + ), + cancel: vi.fn(), + }; + const mockBody = { getReader: vi.fn(() => mockReader) }; + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + body: mockBody, + headers: new Map([["content-type", "text/plain"]]), + }) as unknown as Response, + ); + const att = makeAttachment("huge.log"); + render( + , + ); + await vi.waitFor(() => { + const truncated = document.querySelector("code"); + expect(truncated).toBeTruthy(); + }); + // Should show truncated notice + const truncatedNote = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent?.includes("download full file"), + ); + expect(truncatedNote).toBeTruthy(); + }); +}); + +// ─── Error state ─────────────────────────────────────────────────────────────── + +describe("AttachmentTextPreview — error", () => { + it("renders AttachmentChip fallback when fetch fails", async () => { + mockFetchError(); + const onDownload = vi.fn(); + const att = makeAttachment("broken.txt", 256); + render( + , + ); + await vi.waitFor(() => { + const chip = document.querySelector("button"); + expect(chip).toBeTruthy(); + expect(chip?.textContent).toContain("broken.txt"); + }); + const chip = document.querySelector("button") as HTMLButtonElement; + chip.click(); + expect(onDownload).toHaveBeenCalledWith(att); + }); +}); + +// ─── Cleanup ────────────────────────────────────────────────────────────────── + +describe("AttachmentTextPreview — cleanup", () => { + it("cleans up on unmount", async () => { + mockFetchText("hello"); + const att = makeAttachment("log.txt"); + const { unmount } = render( + , + ); + await vi.waitFor(() => { + expect(document.querySelector("code")).toBeTruthy(); + }); + expect(document.querySelector("code")).toBeTruthy(); + unmount(); + expect(document.querySelector("code")).toBeNull(); + }); +}); -- 2.45.2 From a593e73784b7ccc522bc890cfa006368012be603 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-UIUX Date: Tue, 12 May 2026 00:50:39 +0000 Subject: [PATCH 03/24] test(canvas/chat): add AttachmentAudio + AttachmentPDF coverage (18 cases) Adds Vitest coverage for two missing attachment renderers: AttachmentAudio (9 cases): - Loading skeleton (280x40) with aria-label -