1
2
20(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
21import { act } from "react";
22import { createRoot, type Root } from "react-dom/client";
23import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
24
25const queueChange = vi.fn();
26const saveNow = vi.fn(async () => {});
27
28vi.mock("next/link", () => ({ default: (p: object) => p }));
29vi.mock("@tanstack/react-query", () => ({
30 useQuery: () => ({ data: undefined }),
31 useInfiniteQuery: () => ({ data: undefined }),
32 useQueryClient: () => ({ invalidateQueries: vi.fn() }),
33}));
34vi.mock("@/lib/api", () => ({
35 apiFetch: vi.fn(async () => ({ data: [] })),
36 blockApi: { update: vi.fn(async () => ({})) },
37 uploadFile: vi.fn(),
38 isApiError: () => false,
39}));
40vi.mock("@/components/database/db-embedded", () => ({ DatabaseView: () => null }));
41vi.mock("@/components/files/FileBlock", () => ({ FileBlock: () => null }));
42vi.mock("@/components/form/form-renderer", () => ({ FormRenderer: () => null }));
43vi.mock("@/components/editor/inline-editor", () => ({ InlineEditor: () => null }));
44vi.mock("@/components/editor/block-save-queue", () => ({
45 useBlockSaveQueue: () => ({ queueChange, saveNow }),
46}));
47
48import { CodeBlock } from "../block-renderer";
49import type { Block } from "@/lib/types";
50
51const CODE = 'const x = 42;\nfunction f() { return x; }';
52
53function makeBlock(over: Partial<Block> = {}): Block {
54 return {
55 id: "blk_code_1",
56 pageId: "page_1",
57 parentId: null,
58 type: "code",
59 position: "a0",
60 content: CODE,
61 properties: { language: "javascript" },
62 updatedAt: "2026-01-01T00:00:00.000Z",
63 createdAt: "2026-01-01T00:00:00.000Z",
64 ...over,
65 };
66}
67
68let container: HTMLElement | null = null;
69let root: Root | null = null;
70
71function render(block: Block) {
72 container = document.createElement("div");
73 document.body.appendChild(container);
74 root = createRoot(container);
75 act(() => {
76 root!.render(<CodeBlock block={block} />);
77 });
78}
79
80afterEach(() => {
81 act(() => root?.unmount());
82 container?.remove();
83 container = null;
84 root = null;
85 vi.clearAllMocks();
86});
87
88describe("CodeBlock (syntax highlighting)", () => {
89 it("display mode renders Prism token spans (colorful without interaction)", () => {
90 render(makeBlock());
91 const code = container!.querySelector("pre code")!;
92 expect(code, "expected a <pre><code> display").toBeTruthy();
93 const spans = code!.querySelectorAll("span.token");
94 expect(spans.length, "expected token spans, got plain text").toBeGreaterThan(0);
95 expect(code!.querySelector('span.token.keyword')?.textContent).toBe("const");
96
97 expect(code!.textContent).toContain("const x = 42;");
98
99 expect(container!.querySelector("textarea")).toBeNull();
100 });
101
102 it("display mode keeps plaintext raw (no token spans)", () => {
103 render(makeBlock({ content: "just words", properties: { language: "plaintext" } }));
104 const code = container!.querySelector("pre code")!;
105 expect(code!.querySelector("span.token")).toBeNull();
106 expect(code!.textContent).toBe("just words");
107 });
108
109 it("edit mode: typing re-highlights the overlay live and queues the change", () => {
110 render(makeBlock());
111
112 const pre = container!.querySelector("pre")!;
113 act(() => {
114 pre!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
115 });
116 const ta = container!.querySelector("textarea")!;
117 expect(ta, "expected edit-mode textarea").toBeTruthy();
118
119
120 const overlay = container!.querySelector("pre[aria-hidden] code")!;
121 expect(overlay!.querySelectorAll("span.token").length).toBeGreaterThan(0);
122
123 const newText = "let y = \"hi\"; // typed";
124 const setNativeValue = Object.getOwnPropertyDescriptor(
125 window.HTMLTextAreaElement.prototype,
126 "value"
127 )!.set!;
128 act(() => {
129 setNativeValue.call(ta, newText);
130 ta!.dispatchEvent(new Event("input", { bubbles: true }));
131 });
132
133 expect(overlay!.innerHTML).toContain("token string");
134 expect(overlay!.innerHTML).toContain("token comment");
135
136 expect(queueChange).toHaveBeenCalledWith("blk_code_1", newText);
137 });
138
139 it("commit on blur persists via saveNow and returns to the highlighted display", () => {
140 render(makeBlock());
141 const pre = container!.querySelector("pre")!;
142 act(() => pre!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
143 const ta = container!.querySelector("textarea")!;
144
145 const setNativeValue = Object.getOwnPropertyDescriptor(
146 window.HTMLTextAreaElement.prototype,
147 "value"
148 )!.set!;
149 act(() => {
150 setNativeValue.call(ta, "const z = 7;");
151 ta!.dispatchEvent(new Event("input", { bubbles: true }));
152 });
153
154 act(() => {
155 ta!.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
156 });
157 expect(saveNow).toHaveBeenCalledWith("blk_code_1", "const z = 7;");
158
159 expect(container!.querySelector("textarea")).toBeNull();
160 const code = container!.querySelector("pre code")!;
161 expect(code!.querySelectorAll("span.token").length).toBeGreaterThan(0);
162 });
163
164 it("Escape commits from the editor (no blur needed)", () => {
165 render(makeBlock());
166 const pre = container!.querySelector("pre")!;
167 act(() => pre!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
168 const ta = container!.querySelector("textarea")!;
169 act(() => {
170 ta!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
171 });
172 expect(saveNow).toHaveBeenCalledTimes(1);
173 expect(saveNow).toHaveBeenCalledWith("blk_code_1", CODE);
174 });
175});
176