Files
multica/packages/views/editor/extensions/code-block-view.test.tsx
Naiyuan Qing ceb967aefa feat(editor): inline HTML attachment preview + ```html block render (MUL-2345) (#2790)
* feat(editor): inline HTML attachment preview + ```html block render (MUL-2345)

* attachment-preview-modal: switch HTML iframe sandbox from "" to
  "allow-scripts" so JS-driven chart libraries render. The opaque-origin
  iframe still cannot touch cookies, localStorage, parent state, or
  top-nav — only scripts run.
* New shared AttachmentCard wired into the three attachment surfaces
  (file-card NodeView, ReadonlyContent file-card branch, comment-card
  standalone AttachmentList). HTML attachments now render inline via a
  sandboxed iframe pulled through the existing /content proxy; other
  kinds keep the original chrome behavior.
* New HtmlBlockPreview for fenced ```html blocks in ReadonlyContent —
  default preview iframe, source/Copy toggle. Two-layer code+pre unwrap
  mirrors the Mermaid pattern; unwrap now matches on language-* class
  because react-markdown invokes pre before the code renderer runs.
* CodeBlockView (Tiptap NodeView) renders an iframe preview for
  language=html with a CSS-hidden toggle to the editable source — the
  <NodeViewContent as="code"/> mount must remain in the tree.
* Shared use-attachment-html-text hook keeps inline and modal HTML
  rendering on the same React Query cache.
* Vitest coverage: allow-scripts assertion, attachment-card kind
  branches, readonly HTML iframe + Mermaid unwrap regression, NodeView
  editable + preview/source toggle.

No backend changes; server-side text/plain + nosniff defense kept.

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

* fix(editor): tighten attachment preview and pre unwrap gates (MUL-2345)

Addresses Reviewer REQUEST CHANGES on PR #2790:

1. URL-only text/html attachment cards no longer surface a dead Eye
   button. `AttachmentCard` previously allowed preview when
   `previewableFromUrl=true` regardless of kind, but the modal's
   `tryOpen` rejects URL-only text kinds because the `/content` proxy
   is ID-keyed. Drop the `previewableFromUrl` prop and gate the
   no-attachmentId path strictly to URL-previewable media kinds
   (pdf/video/audio).

2. Readonly `pre` unwrap now uses exact class-token matching. The
   previous `className.includes("language-html")` check also fired
   on `language-htmlbars`, silently stripping its `<pre>` wrapper.
   Use `/(^|\s)language-(html|mermaid)(\s|$)/` so only the exact
   tokens unwrap.

Regression tests:
- `report.html + no attachmentId` asserts no Preview button.
- `pdf URL-only` asserts Preview button still appears.
- `htmlbars` / `mermaidx` fences keep their `<pre><code>` wrapper.

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 16:23:40 +08:00

99 lines
3.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
// Tiptap's NodeView primitives are hard to instantiate in jsdom without a
// full editor. Stub them so the test can render <CodeBlockView /> as a plain
// React component and inspect the resulting DOM shape.
vi.mock("@tiptap/react", () => {
const NodeViewWrapper = ({ children, ...rest }: any) => (
<div data-testid="nvw" {...rest}>
{children}
</div>
);
// The real NodeViewContent renders an element managed by ProseMirror. For
// the test it's enough to surface a sentinel element so we can assert it
// remains mounted while CSS-hidden.
const NodeViewContent = ({ as = "div", ...rest }: any) => {
const Tag = as;
return <Tag data-testid="nvc" {...rest} />;
};
return { NodeViewWrapper, NodeViewContent };
});
vi.mock("../mermaid-diagram", () => ({
MermaidDiagram: () => null,
}));
vi.mock("../../i18n", () => ({
useT: () => ({
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
sel({
code_block: {
copy_code: "Copy code",
show_preview: "Show preview",
show_source: "Show source",
},
}),
}),
}));
import { CodeBlockView } from "./code-block-view";
function makeProps(language: string, text: string) {
return {
node: {
attrs: { language },
textContent: text,
},
} as unknown as Parameters<typeof CodeBlockView>[0];
}
describe("CodeBlockView — html language toggle", () => {
// Inner async timers in useDebouncedValue make the iframe srcDoc lag by
// ~200ms; use fake timers so the test stays deterministic.
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
it("defaults to preview view: renders an iframe with sandbox='allow-scripts' and keeps the <pre> mounted (hidden)", () => {
render(<CodeBlockView {...makeProps("html", "<p>hello</p>")} />);
act(() => {
vi.advanceTimersByTime(250);
});
const frame = document.querySelector("iframe");
expect(frame).toBeTruthy();
expect(frame?.getAttribute("sandbox")).toBe("allow-scripts");
// NodeViewContent (and its enclosing <pre>) MUST remain mounted —
// unmounting would break Tiptap's bindings and prevent editing.
const nvc = screen.getByTestId("nvc");
expect(nvc).toBeTruthy();
const pre = nvc.closest("pre");
expect(pre).toBeTruthy();
expect(pre?.className).toContain("sr-only");
});
it("toggles to source view: iframe is removed and the <pre> is no longer hidden", () => {
render(<CodeBlockView {...makeProps("html", "<p>hello</p>")} />);
act(() => {
vi.advanceTimersByTime(250);
});
expect(document.querySelector("iframe")).toBeTruthy();
const toggle = screen.getByTitle("Show source");
fireEvent.click(toggle);
expect(document.querySelector("iframe")).toBeNull();
const nvc = screen.getByTestId("nvc");
const pre = nvc.closest("pre")!;
expect(pre.className).not.toContain("sr-only");
});
it("does not show the toggle or an iframe for a non-html language", () => {
render(<CodeBlockView {...makeProps("typescript", "const x = 1;")} />);
expect(screen.queryByTitle("Show source")).toBeNull();
expect(screen.queryByTitle("Show preview")).toBeNull();
expect(document.querySelector("iframe")).toBeNull();
});
});