@tangle-network/ui 11.51.7 → 11.51.9
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 +10 -0
- package/README.md +6 -0
- package/dist/chat/chat-message.js +2 -7
- package/dist/chat/user-message.js +3 -5
- package/dist/files/file-card.js +1 -1
- package/dist/files/file-tree.d.ts +0 -6
- package/dist/files/file-tree.js +2 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/primitives/hover-card.d.ts +22 -13
- package/dist/primitives/hover-card.js +149 -40
- package/dist/primitives/popover.d.ts +3 -1
- package/dist/primitives/popover.js +15 -12
- package/dist/primitives/upload-progress.d.ts +0 -6
- package/dist/primitives/upload-progress.js +2 -6
- package/dist/run/run-group.js +2 -2
- package/dist/run/run-item-primitives.js +2 -2
- package/dist/run/run-row-shell.js +2 -2
- package/dist/utils/format.d.ts +14 -12
- package/dist/utils/format.js +50 -30
- package/dist/utils.d.ts +2 -2
- package/dist/utils.js +2 -2
- package/package.json +11 -2
- package/src/chat/chat-message.tsx +2 -7
- package/src/chat/user-message.tsx +3 -5
- package/src/editor/source-markdown-editor.test.tsx +7 -7
- package/src/files/file-tree.tsx +2 -7
- package/src/primitives/hover-card.tsx +64 -74
- package/src/primitives/popover.tsx +3 -3
- package/src/primitives/upload-progress.tsx +2 -7
- package/src/utils/format.ts +50 -33
- package/src/utils/index.ts +1 -1
- 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
- package/src/utils/format.test.ts +0 -58
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
-
import { describe, expect, it, vi } from "vitest";
|
|
3
|
-
import { FileCard } from "./file-card";
|
|
4
|
-
|
|
5
|
-
const UPLOADED = new Date("2026-10-06T15:30:00Z");
|
|
6
|
-
|
|
7
|
-
describe("FileCard", () => {
|
|
8
|
-
it("names the file with its size, date and uploader", () => {
|
|
9
|
-
const { container } = render(
|
|
10
|
-
<FileCard
|
|
11
|
-
filename="Engagement letter.pdf"
|
|
12
|
-
size={2_516_582}
|
|
13
|
-
date={UPLOADED}
|
|
14
|
-
uploader={{ name: "Dana Ruiz", role: "Client" }}
|
|
15
|
-
/>,
|
|
16
|
-
);
|
|
17
|
-
|
|
18
|
-
expect(screen.getByText("Engagement letter.pdf")).toBeInTheDocument();
|
|
19
|
-
expect(screen.getByText("2.4 MB")).toBeInTheDocument();
|
|
20
|
-
expect(container.querySelector("time")).toHaveAttribute("datetime", UPLOADED.toISOString());
|
|
21
|
-
expect(screen.getByText("Uploaded by Dana Ruiz (Client)")).toBeInTheDocument();
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("leaves out what it does not know", () => {
|
|
25
|
-
const { container } = render(<FileCard filename="notes.txt" date="not a date" />);
|
|
26
|
-
expect(container.querySelector("time")).toBeNull();
|
|
27
|
-
expect(screen.queryByText(/Uploaded by/)).toBeNull();
|
|
28
|
-
expect(screen.queryByRole("button")).toBeNull();
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("takes its icon tone from the file kind, with the MIME type outranking the name", () => {
|
|
32
|
-
const pdf = render(<FileCard filename="brief.pdf" />);
|
|
33
|
-
expect(pdf.container.querySelector("[aria-hidden='true']")?.className).toContain(
|
|
34
|
-
"--tone-orange-bg",
|
|
35
|
-
);
|
|
36
|
-
pdf.unmount();
|
|
37
|
-
|
|
38
|
-
const sheet = render(<FileCard filename="export" mimeType="text/csv" />);
|
|
39
|
-
expect(sheet.container.querySelector("[aria-hidden='true']")?.className).toContain(
|
|
40
|
-
"--tone-lime-bg",
|
|
41
|
-
);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("offers Download and the host's own actions", () => {
|
|
45
|
-
const onDownload = vi.fn();
|
|
46
|
-
render(
|
|
47
|
-
<FileCard
|
|
48
|
-
filename="book.xlsx"
|
|
49
|
-
description="Download to open this workbook in a spreadsheet app."
|
|
50
|
-
onDownload={onDownload}
|
|
51
|
-
actions={<button type="button">Open</button>}
|
|
52
|
-
/>,
|
|
53
|
-
);
|
|
54
|
-
|
|
55
|
-
expect(
|
|
56
|
-
screen.getByText("Download to open this workbook in a spreadsheet app."),
|
|
57
|
-
).toBeInTheDocument();
|
|
58
|
-
fireEvent.click(screen.getByRole("button", { name: "Download" }));
|
|
59
|
-
expect(onDownload).toHaveBeenCalledTimes(1);
|
|
60
|
-
expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument();
|
|
61
|
-
});
|
|
62
|
-
});
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
2
|
-
import { describe, expect, it, vi } from "vitest";
|
|
3
|
-
import { FileList, type FileListItem } from "./file-list";
|
|
4
|
-
|
|
5
|
-
interface CaseDocument extends FileListItem {
|
|
6
|
-
matterId: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
const FILES: CaseDocument[] = [
|
|
10
|
-
{
|
|
11
|
-
id: "lease",
|
|
12
|
-
matterId: "m-1",
|
|
13
|
-
filename: "Commercial lease.pdf",
|
|
14
|
-
size: 482_000,
|
|
15
|
-
date: "2026-10-01T12:00:00Z",
|
|
16
|
-
uploader: { name: "Dana Ruiz", role: "Client" },
|
|
17
|
-
},
|
|
18
|
-
{
|
|
19
|
-
id: "photos",
|
|
20
|
-
matterId: "m-1",
|
|
21
|
-
filename: "Water damage.jpg",
|
|
22
|
-
mimeType: "image/jpeg",
|
|
23
|
-
size: 3_100_000,
|
|
24
|
-
},
|
|
25
|
-
];
|
|
26
|
-
|
|
27
|
-
describe("FileList", () => {
|
|
28
|
-
it("shows each file's kind, size and uploader", () => {
|
|
29
|
-
render(<FileList files={FILES} aria-label="Case documents" />);
|
|
30
|
-
const rows = within(screen.getByRole("list", { name: "Case documents" })).getAllByRole(
|
|
31
|
-
"listitem",
|
|
32
|
-
);
|
|
33
|
-
expect(rows).toHaveLength(2);
|
|
34
|
-
expect(rows[0]).toHaveTextContent("Commercial lease.pdf");
|
|
35
|
-
// A dot between facts on screen, a comma for a screen reader.
|
|
36
|
-
expect(rows[0]).toHaveTextContent(/PDF · , 471 KB · , .+ · , Dana Ruiz \(Client\)/);
|
|
37
|
-
expect(rows[1]).toHaveTextContent("Image · , 3.0 MB");
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("opens a file from its row and hands back the host's own record", () => {
|
|
41
|
-
const onOpen = vi.fn();
|
|
42
|
-
render(<FileList files={FILES} onOpen={onOpen} activeId="photos" />);
|
|
43
|
-
|
|
44
|
-
fireEvent.click(screen.getByRole("button", { name: /Commercial lease\.pdf/ }));
|
|
45
|
-
expect(onOpen).toHaveBeenCalledWith(FILES[0]);
|
|
46
|
-
expect(onOpen.mock.calls[0][0].matterId).toBe("m-1");
|
|
47
|
-
|
|
48
|
-
expect(screen.getByRole("button", { name: /Water damage\.jpg/ })).toHaveAttribute(
|
|
49
|
-
"aria-current",
|
|
50
|
-
"true",
|
|
51
|
-
);
|
|
52
|
-
expect(screen.getByRole("button", { name: /Commercial lease\.pdf/ })).not.toHaveAttribute(
|
|
53
|
-
"aria-current",
|
|
54
|
-
);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("downloads and deletes through per-file buttons", () => {
|
|
58
|
-
const onDownload = vi.fn();
|
|
59
|
-
const onDelete = vi.fn();
|
|
60
|
-
render(<FileList files={FILES} onDownload={onDownload} onDelete={onDelete} />);
|
|
61
|
-
|
|
62
|
-
fireEvent.click(screen.getByRole("button", { name: "Download Water damage.jpg" }));
|
|
63
|
-
expect(onDownload).toHaveBeenCalledWith(FILES[1]);
|
|
64
|
-
fireEvent.click(screen.getByRole("button", { name: "Delete Commercial lease.pdf" }));
|
|
65
|
-
expect(onDelete).toHaveBeenCalledWith(FILES[0]);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("renders no controls the host did not ask for", () => {
|
|
69
|
-
render(<FileList files={FILES} />);
|
|
70
|
-
expect(screen.queryByRole("button")).toBeNull();
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("shows the empty message in place of the list", () => {
|
|
74
|
-
const { rerender } = render(<FileList files={[]} />);
|
|
75
|
-
expect(screen.queryByRole("list")).toBeNull();
|
|
76
|
-
expect(screen.getByText("No files yet.")).toBeInTheDocument();
|
|
77
|
-
|
|
78
|
-
rerender(<FileList files={[]} empty="Upload the lease to start." />);
|
|
79
|
-
expect(screen.getByText("Upload the lease to start.")).toBeInTheDocument();
|
|
80
|
-
});
|
|
81
|
-
});
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
const PDFJS = "pdfjs-dist/legacy/build/pdf.mjs";
|
|
4
|
-
const WORKER = "pdfjs-dist/legacy/build/pdf.worker.min.mjs";
|
|
5
|
-
|
|
6
|
-
/** What the next import of pdf.js yields: a namespace, or a thrown error. */
|
|
7
|
-
let pdfjsImport: () => Record<string, unknown>;
|
|
8
|
-
let workerImports = 0;
|
|
9
|
-
|
|
10
|
-
function installedPdfjs() {
|
|
11
|
-
return { getDocument: () => ({}), GlobalWorkerOptions: { workerSrc: "", workerPort: null } };
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Loads the module under test with pdf.js replaced. The loader reads it through
|
|
16
|
-
* a dynamic import, so the substitution happens before the graph is built.
|
|
17
|
-
*/
|
|
18
|
-
async function loadModule() {
|
|
19
|
-
vi.resetModules();
|
|
20
|
-
vi.doMock(PDFJS, () => pdfjsImport());
|
|
21
|
-
vi.doMock(WORKER, () => {
|
|
22
|
-
workerImports += 1;
|
|
23
|
-
return {};
|
|
24
|
-
});
|
|
25
|
-
return await import("./pdf-loader");
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
let consoleWarn: ReturnType<typeof vi.spyOn>;
|
|
29
|
-
|
|
30
|
-
beforeEach(() => {
|
|
31
|
-
pdfjsImport = installedPdfjs;
|
|
32
|
-
workerImports = 0;
|
|
33
|
-
consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
afterEach(() => {
|
|
37
|
-
consoleWarn.mockRestore();
|
|
38
|
-
vi.doUnmock(PDFJS);
|
|
39
|
-
vi.doUnmock(WORKER);
|
|
40
|
-
vi.resetModules();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
describe("loadPdfjs", () => {
|
|
44
|
-
it("runs the worker on the main thread when no worker URL is configured", async () => {
|
|
45
|
-
const { loadPdfjs } = await loadModule();
|
|
46
|
-
const pdfjs = await loadPdfjs();
|
|
47
|
-
expect(typeof pdfjs.getDocument).toBe("function");
|
|
48
|
-
expect(workerImports).toBe(1);
|
|
49
|
-
expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe("");
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("uses the configured worker URL instead of importing the worker", async () => {
|
|
53
|
-
const { configurePdfViewer, loadPdfjs } = await loadModule();
|
|
54
|
-
configurePdfViewer({ workerSrc: "/assets/pdf.worker.min.mjs", assetsUrl: "/pdfjs/" });
|
|
55
|
-
const pdfjs = await loadPdfjs();
|
|
56
|
-
expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe("/assets/pdf.worker.min.mjs");
|
|
57
|
-
expect(workerImports).toBe(0);
|
|
58
|
-
expect(consoleWarn).not.toHaveBeenCalled();
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("warns once that scanned pages need assetsUrl", async () => {
|
|
62
|
-
const { loadPdfjs } = await loadModule();
|
|
63
|
-
await loadPdfjs();
|
|
64
|
-
await loadPdfjs();
|
|
65
|
-
expect(consoleWarn).toHaveBeenCalledTimes(1);
|
|
66
|
-
expect(consoleWarn.mock.calls[0][0]).toMatch(/no assetsUrl.*JBIG2, CCITT fax/);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("treats a stub without the pdf.js API as missing, for the rest of the session", async () => {
|
|
70
|
-
// What a bundler told to ignore the uninstalled peer hands back.
|
|
71
|
-
pdfjsImport = () => ({ default: {}, getDocument: undefined });
|
|
72
|
-
const { loadPdfjs, isMissingPdfjsError } = await loadModule();
|
|
73
|
-
const first = await loadPdfjs().catch((error: unknown) => error);
|
|
74
|
-
expect(isMissingPdfjsError(first)).toBe(true);
|
|
75
|
-
expect((first as Error).message).toMatch(/Install pdfjs-dist/);
|
|
76
|
-
|
|
77
|
-
pdfjsImport = installedPdfjs;
|
|
78
|
-
expect(await loadPdfjs().catch((error: unknown) => error)).toBe(first);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("tries again after a failure that is not a missing package", async () => {
|
|
82
|
-
let attempts = 0;
|
|
83
|
-
pdfjsImport = () => {
|
|
84
|
-
attempts += 1;
|
|
85
|
-
if (attempts === 1) throw new Error("Failed to fetch dynamically imported module");
|
|
86
|
-
return installedPdfjs();
|
|
87
|
-
};
|
|
88
|
-
const { loadPdfjs, isMissingPdfjsError } = await loadModule();
|
|
89
|
-
const failure = await loadPdfjs().catch((error: unknown) => error);
|
|
90
|
-
expect(isMissingPdfjsError(failure)).toBe(false);
|
|
91
|
-
await expect(loadPdfjs()).resolves.toHaveProperty("getDocument");
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
describe("asMissingPdfjsError", () => {
|
|
96
|
-
it("names pdfjs-dist only for a resolution failure about it", async () => {
|
|
97
|
-
const { asMissingPdfjsError, isMissingPdfjsError } = await loadModule();
|
|
98
|
-
for (const message of [
|
|
99
|
-
`Failed to resolve import "${PDFJS}". Does the file exist?`,
|
|
100
|
-
`Could not resolve "pdfjs-dist/legacy/build/pdf.mjs"`,
|
|
101
|
-
"Cannot find module 'pdfjs-dist/legacy/build/pdf.mjs'",
|
|
102
|
-
]) {
|
|
103
|
-
expect(isMissingPdfjsError(asMissingPdfjsError(new Error(message))), message).toBe(true);
|
|
104
|
-
}
|
|
105
|
-
const chunk = new Error("Failed to fetch dynamically imported module");
|
|
106
|
-
expect(asMissingPdfjsError(chunk)).toBe(chunk);
|
|
107
|
-
const other = new Error('Could not resolve "./chunk-abc.js"');
|
|
108
|
-
expect(asMissingPdfjsError(other)).toBe(other);
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
describe("pdfAssetParams", () => {
|
|
113
|
-
it("asks for nothing until assetsUrl is configured", async () => {
|
|
114
|
-
const { pdfAssetParams } = await loadModule();
|
|
115
|
-
expect(pdfAssetParams()).toEqual({});
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("points every asset directory under an absolute assetsUrl", async () => {
|
|
119
|
-
const { configurePdfViewer, pdfAssetParams } = await loadModule();
|
|
120
|
-
configurePdfViewer({ assetsUrl: "/static/pdfjs" });
|
|
121
|
-
const base = new URL("/static/pdfjs/", document.baseURI).href;
|
|
122
|
-
expect(pdfAssetParams()).toEqual({
|
|
123
|
-
wasmUrl: `${base}wasm/`,
|
|
124
|
-
cMapUrl: `${base}cmaps/`,
|
|
125
|
-
cMapPacked: true,
|
|
126
|
-
standardFontDataUrl: `${base}standard_fonts/`,
|
|
127
|
-
iccUrl: `${base}iccs/`,
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
});
|
|
@@ -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
|
-
});
|