Files
multica/packages/views/editor/attachment-card.test.tsx
Naiyuan Qing 5f1ced867c feat(editor): HTML attachments render like images (MUL-2345 v4) (#2798)
* feat(editor): HTML attachments render like images (MUL-2345 v4)

HTML attachments no longer wear the file-card chrome (icon + filename
row). They now render as a sandboxed iframe with a hover-revealed
right-top toolbar (Open / Download / Copy code), mirroring the image
attachment visual model.

- New HtmlAttachmentPreview owns the iframe + hover toolbar plus three
  states (loading / success / error). Failure mode keeps the toolbar
  pinned open and Open/Download enabled so the user is never stranded
  without an escape hatch — Copy code disables when the text body is
  unavailable.
- New AttachmentBlock thin dispatcher picks the renderer per kind:
  html + attachmentId + !uploading -> HtmlAttachmentPreview, else
  AttachmentCard. All three entry points (file-card NodeView, readonly
  file-card, standalone AttachmentList) call AttachmentBlock, so feature
  work on a new kind only touches one place.
- AttachmentCard collapses back to a pure file-card row UI: the inline
  HTML iframe branch (InlineHtmlIframe + inlineHtmlEnabled +
  showInlineHtml) is removed.
- AttachmentBlock added to the editor barrel export.

Sandbox/server-side defenses unchanged: sandbox="allow-scripts" (no
allow-same-origin), srcDoc, server still returns text/plain + nosniff
on the /content proxy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(editor): pin three entry points to AttachmentBlock HTML route (MUL-2345)

Reviewer flagged that the v4 dispatcher refactor only had tests on the
shared AttachmentBlock + HtmlAttachmentPreview; the three real call
sites at file-card.tsx:59, readonly-content.tsx:279, and
comment-card.tsx:152 had no regression coverage. Reverting any one
would silently lose the inline HTML iframe path — the exact MUL-2330
regression we're meant to be locking down.

Each new test renders the real entry point with an HTML+attachmentId
fixture and asserts the dispatched iframe (sandbox=allow-scripts,
srcdoc) shows up while the AttachmentCard chrome (filename row) does
not. FileCardView and AttachmentList are exported from their files for
direct rendering, mirroring the existing CodeBlockView test pattern.

Mutation-tested locally: temporarily flipping each site back to
<AttachmentCard> turns its corresponding test red.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 17:44:32 +08:00

142 lines
4.5 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
vi.mock("../i18n", () => ({
useT: () => ({
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
sel({
image: { download: "Download" },
attachment: {
preview: "Preview",
preview_loading: "Loading preview…",
},
file_card: { uploading: "Uploading {{filename}}" },
}),
}),
}));
import { AttachmentCard } from "./attachment-card";
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.restoreAllMocks());
describe("AttachmentCard — chrome row", () => {
it("renders chrome only and never an inline iframe (HTML rich preview lives in HtmlAttachmentPreview)", () => {
render(
<AttachmentCard
filename="report.html"
contentType="text/html"
attachmentId="att-1"
href="https://cdn.example/report.html"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
expect(screen.getByText("report.html")).toBeTruthy();
expect(document.querySelector("iframe")).toBeNull();
});
it("hides the Eye button for an html URL-only source (the modal's /content proxy is ID-keyed)", () => {
// Regression: a cross-comment / copy-pasted `!file[report.html](url)`
// used to surface a dead Eye button — text kinds need an attachmentId,
// otherwise tryOpen rejects and the click becomes a silent no-op.
render(
<AttachmentCard
filename="report.html"
contentType="text/html"
href="https://cdn.example/report.html"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
expect(screen.queryByTitle("Preview")).toBeNull();
// Download stays available — the underlying URL is still reachable.
expect(screen.getByTitle("Download")).toBeTruthy();
});
it("shows the Eye button for an html source when an attachmentId is available", () => {
render(
<AttachmentCard
filename="report.html"
contentType="text/html"
attachmentId="att-1"
href="https://cdn.example/report.html"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
expect(screen.getByTitle("Preview")).toBeTruthy();
});
it("shows the Eye button for a URL-only pdf source (modal renders pdfs directly from URL)", () => {
// Counterpart to the html regression: media kinds (pdf/video/audio)
// ARE URL-previewable because the modal renders them via
// <iframe src=url>/<video>/<audio>, not via the /content proxy.
render(
<AttachmentCard
filename="manual.pdf"
contentType="application/pdf"
href="https://cdn.example/manual.pdf"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
expect(screen.getByTitle("Preview")).toBeTruthy();
});
});
describe("AttachmentCard — Eye / Download buttons", () => {
it("invokes onPreview when Eye is clicked", () => {
const onPreview = vi.fn();
render(
<AttachmentCard
filename="manual.pdf"
contentType="application/pdf"
attachmentId="att-1"
href="https://cdn.example/manual.pdf"
onPreview={onPreview}
onDownload={() => {}}
/>,
);
fireEvent.mouseDown(screen.getByTitle("Preview"));
expect(onPreview).toHaveBeenCalled();
});
it("invokes onDownload when Download is clicked", () => {
const onDownload = vi.fn();
render(
<AttachmentCard
filename="manual.pdf"
contentType="application/pdf"
attachmentId="att-1"
href="https://cdn.example/manual.pdf"
onPreview={() => {}}
onDownload={onDownload}
/>,
);
fireEvent.mouseDown(screen.getByTitle("Download"));
expect(onDownload).toHaveBeenCalled();
});
it("hides Eye and Download buttons while uploading", () => {
render(
<AttachmentCard
filename="report.html"
contentType="text/html"
attachmentId="att-1"
href="https://cdn.example/report.html"
uploading
onPreview={() => {}}
onDownload={() => {}}
/>,
);
expect(screen.queryByTitle("Preview")).toBeNull();
expect(screen.queryByTitle("Download")).toBeNull();
// The mock `t()` returns the i18n template as-is; the production t-fn
// interpolates {{filename}} → "report.html". Asserting the template
// proves the uploading branch was selected without depending on the
// interpolation behavior of the mock.
expect(screen.getByText("Uploading {{filename}}")).toBeTruthy();
});
});