diff --git a/packages/views/editor/extensions/file-card.test.tsx b/packages/views/editor/extensions/file-card.test.tsx new file mode 100644 index 000000000..7d0ab9904 --- /dev/null +++ b/packages/views/editor/extensions/file-card.test.tsx @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +// Tiptap NodeView primitives can't be instantiated without a full editor. +// Stub the wrapper so FileCardView renders as a plain React component and +// the DOM can be inspected directly. +vi.mock("@tiptap/react", () => ({ + NodeViewWrapper: ({ children, ...rest }: any) =>
{children}
, +})); + +const { getAttachmentTextContentMock, resolveAttachmentMock, openByUrlMock, tryOpenMock } = + vi.hoisted(() => ({ + getAttachmentTextContentMock: vi.fn(), + resolveAttachmentMock: vi.fn(), + openByUrlMock: vi.fn(), + tryOpenMock: vi.fn(), + })); + +vi.mock("@multica/core/api", () => ({ + api: { getAttachmentTextContent: getAttachmentTextContentMock }, + PreviewTooLargeError: class extends Error {}, + PreviewUnsupportedError: class extends Error {}, +})); + +vi.mock("../attachment-download-context", () => ({ + useAttachmentDownloadResolver: () => ({ + openByUrl: openByUrlMock, + resolveAttachment: resolveAttachmentMock, + }), +})); + +vi.mock("../attachment-preview-modal", () => ({ + useAttachmentPreview: () => ({ tryOpen: tryOpenMock, open: vi.fn(), modal: null }), +})); + +vi.mock("../i18n", () => ({ + useT: () => ({ + t: (sel: (s: Record>) => string) => + sel({ + image: { download: "Download" }, + attachment: { + preview: "Preview", + preview_loading: "Loading preview…", + preview_failed: "Couldn't load preview", + }, + code_block: { copy_code: "Copy code" }, + file_card: { uploading: "Uploading {{filename}}" }, + }), + }), +})); + +import { FileCardView } from "./file-card"; + +function renderWithQuery(ui: ReactElement) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return render({ui}); +} + +beforeEach(() => vi.clearAllMocks()); +afterEach(() => vi.restoreAllMocks()); + +describe("FileCardView — HTML attachment routes through AttachmentBlock to iframe", () => { + // Regression pin for file-card.tsx:59. The NodeView must render through + // , not the older . If someone reverts that + // line, the dispatcher's html+attachmentId branch is bypassed and the user + // is left with the file-card chrome — exactly the bug MUL-2330 surfaced. + it("renders an iframe (no file-card chrome) when the node resolves to an HTML attachment", async () => { + resolveAttachmentMock.mockReturnValue({ + id: "att-1", + content_type: "text/html", + url: "/uploads/report.html", + filename: "report.html", + }); + getAttachmentTextContentMock.mockResolvedValueOnce({ + text: "

chart

", + originalContentType: "text/html", + }); + + const node = { + attrs: { + href: "/uploads/report.html", + filename: "report.html", + uploading: false, + }, + } as any; + + renderWithQuery(); + + const frame = await waitFor(() => { + const f = document.querySelector("iframe") as HTMLIFrameElement | null; + expect(f).toBeTruthy(); + return f!; + }); + expect(frame.getAttribute("sandbox")).toBe("allow-scripts"); + expect(frame.getAttribute("srcdoc")).toContain("

chart

"); + // The AttachmentCard chrome surfaces the filename as text inside its row. + // HtmlAttachmentPreview replaces the chrome entirely, so the filename + // must not appear as visible text. + expect(screen.queryByText("report.html")).toBeNull(); + }); +}); diff --git a/packages/views/editor/extensions/file-card.tsx b/packages/views/editor/extensions/file-card.tsx index cb42b5207..fe7285a5e 100644 --- a/packages/views/editor/extensions/file-card.tsx +++ b/packages/views/editor/extensions/file-card.tsx @@ -31,7 +31,7 @@ const FILE_CARD_MARKDOWN_RE = new RegExp( // React NodeView // --------------------------------------------------------------------------- -function FileCardView({ node }: NodeViewProps) { +export function FileCardView({ node }: NodeViewProps) { const href = (node.attrs.href as string) || ""; const filename = (node.attrs.filename as string) || ""; const uploading = node.attrs.uploading as boolean; diff --git a/packages/views/editor/readonly-content.test.tsx b/packages/views/editor/readonly-content.test.tsx index c87948b6e..9e30b6085 100644 --- a/packages/views/editor/readonly-content.test.tsx +++ b/packages/views/editor/readonly-content.test.tsx @@ -1,5 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, waitFor } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const { getAttachmentTextContentMock } = vi.hoisted(() => ({ + getAttachmentTextContentMock: vi.fn(), +})); + +vi.mock("@multica/core/api", () => ({ + api: { getAttachmentTextContent: getAttachmentTextContentMock }, + PreviewTooLargeError: class extends Error {}, + PreviewUnsupportedError: class extends Error {}, +})); vi.mock("@multica/core/paths", () => ({ useWorkspacePaths: () => ({ @@ -253,3 +265,47 @@ describe("ReadonlyContent HTML block rendering", () => { ).not.toBeNull(); }); }); + +describe("ReadonlyContent file-card → AttachmentBlock HTML routing", () => { + // Regression pin for readonly-content.tsx:279. The `div data-type=fileCard` + // branch must render through , not the older + // . Reverting that line would skip the html+attachmentId + // dispatcher branch and surface the bare file-card chrome (filename row) + // instead of the rendered iframe — the exact regression MUL-2330 fixed. + function renderWithQuery(ui: ReactElement) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return render({ui}); + } + + it("renders the !file[](url) HTML attachment as an iframe (no file-card chrome)", async () => { + getAttachmentTextContentMock.mockResolvedValueOnce({ + text: "

chart

", + originalContentType: "text/html", + }); + const attachment = { + id: "att-1", + url: "/uploads/report.html", + filename: "report.html", + content_type: "text/html", + size_bytes: 0, + } as any; + const { container, queryByText } = renderWithQuery( + , + ); + const frame = await waitFor(() => { + const f = container.querySelector("iframe"); + expect(f).not.toBeNull(); + return f!; + }); + expect(frame.getAttribute("sandbox")).toBe("allow-scripts"); + expect(frame.getAttribute("srcdoc")).toContain("

chart

"); + // AttachmentCard chrome surfaces the filename as visible text in a + //

row. HtmlAttachmentPreview replaces it entirely. + expect(queryByText("report.html")).toBeNull(); + }); +}); diff --git a/packages/views/issues/components/comment-card.test.tsx b/packages/views/issues/components/comment-card.test.tsx new file mode 100644 index 000000000..2a7b93dd9 --- /dev/null +++ b/packages/views/issues/components/comment-card.test.tsx @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const { getAttachmentTextContentMock } = vi.hoisted(() => ({ + getAttachmentTextContentMock: vi.fn(), +})); + +vi.mock("@multica/core/api", () => ({ + api: { + getAttachmentTextContent: getAttachmentTextContentMock, + getAttachment: vi.fn(), + }, + PreviewTooLargeError: class extends Error {}, + PreviewUnsupportedError: class extends Error {}, +})); + +import { AttachmentList } from "./comment-card"; + +function renderWithQuery(ui: ReactElement) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return render({ui}); +} + +beforeEach(() => vi.clearAllMocks()); +afterEach(() => vi.restoreAllMocks()); + +describe("AttachmentList — standalone HTML attachment routes through AttachmentBlock", () => { + // Regression pin for comment-card.tsx:152. This is the entry point + // MUL-2330 originally regressed on: standalone HTML attachments (not + // referenced inline in the markdown body) MUST render through + // so the html+attachmentId dispatch fires. Reverting to + // here re-introduces the "report.html shows as a bare + // file card row instead of the rendered chart" bug. + it("renders an iframe (no file-card chrome) for a standalone HTML attachment", async () => { + getAttachmentTextContentMock.mockResolvedValueOnce({ + text: "

chart

", + originalContentType: "text/html", + }); + const attachment = { + id: "att-1", + url: "/uploads/report.html", + filename: "report.html", + content_type: "text/html", + size_bytes: 0, + } as any; + + renderWithQuery(); + + const frame = await waitFor(() => { + const f = document.querySelector("iframe") as HTMLIFrameElement | null; + expect(f).toBeTruthy(); + return f!; + }); + expect(frame.getAttribute("sandbox")).toBe("allow-scripts"); + expect(frame.getAttribute("srcdoc")).toContain("

chart

"); + // AttachmentCard chrome would render the filename as visible

text; + // HtmlAttachmentPreview replaces the row entirely. + expect(screen.queryByText("report.html")).toBeNull(); + }); +}); diff --git a/packages/views/issues/components/comment-card.tsx b/packages/views/issues/components/comment-card.tsx index 83fc557b3..47f2a6905 100644 --- a/packages/views/issues/components/comment-card.tsx +++ b/packages/views/issues/components/comment-card.tsx @@ -121,7 +121,7 @@ function DeleteCommentDialog({ // Standalone attachment list — renders attachments not already in the markdown // --------------------------------------------------------------------------- -function AttachmentList({ attachments, content, className }: { attachments?: Attachment[]; content?: string; className?: string }) { +export function AttachmentList({ attachments, content, className }: { attachments?: Attachment[]; content?: string; className?: string }) { const download = useDownloadAttachment(); const preview = useAttachmentPreview(); if (!attachments?.length) return null;