@tangle-network/ui 11.51.7 → 11.51.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/package.json +1 -1
- package/src/editor/source-markdown-editor.test.tsx +7 -7
- package/src/editor/document-editor-pane.test.tsx +0 -60
- package/src/editor/editor-lazy.test.tsx +0 -102
- package/src/editor/editor-peers.test.ts +0 -250
- package/src/editor/editor-provider.test.tsx +0 -226
- package/src/editor/markdown-document-editor.test.tsx +0 -126
- package/src/editor/tiptap-editor.test.tsx +0 -228
- package/src/files/file-artifact-pane.test.tsx +0 -301
- package/src/files/file-card.test.tsx +0 -62
- package/src/files/file-list.test.tsx +0 -81
- package/src/files/pdf-loader.test.ts +0 -130
- package/src/files/pdf-viewer.test.tsx +0 -245
- package/src/files/rich-file-tree.test.tsx +0 -90
- package/src/markdown/api-presentation.test.tsx +0 -63
- package/src/markdown/code-block.test.tsx +0 -201
- package/src/markdown/markdown-table.test.tsx +0 -76
- package/src/markdown/markdown.test.tsx +0 -166
- package/src/openui/schema.test.tsx +0 -158
- package/src/primitives/button.test.tsx +0 -230
- package/src/primitives/card.test.tsx +0 -71
- package/src/primitives/chip.test.tsx +0 -200
- package/src/primitives/date-picker.test.tsx +0 -146
- package/src/primitives/dialog.test.tsx +0 -75
- package/src/primitives/input.test.tsx +0 -161
- package/src/primitives/interaction-states.test.tsx +0 -287
- package/src/primitives/pagination-alert.test.tsx +0 -180
- package/src/primitives/popover-collapsible-alert-dialog-context-menu-slider.test.tsx +0 -165
- package/src/primitives/radio-group.test.tsx +0 -26
- package/src/primitives/segmented-control.test.tsx +0 -451
- package/src/primitives/table.test.tsx +0 -72
- package/src/primitives/theme-toggle.test.tsx +0 -168
- package/src/primitives/toast.test.tsx +0 -130
- package/src/primitives/wizard.test.tsx +0 -106
- package/src/run/expandable-content.test.tsx +0 -52
|
@@ -1,245 +0,0 @@
|
|
|
1
|
-
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
-
import { MissingPdfjsError, loadPdfjs, type PdfjsLib } from "./pdf-loader";
|
|
4
|
-
import { PdfViewer } from "./pdf-viewer";
|
|
5
|
-
|
|
6
|
-
vi.mock("./pdf-loader", async (importOriginal) => ({
|
|
7
|
-
...(await importOriginal<typeof import("./pdf-loader")>()),
|
|
8
|
-
loadPdfjs: vi.fn(),
|
|
9
|
-
}));
|
|
10
|
-
|
|
11
|
-
const BLOB_URL = "blob:https://app.tangle.tools/9a8b7c";
|
|
12
|
-
/** US Letter in points. */
|
|
13
|
-
const LETTER = { width: 612, height: 792 };
|
|
14
|
-
const CSS_PER_POINT = 96 / 72;
|
|
15
|
-
|
|
16
|
-
/** A pdf.js stand-in: pages of US Letter that render at once. */
|
|
17
|
-
function fakePdfjs({ numPages = 3, fail }: { numPages?: number; fail?: Error } = {}) {
|
|
18
|
-
const render = vi.fn(() => ({ promise: Promise.resolve(), cancel: vi.fn() }));
|
|
19
|
-
const page = {
|
|
20
|
-
getViewport: ({ scale }: { scale: number }) => ({
|
|
21
|
-
width: LETTER.width * scale,
|
|
22
|
-
height: LETTER.height * scale,
|
|
23
|
-
}),
|
|
24
|
-
render,
|
|
25
|
-
cleanup: vi.fn(),
|
|
26
|
-
};
|
|
27
|
-
const doc = { numPages, getPage: vi.fn(async () => page) };
|
|
28
|
-
const destroy = vi.fn(async () => {});
|
|
29
|
-
const getDocument = vi.fn(() => ({
|
|
30
|
-
promise: fail ? Promise.reject(fail) : Promise.resolve(doc),
|
|
31
|
-
destroy,
|
|
32
|
-
onProgress: undefined,
|
|
33
|
-
}));
|
|
34
|
-
const pdfjs = { getDocument } as unknown as PdfjsLib;
|
|
35
|
-
return { pdfjs, getDocument, destroy, render };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function namedError(name: string, extra: Record<string, unknown> = {}): Error {
|
|
39
|
-
return Object.assign(new Error(name), { name }, extra);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const pageInput = () => screen.getByRole("textbox", { name: /^Page/ });
|
|
43
|
-
|
|
44
|
-
let consoleError: ReturnType<typeof vi.spyOn>;
|
|
45
|
-
let consoleWarn: ReturnType<typeof vi.spyOn>;
|
|
46
|
-
const realFetch = globalThis.fetch;
|
|
47
|
-
|
|
48
|
-
beforeEach(() => {
|
|
49
|
-
consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
50
|
-
consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
51
|
-
// Node cannot read a browser's object URLs; serve the test's as a PDF header.
|
|
52
|
-
vi.stubGlobal("fetch", (input: RequestInfo | URL, init?: RequestInit) =>
|
|
53
|
-
String(input).startsWith("blob:")
|
|
54
|
-
? Promise.resolve(new Response("%PDF-"))
|
|
55
|
-
: realFetch(input, init),
|
|
56
|
-
);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
afterEach(() => {
|
|
60
|
-
consoleError.mockRestore();
|
|
61
|
-
consoleWarn.mockRestore();
|
|
62
|
-
vi.unstubAllGlobals();
|
|
63
|
-
vi.mocked(loadPdfjs).mockReset();
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
describe("PdfViewer", () => {
|
|
67
|
-
it("renders every page of the document to a canvas", async () => {
|
|
68
|
-
const fake = fakePdfjs({ numPages: 3 });
|
|
69
|
-
vi.mocked(loadPdfjs).mockResolvedValue(fake.pdfjs);
|
|
70
|
-
|
|
71
|
-
const { container } = render(<PdfViewer src={BLOB_URL} filename="lease.pdf" />);
|
|
72
|
-
|
|
73
|
-
expect(screen.getByRole("status")).toHaveTextContent("Loading lease.pdf");
|
|
74
|
-
expect(await screen.findByRole("img", { name: "Page 3 of 3" })).toBeInTheDocument();
|
|
75
|
-
expect(screen.getByRole("region", { name: "lease.pdf" })).toBeInTheDocument();
|
|
76
|
-
expect(pageInput()).toHaveValue("1");
|
|
77
|
-
expect(screen.getByText("/ 3")).toBeInTheDocument();
|
|
78
|
-
await waitFor(() => expect(container.querySelectorAll("canvas")).toHaveLength(3));
|
|
79
|
-
expect(fake.render).toHaveBeenCalledWith(
|
|
80
|
-
expect.objectContaining({ canvas: expect.any(HTMLCanvasElement) }),
|
|
81
|
-
);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("streams an http(s) URL and reads other URLs as bytes", async () => {
|
|
85
|
-
const fake = fakePdfjs();
|
|
86
|
-
vi.mocked(loadPdfjs).mockResolvedValue(fake.pdfjs);
|
|
87
|
-
|
|
88
|
-
const remote = render(<PdfViewer src="https://files.example/brief.pdf" />);
|
|
89
|
-
await screen.findByRole("img", { name: "Page 1 of 3" });
|
|
90
|
-
expect(fake.getDocument).toHaveBeenLastCalledWith({ url: "https://files.example/brief.pdf" });
|
|
91
|
-
remote.unmount();
|
|
92
|
-
|
|
93
|
-
render(<PdfViewer src="data:application/pdf;base64,JVBERi0=" />);
|
|
94
|
-
await screen.findByRole("img", { name: "Page 1 of 3" });
|
|
95
|
-
const [params] = fake.getDocument.mock.lastCall as unknown as [{ data: Uint8Array }];
|
|
96
|
-
expect(new TextDecoder().decode(params.data)).toBe("%PDF-");
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("moves between pages with the buttons and the page field", async () => {
|
|
100
|
-
vi.mocked(loadPdfjs).mockResolvedValue(fakePdfjs({ numPages: 3 }).pdfjs);
|
|
101
|
-
render(<PdfViewer src={BLOB_URL} filename="lease.pdf" />);
|
|
102
|
-
await screen.findByRole("img", { name: "Page 3 of 3" });
|
|
103
|
-
|
|
104
|
-
const previous = screen.getByRole("button", { name: "Previous page" });
|
|
105
|
-
const next = screen.getByRole("button", { name: "Next page" });
|
|
106
|
-
expect(previous).toBeDisabled();
|
|
107
|
-
|
|
108
|
-
fireEvent.click(next);
|
|
109
|
-
expect(pageInput()).toHaveValue("2");
|
|
110
|
-
expect(previous).toBeEnabled();
|
|
111
|
-
|
|
112
|
-
fireEvent.change(pageInput(), { target: { value: "9" } });
|
|
113
|
-
fireEvent.keyDown(pageInput(), { key: "Enter" });
|
|
114
|
-
expect(pageInput()).toHaveValue("3");
|
|
115
|
-
expect(next).toBeDisabled();
|
|
116
|
-
|
|
117
|
-
fireEvent.change(pageInput(), { target: { value: "1x" } });
|
|
118
|
-
expect(pageInput()).toHaveValue("1");
|
|
119
|
-
fireEvent.keyDown(pageInput(), { key: "Escape" });
|
|
120
|
-
expect(pageInput()).toHaveValue("3");
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("zooms in steps and returns to fit-to-width", async () => {
|
|
124
|
-
vi.mocked(loadPdfjs).mockResolvedValue(fakePdfjs({ numPages: 1 }).pdfjs);
|
|
125
|
-
render(<PdfViewer src={BLOB_URL} filename="lease.pdf" />);
|
|
126
|
-
const page = await screen.findByRole("img", { name: "Page 1 of 1" });
|
|
127
|
-
|
|
128
|
-
// jsdom lays nothing out, so the fitted width falls back to 100%.
|
|
129
|
-
const fit = screen.getByRole("button", { name: "100%, fit to width" });
|
|
130
|
-
expect(fit).toHaveAttribute("aria-pressed", "true");
|
|
131
|
-
|
|
132
|
-
fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));
|
|
133
|
-
const zoomed = screen.getByRole("button", { name: "125%, fit to width" });
|
|
134
|
-
expect(zoomed).toHaveAttribute("aria-pressed", "false");
|
|
135
|
-
await waitFor(() =>
|
|
136
|
-
expect(page.style.width).toBe(`${Math.round(LETTER.width * CSS_PER_POINT * 1.25)}px`),
|
|
137
|
-
);
|
|
138
|
-
|
|
139
|
-
fireEvent.click(screen.getByRole("button", { name: "Zoom out" }));
|
|
140
|
-
fireEvent.click(screen.getByRole("button", { name: "Zoom out" }));
|
|
141
|
-
expect(screen.getByRole("button", { name: "75%, fit to width" })).toBeInTheDocument();
|
|
142
|
-
|
|
143
|
-
fireEvent.click(screen.getByRole("button", { name: "75%, fit to width" }));
|
|
144
|
-
expect(screen.getByRole("button", { name: "100%, fit to width" })).toHaveAttribute(
|
|
145
|
-
"aria-pressed",
|
|
146
|
-
"true",
|
|
147
|
-
);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it("fits the first page to the width of the pane", async () => {
|
|
151
|
-
const width = vi
|
|
152
|
-
.spyOn(HTMLElement.prototype, "clientWidth", "get")
|
|
153
|
-
.mockReturnValue(600);
|
|
154
|
-
try {
|
|
155
|
-
vi.mocked(loadPdfjs).mockResolvedValue(fakePdfjs({ numPages: 1 }).pdfjs);
|
|
156
|
-
render(<PdfViewer src={BLOB_URL} />);
|
|
157
|
-
const page = await screen.findByRole("img", { name: "Page 1 of 1" });
|
|
158
|
-
// 600px less the column's 12px padding on each side.
|
|
159
|
-
await waitFor(() => expect(page.style.width).toBe("576px"));
|
|
160
|
-
const percent = Math.round((576 / (LETTER.width * CSS_PER_POINT)) * 100);
|
|
161
|
-
expect(screen.getByRole("button", { name: `${percent}%, fit to width` })).toHaveAttribute(
|
|
162
|
-
"aria-pressed",
|
|
163
|
-
"true",
|
|
164
|
-
);
|
|
165
|
-
} finally {
|
|
166
|
-
width.mockRestore();
|
|
167
|
-
}
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it("offers Download in the toolbar only when the host handles it", async () => {
|
|
171
|
-
vi.mocked(loadPdfjs).mockResolvedValue(fakePdfjs().pdfjs);
|
|
172
|
-
const onDownload = vi.fn();
|
|
173
|
-
const { rerender } = render(<PdfViewer src={BLOB_URL} filename="lease.pdf" />);
|
|
174
|
-
await screen.findByRole("img", { name: "Page 1 of 3" });
|
|
175
|
-
expect(screen.queryByRole("button", { name: "Download lease.pdf" })).toBeNull();
|
|
176
|
-
|
|
177
|
-
rerender(<PdfViewer src={BLOB_URL} filename="lease.pdf" onDownload={onDownload} />);
|
|
178
|
-
fireEvent.click(screen.getByRole("button", { name: "Download lease.pdf" }));
|
|
179
|
-
expect(onDownload).toHaveBeenCalledTimes(1);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it("says so and falls back to the browser's viewer when pdfjs-dist is missing", async () => {
|
|
183
|
-
vi.mocked(loadPdfjs).mockRejectedValue(new MissingPdfjsError());
|
|
184
|
-
const onDownload = vi.fn();
|
|
185
|
-
const { container } = render(
|
|
186
|
-
<PdfViewer src={BLOB_URL} filename="lease.pdf" size={2048} onDownload={onDownload} />,
|
|
187
|
-
);
|
|
188
|
-
|
|
189
|
-
expect(
|
|
190
|
-
await screen.findByText(/does not include the page viewer/),
|
|
191
|
-
).toBeInTheDocument();
|
|
192
|
-
const object = container.querySelector("object");
|
|
193
|
-
expect(object).toHaveAttribute("data", BLOB_URL);
|
|
194
|
-
expect(object).toHaveAttribute("type", "application/pdf");
|
|
195
|
-
expect(screen.getByText("This browser does not show PDFs inline.")).toBeInTheDocument();
|
|
196
|
-
expect(screen.getByText("2.0 KB")).toBeInTheDocument();
|
|
197
|
-
fireEvent.click(screen.getByRole("button", { name: "Download" }));
|
|
198
|
-
expect(onDownload).toHaveBeenCalledTimes(1);
|
|
199
|
-
// A missing package will not appear on a retry.
|
|
200
|
-
expect(screen.queryByRole("button", { name: "Try again" })).toBeNull();
|
|
201
|
-
expect(consoleError).toHaveBeenCalledWith(
|
|
202
|
-
expect.stringContaining("falling back"),
|
|
203
|
-
expect.any(MissingPdfjsError),
|
|
204
|
-
);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it.each([
|
|
208
|
-
["PasswordException", {}, /password-protected/, false],
|
|
209
|
-
["InvalidPDFException", {}, /could not be read as a PDF/, false],
|
|
210
|
-
["ResponseException", { status: 404 }, /could not be downloaded \(HTTP 404\)/, true],
|
|
211
|
-
["UnknownErrorException", {}, /could not open this PDF/, true],
|
|
212
|
-
])("explains a %s and offers a retry only when one can help", async (name, extra, notice, retry) => {
|
|
213
|
-
vi.mocked(loadPdfjs).mockResolvedValue(fakePdfjs({ fail: namedError(name, extra) }).pdfjs);
|
|
214
|
-
render(<PdfViewer src={BLOB_URL} />);
|
|
215
|
-
expect(await screen.findByText(notice)).toBeInTheDocument();
|
|
216
|
-
expect(Boolean(screen.queryByRole("button", { name: "Try again" }))).toBe(retry);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it("loads the document again on Try again", async () => {
|
|
220
|
-
const failing = fakePdfjs({ fail: namedError("UnknownErrorException") });
|
|
221
|
-
const working = fakePdfjs({ numPages: 2 });
|
|
222
|
-
vi.mocked(loadPdfjs)
|
|
223
|
-
.mockResolvedValueOnce(failing.pdfjs)
|
|
224
|
-
.mockResolvedValueOnce(working.pdfjs);
|
|
225
|
-
render(<PdfViewer src={BLOB_URL} />);
|
|
226
|
-
|
|
227
|
-
fireEvent.click(await screen.findByRole("button", { name: "Try again" }));
|
|
228
|
-
expect(await screen.findByRole("img", { name: "Page 2 of 2" })).toBeInTheDocument();
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it("destroys the loading task when it unmounts or the source changes", async () => {
|
|
232
|
-
const fake = fakePdfjs();
|
|
233
|
-
vi.mocked(loadPdfjs).mockResolvedValue(fake.pdfjs);
|
|
234
|
-
const { rerender, unmount } = render(<PdfViewer src={BLOB_URL} />);
|
|
235
|
-
await screen.findByRole("img", { name: "Page 1 of 3" });
|
|
236
|
-
|
|
237
|
-
rerender(<PdfViewer src="blob:https://app.tangle.tools/other" />);
|
|
238
|
-
expect(fake.destroy).toHaveBeenCalledTimes(1);
|
|
239
|
-
await screen.findByRole("img", { name: "Page 1 of 3" });
|
|
240
|
-
|
|
241
|
-
unmount();
|
|
242
|
-
await act(async () => {});
|
|
243
|
-
expect(fake.destroy).toHaveBeenCalledTimes(2);
|
|
244
|
-
});
|
|
245
|
-
});
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest"
|
|
2
|
-
import { fireEvent, render, waitFor } from "@testing-library/react"
|
|
3
|
-
import { RichFileTree } from "./rich-file-tree"
|
|
4
|
-
|
|
5
|
-
function treeRow(container: HTMLElement, path: string): HTMLElement {
|
|
6
|
-
const host = container.querySelector("file-tree-container");
|
|
7
|
-
const row = Array.from(host?.shadowRoot?.querySelectorAll("[data-item-path]") ?? [])
|
|
8
|
-
.find((element) => element.getAttribute("data-item-path") === path);
|
|
9
|
-
if (!(row instanceof HTMLElement)) throw new Error(`Missing tree row: ${path}`);
|
|
10
|
-
return row;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
describe("RichFileTree", () => {
|
|
14
|
-
it("renders without crashing given a flat path list", () => {
|
|
15
|
-
const { container } = render(
|
|
16
|
-
<RichFileTree paths={["README.md", "src/index.ts"]} />,
|
|
17
|
-
)
|
|
18
|
-
// Pierre renders into a custom element / shadow root; we can't peek
|
|
19
|
-
// inside shadow DOM from RTL, but we can confirm the host mounted.
|
|
20
|
-
expect(container.querySelectorAll("*").length).toBeGreaterThan(0)
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
it("flattens a recursive FileNode tree to paths", () => {
|
|
24
|
-
const { container } = render(
|
|
25
|
-
<RichFileTree
|
|
26
|
-
root={{
|
|
27
|
-
name: "root",
|
|
28
|
-
path: "",
|
|
29
|
-
type: "directory",
|
|
30
|
-
children: [
|
|
31
|
-
{ name: "a.md", path: "a.md", type: "file" },
|
|
32
|
-
{
|
|
33
|
-
name: "b",
|
|
34
|
-
path: "b",
|
|
35
|
-
type: "directory",
|
|
36
|
-
children: [{ name: "c.md", path: "b/c.md", type: "file" }],
|
|
37
|
-
},
|
|
38
|
-
],
|
|
39
|
-
}}
|
|
40
|
-
/>,
|
|
41
|
-
)
|
|
42
|
-
expect(container.querySelectorAll("*").length).toBeGreaterThan(0)
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
it("calls the current selection callback after its owner rerenders", async () => {
|
|
46
|
-
const original = vi.fn();
|
|
47
|
-
const current = vi.fn();
|
|
48
|
-
const paths = ["brief.md", "outreach.md"];
|
|
49
|
-
const { container, rerender } = render(<RichFileTree paths={paths} onSelect={original} />);
|
|
50
|
-
rerender(<RichFileTree paths={paths} onSelect={current} />);
|
|
51
|
-
fireEvent.click(treeRow(container, "outreach.md"));
|
|
52
|
-
await waitFor(() => expect(current).toHaveBeenCalledWith("outreach.md"));
|
|
53
|
-
expect(original).not.toHaveBeenCalled();
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("clears controlled selection so a closed file can be selected again", async () => {
|
|
57
|
-
const onSelect = vi.fn();
|
|
58
|
-
const paths = ["brief.md", "outreach.md"];
|
|
59
|
-
const { container, rerender } = render(
|
|
60
|
-
<RichFileTree paths={paths} selectedPath="brief.md" onSelect={onSelect} />,
|
|
61
|
-
);
|
|
62
|
-
rerender(<RichFileTree paths={paths} selectedPath={undefined} onSelect={onSelect} />);
|
|
63
|
-
await waitFor(() => expect(treeRow(container, "brief.md")).toHaveAttribute("aria-selected", "false"));
|
|
64
|
-
fireEvent.click(treeRow(container, "brief.md"));
|
|
65
|
-
await waitFor(() => expect(onSelect).toHaveBeenCalledExactlyOnceWith("brief.md"));
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("preserves uncontrolled selection when the file listing refreshes", async () => {
|
|
69
|
-
const { container, rerender } = render(<RichFileTree paths={["brief.md"]} />);
|
|
70
|
-
fireEvent.click(treeRow(container, "brief.md"));
|
|
71
|
-
await waitFor(() => expect(treeRow(container, "brief.md")).toHaveAttribute("aria-selected", "true"));
|
|
72
|
-
rerender(<RichFileTree paths={["brief.md", "outreach.md"]} />);
|
|
73
|
-
await waitFor(() => expect(treeRow(container, "brief.md")).toHaveAttribute("aria-selected", "true"));
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("does not notify the owner when a controlled selection is synchronized", async () => {
|
|
77
|
-
const onSelect = vi.fn();
|
|
78
|
-
const paths = ["brief.md", "outreach.md"];
|
|
79
|
-
const { container, rerender } = render(<RichFileTree paths={paths} selectedPath="brief.md" onSelect={onSelect} />);
|
|
80
|
-
rerender(<RichFileTree paths={paths} selectedPath="outreach.md" onSelect={onSelect} />);
|
|
81
|
-
await waitFor(() => expect(treeRow(container, "outreach.md")).toHaveAttribute("aria-selected", "true"));
|
|
82
|
-
expect(onSelect).not.toHaveBeenCalled();
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("throws when both root and paths are passed", () => {
|
|
86
|
-
expect(() =>
|
|
87
|
-
render(<RichFileTree root={{ name: "x", path: "x", type: "file" }} paths={["y"]} />),
|
|
88
|
-
).toThrow(/root.*paths.*not both/i)
|
|
89
|
-
})
|
|
90
|
-
})
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { fireEvent,render,screen } from "@testing-library/react";
|
|
2
|
-
import { describe,it,expect,vi } from "vitest";
|
|
3
|
-
import { CodeBlock } from "./code-block";
|
|
4
|
-
import { ApiSchema } from "./api-schema";
|
|
5
|
-
import { bindCopyButton,createCopyFeedback,highlightCode } from "../code-presentation";
|
|
6
|
-
describe("shared API presentation",()=>{
|
|
7
|
-
it("renders field paths without turning schema text into HTML",()=>{
|
|
8
|
-
render(<ApiSchema fields={[{name:"messages",type:"array",children:[{name:"content",type:"string",description:"<script>bad()</script>"}]}]}/>);
|
|
9
|
-
expect(screen.getByText("messages.content")).toBeInTheDocument();expect(document.querySelector("script")).toBeNull();
|
|
10
|
-
});
|
|
11
|
-
it("keeps stale clipboard completions and disposed feedback silent",async()=>{
|
|
12
|
-
const updates=vi.fn();let complete!:()=>void;const feedback=createCopyFeedback(updates,()=>new Promise<void>(resolve=>{complete=resolve;}));
|
|
13
|
-
const pending=feedback.copy("bytes");feedback.dispose();complete();await pending;expect(updates).toHaveBeenCalledTimes(1);
|
|
14
|
-
});
|
|
15
|
-
it("blocks duplicate writes until the accepted operation settles",async()=>{
|
|
16
|
-
const updates=vi.fn();let complete!:()=>void;
|
|
17
|
-
const write=vi.fn(()=>new Promise<void>(resolve=>{complete=resolve;}));
|
|
18
|
-
const feedback=createCopyFeedback(updates,write);
|
|
19
|
-
const first=feedback.copy("first");await feedback.copy("duplicate");
|
|
20
|
-
expect(write.mock.calls).toEqual([["first"]]);
|
|
21
|
-
expect(updates).toHaveBeenLastCalledWith(expect.objectContaining({copied:false,status:"Copying to clipboard",pending:true}));
|
|
22
|
-
complete();await first;
|
|
23
|
-
expect(updates).toHaveBeenLastCalledWith(expect.objectContaining({copied:true,status:"Copied to clipboard",pending:false}));
|
|
24
|
-
const next=feedback.copy("next");expect(write).toHaveBeenCalledTimes(2);complete();await next;
|
|
25
|
-
feedback.dispose();await feedback.copy("disposed");expect(write).toHaveBeenCalledTimes(2);
|
|
26
|
-
});
|
|
27
|
-
it("retains exact newlines and plain binary/SSE text",async()=>{
|
|
28
|
-
const text='data: {"delta":"<x>"}\n\ndata: [DONE]\n';const lines=await highlightCode(text,"text");expect(lines.map(line=>line.map(t=>t.content).join("")).join("\n")).toBe(text);
|
|
29
|
-
});
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
describe("copy binding cleanup",()=>{
|
|
33
|
-
it.each(["pending","success","failure"])("restores retained DOM after %s",async phase=>{
|
|
34
|
-
const descriptor=Object.getOwnPropertyDescriptor(navigator,"clipboard");
|
|
35
|
-
let resolve!:()=>void,reject!:(error:Error)=>void;
|
|
36
|
-
const writeText=vi.fn(()=>new Promise<void>((yes,no)=>{resolve=yes;reject=no;}));
|
|
37
|
-
Object.defineProperty(navigator,"clipboard",{configurable:true,value:{writeText}});
|
|
38
|
-
const button=document.createElement("button"),status=document.createElement("span");
|
|
39
|
-
button.setAttribute("aria-label","Copy request");status.textContent="Ready";
|
|
40
|
-
const cleanup=bindCopyButton(button,status,()=>"exact bytes");
|
|
41
|
-
try {
|
|
42
|
-
button.click();expect(button.getAttribute("aria-label")).toBe("Copying to clipboard");
|
|
43
|
-
if(phase!=="pending"){
|
|
44
|
-
if(phase==="failure")reject(new Error("Denied"));else resolve();
|
|
45
|
-
await vi.waitFor(()=>expect(button.getAttribute("aria-label")).toBe(phase==="failure"?"Copy to clipboard":"Copied"));
|
|
46
|
-
}
|
|
47
|
-
cleanup();
|
|
48
|
-
expect(button.getAttribute("aria-label")).toBe("Copy request");expect(status.textContent).toBe("Ready");
|
|
49
|
-
resolve();await Promise.resolve();await Promise.resolve();
|
|
50
|
-
expect(button.getAttribute("aria-label")).toBe("Copy request");expect(status.textContent).toBe("Ready");
|
|
51
|
-
}finally{
|
|
52
|
-
cleanup();if(descriptor)Object.defineProperty(navigator,"clipboard",descriptor);else delete (navigator as {clipboard?:unknown}).clipboard;
|
|
53
|
-
}
|
|
54
|
-
});
|
|
55
|
-
it("restores an originally absent accessible label",()=>{
|
|
56
|
-
const button=document.createElement("button"),status=document.createElement("span");
|
|
57
|
-
const cleanup=bindCopyButton(button,status,()=>"bytes");button.setAttribute("aria-label","Copied");cleanup();expect(button.hasAttribute("aria-label")).toBe(false);
|
|
58
|
-
});
|
|
59
|
-
it("gives a single code example no orphaned tab relationship",()=>{
|
|
60
|
-
const {container,unmount}=render(<CodeBlock code="fallback" examples={[{label:"Plain",language:"text",code:"one"}]}/>);
|
|
61
|
-
try {expect(container.querySelector('[role="tab"]')).toBeNull();expect(container.querySelector('[role="tabpanel"]')).toBeNull();expect(container.querySelector('[aria-labelledby]')).toBeNull();expect(container.querySelector("pre")?.textContent).toBe("one");}finally{unmount();}
|
|
62
|
-
});
|
|
63
|
-
});
|
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
-
import { renderToString } from "react-dom/server";
|
|
3
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
-
import { CodeBlock, CopyButton } from "./code-block";
|
|
5
|
-
|
|
6
|
-
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard");
|
|
7
|
-
|
|
8
|
-
afterEach(() => {
|
|
9
|
-
cleanup();
|
|
10
|
-
vi.restoreAllMocks();
|
|
11
|
-
if (originalClipboard) Object.defineProperty(navigator, "clipboard", originalClipboard);
|
|
12
|
-
else delete (navigator as { clipboard?: unknown }).clipboard;
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
describe("CodeBlock scoped semantic colors", () => {
|
|
16
|
-
it("keeps code readable before the renderer loads, then applies live semantic colors and line numbers", async () => {
|
|
17
|
-
// Shiki stops tokenizing a line after 500ms and paints the rest in the last
|
|
18
|
-
// token's color. The first pass over a cold grammar also compiles its
|
|
19
|
-
// patterns, which on a loaded host can take longer than that. Warm the
|
|
20
|
-
// grammar first: this test checks the color mapping, not cold-start time.
|
|
21
|
-
const warm = render(<CodeBlock code={'const message = "hello";'} language="javascript" />);
|
|
22
|
-
await waitFor(() => expect(warm.container.querySelector("[data-highlighted]")).not.toBeNull(), { timeout: 10_000 });
|
|
23
|
-
warm.unmount();
|
|
24
|
-
const { container } = render(<CodeBlock code={'const message = "hello";'} language="javascript" showLineNumbers />);
|
|
25
|
-
// Plain code already carries its line numbers, so highlighting shifts no layout.
|
|
26
|
-
expect(container.querySelector("pre code")?.textContent?.replace(/^1/, "")).toBe('const message = "hello";');
|
|
27
|
-
// The highlighter loads asynchronously; under a full parallel run it can take longer than waitFor's 1s default.
|
|
28
|
-
await waitFor(() => expect(container.innerHTML).toContain("var(--syntax-keyword, currentColor)"), { timeout: 10_000 });
|
|
29
|
-
expect(container.innerHTML).toContain("var(--syntax-string, currentColor)");
|
|
30
|
-
expect(container.innerHTML).toContain("var(--syntax-comment, currentColor)");
|
|
31
|
-
expect(container.querySelector("[data-line-number]")).not.toBeNull();
|
|
32
|
-
expect(container.querySelector("pre code")?.textContent?.replace(/^1/, "")).toBe('const message = "hello";');
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("inherits a named scope by default without inserting its own mode", () => {
|
|
36
|
-
const { container } = render(<section data-theme="arena-light"><CodeBlock data-testid="code" code="const x = 1" language="javascript" /></section>);
|
|
37
|
-
const code = screen.getByTestId("code");
|
|
38
|
-
expect(code.hasAttribute("data-theme")).toBe(false);
|
|
39
|
-
const before = code.innerHTML;
|
|
40
|
-
container.querySelector("section")!.setAttribute("data-theme", "arena");
|
|
41
|
-
expect(code.innerHTML).toBe(before); // References stay live; the browser fixture checks the computed colors.
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it.each([true, false])("preserves explicit light=%s as an opposite nested mode", (light) => {
|
|
45
|
-
render(<section data-theme={light ? "dark" : "light"}><CodeBlock data-testid="code" code="x" light={light} /></section>);
|
|
46
|
-
expect(screen.getByTestId("code").getAttribute("data-theme")).toBe(light ? "light" : "dark");
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("preserves caller attributes, labels, children and custom scopes", () => {
|
|
50
|
-
render(<CodeBlock data-testid="code" data-theme="aubergine" className="caller" code="echo hello" language="bash" label="BASHRC"><button>Copy example</button></CodeBlock>);
|
|
51
|
-
expect(screen.getByTestId("code").getAttribute("data-theme")).toBe("aubergine");
|
|
52
|
-
expect(screen.getByTestId("code").classList.contains("caller")).toBe(true);
|
|
53
|
-
expect(screen.getByText("BASHRC")).toBeTruthy();
|
|
54
|
-
expect(screen.getByRole("button", { name: "Copy example" })).toBeTruthy();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("server-renders readable code with the same semantic references independent of document theme", () => {
|
|
58
|
-
const source = <CodeBlock code="const x = 1" language="javascript" />;
|
|
59
|
-
document.documentElement.className = "dark";
|
|
60
|
-
const dark = renderToString(source);
|
|
61
|
-
expect(dark).toContain("const x = 1");
|
|
62
|
-
expect(dark).toContain("var(--syntax-foreground, currentColor)");
|
|
63
|
-
document.documentElement.className = "light";
|
|
64
|
-
expect(renderToString(source)).toBe(dark);
|
|
65
|
-
document.documentElement.removeAttribute("class");
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
// Retain the existing header/label/copy-slot contracts alongside theme coverage.
|
|
70
|
-
describe("CodeBlock", () => {
|
|
71
|
-
it("renders the code through the syntax highlighter", () => {
|
|
72
|
-
const { container } = render(<CodeBlock code={"const x = 1;"} language="typescript" />);
|
|
73
|
-
expect(container.querySelector("code")).not.toBeNull();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it.each(["javascript", "js", "ts", "tsx"])("highlights %s with Shiki into Brand syntax tokens", async (language) => {
|
|
77
|
-
const code = "const answer = 42; // the answer";
|
|
78
|
-
const { container } = render(<CodeBlock code={code} language={language} />);
|
|
79
|
-
await waitFor(() => expect(container.querySelector("[data-highlighted]")).not.toBeNull(), { timeout: 10_000 });
|
|
80
|
-
const tokens = Array.from(container.querySelectorAll("pre code span span"));
|
|
81
|
-
expect(tokens.length).toBeGreaterThan(3);
|
|
82
|
-
expect(container.innerHTML).toContain("var(--syntax-keyword, currentColor)");
|
|
83
|
-
expect(container.innerHTML).toContain("var(--syntax-comment, currentColor)");
|
|
84
|
-
expect(container.innerHTML).not.toContain("--shiki-");
|
|
85
|
-
expect(container.querySelector("pre code")?.textContent).toBe(code);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it.each(["brainfuck", "not-a-language", undefined])("keeps %s as readable plain text", (language) => {
|
|
89
|
-
const code = "++[>+++<-]";
|
|
90
|
-
const { container } = render(<CodeBlock code={code} language={language} />);
|
|
91
|
-
expect(container.querySelector("[data-highlighted]")).toBeNull();
|
|
92
|
-
expect(container.querySelector("pre code")?.textContent).toBe(code);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("preserves whitespace and numbers every line", async () => {
|
|
96
|
-
const code = "\tfirst \n second\n";
|
|
97
|
-
const { container } = render(<CodeBlock code={code} language="text" showLineNumbers />);
|
|
98
|
-
const numbers = Array.from(container.querySelectorAll("[data-line-number]"), (node) => node.textContent);
|
|
99
|
-
expect(numbers).toEqual(["1", "2", "3"]);
|
|
100
|
-
const text = Array.from(container.querySelectorAll("pre code > span"), (line) =>
|
|
101
|
-
Array.from(line.childNodes).filter((node) => !(node instanceof HTMLElement && node.hasAttribute("data-line-number")))
|
|
102
|
-
.map((node) => node.textContent).join("")).join("");
|
|
103
|
-
expect(text).toBe(code);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("names the file in the header beside the language", () => {
|
|
107
|
-
render(<CodeBlock code={"export {}"} language="ts" filename="src/lib/ctr.ts" />);
|
|
108
|
-
expect(screen.getByText("src/lib/ctr.ts")).toBeInTheDocument();
|
|
109
|
-
expect(screen.getByText("TypeScript")).toBeInTheDocument();
|
|
110
|
-
expect(screen.getByRole("region", { name: "src/lib/ctr.ts code" })).toBeInTheDocument();
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("uses `label` as the header text, overriding `language`", () => {
|
|
114
|
-
const { getByText, queryByText } = render(
|
|
115
|
-
<CodeBlock code={"x"} language="typescript" label="config.ts" />,
|
|
116
|
-
);
|
|
117
|
-
expect(getByText("config.ts")).toBeInTheDocument();
|
|
118
|
-
expect(queryByText("typescript")).toBeNull();
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("falls back to the language's name for the header when no `label` is given", () => {
|
|
122
|
-
const { getByText } = render(<CodeBlock code={"x"} language="python" />);
|
|
123
|
-
expect(getByText("Python")).toBeInTheDocument();
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it.each(["text", "TXT", "plain", "plaintext"])("shows no %s label on a plain-text fence, only its copy control", (language) => {
|
|
127
|
-
const { container, queryByText } = render(
|
|
128
|
-
<CodeBlock code={"x"} language={language}>
|
|
129
|
-
<CopyButton text="x" />
|
|
130
|
-
</CodeBlock>,
|
|
131
|
-
);
|
|
132
|
-
expect(container.querySelector(".border-b")).toBeNull();
|
|
133
|
-
expect(queryByText(language)).toBeNull();
|
|
134
|
-
expect(container.querySelector('button[title="Copy to clipboard"]')).not.toBeNull();
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("renders no header when neither label nor language is set", () => {
|
|
138
|
-
const { container } = render(<CodeBlock code={"x"} />);
|
|
139
|
-
// The header is the only element carrying a bottom border.
|
|
140
|
-
expect(container.querySelector(".border-b")).toBeNull();
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it("treats an explicit empty label as 'no header', even with a language", () => {
|
|
144
|
-
const { container, queryByText } = render(
|
|
145
|
-
<CodeBlock code={"x"} language="python" label="" />,
|
|
146
|
-
);
|
|
147
|
-
expect(container.querySelector(".border-b")).toBeNull();
|
|
148
|
-
expect(queryByText("python")).toBeNull();
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it("places children in the header when a header is shown", () => {
|
|
152
|
-
const { container } = render(
|
|
153
|
-
<CodeBlock code={"x"} label="run.ts">
|
|
154
|
-
<CopyButton text="x" />
|
|
155
|
-
</CodeBlock>,
|
|
156
|
-
);
|
|
157
|
-
const copyButton = container.querySelector('button[title="Copy to clipboard"]');
|
|
158
|
-
expect(copyButton).not.toBeNull();
|
|
159
|
-
// Inside the bordered header, not the absolute hover overlay.
|
|
160
|
-
expect(copyButton?.closest(".border-b")).not.toBeNull();
|
|
161
|
-
expect(copyButton?.closest(".absolute")).toBeNull();
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("moves children to a hover overlay when no header is shown", () => {
|
|
165
|
-
const { container } = render(
|
|
166
|
-
<CodeBlock code={"x"}>
|
|
167
|
-
<CopyButton text="x" />
|
|
168
|
-
</CodeBlock>,
|
|
169
|
-
);
|
|
170
|
-
const copyButton = container.querySelector('button[title="Copy to clipboard"]');
|
|
171
|
-
expect(copyButton).not.toBeNull();
|
|
172
|
-
expect(copyButton?.closest(".absolute")).not.toBeNull();
|
|
173
|
-
expect(copyButton?.closest(".border-b")).toBeNull();
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it("names the copy button, keeps it out of forms, and announces the copy", async () => {
|
|
177
|
-
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
178
|
-
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
|
|
179
|
-
render(<CopyButton text="secret" />);
|
|
180
|
-
const button = screen.getByRole("button", { name: "Copy to clipboard" });
|
|
181
|
-
expect(button.getAttribute("type")).toBe("button");
|
|
182
|
-
const status = screen.getByRole("status");
|
|
183
|
-
expect(status.getAttribute("aria-live")).toBe("polite");
|
|
184
|
-
expect(status.textContent).toBe("");
|
|
185
|
-
fireEvent.click(button);
|
|
186
|
-
await waitFor(() => expect(status.textContent).toBe("Copied to clipboard"));
|
|
187
|
-
expect(writeText).toHaveBeenCalledWith("secret");
|
|
188
|
-
expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy();
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it("announces a failed copy", async () => {
|
|
192
|
-
const writeText = vi.fn().mockRejectedValue(new Error("denied"));
|
|
193
|
-
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
|
|
194
|
-
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
195
|
-
render(<CopyButton text="secret" />);
|
|
196
|
-
fireEvent.click(screen.getByRole("button", { name: "Copy to clipboard" }));
|
|
197
|
-
await waitFor(() => expect(screen.getByRole("status").textContent).toBe("Copy failed"));
|
|
198
|
-
expect(screen.getByRole("button", { name: "Copy to clipboard" })).toBeTruthy();
|
|
199
|
-
warn.mockRestore();
|
|
200
|
-
});
|
|
201
|
-
});
|