|
1 | | -// @vitest-environment jsdom |
| 1 | +import { FileTree } from "@pierre/trees/react"; |
2 | 2 | import { act, type ReactNode } from "react"; |
3 | | -import { createRoot, type Root } from "react-dom/client"; |
| 3 | +import { create, type ReactTestRenderer } from "react-test-renderer"; |
4 | 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
5 | 5 |
|
6 | 6 | import type { ZipPreviewEntry } from "~/lib/zipPreview"; |
7 | 7 |
|
8 | 8 | import ZipArchivePreview from "./ZipArchivePreview"; |
9 | 9 |
|
10 | | -const entries: ZipPreviewEntry[] = [ |
11 | | - { path: "photos/", name: "photos", isDirectory: true, sizeBytes: 0, mimeType: null }, |
12 | | - { path: "photos/a.png", name: "a.png", isDirectory: false, sizeBytes: 3, mimeType: "image/png" }, |
13 | | -]; |
14 | | - |
| 10 | +const { load, readFile, refresh } = vi.hoisted(() => ({ |
| 11 | + load: vi.fn(), |
| 12 | + readFile: vi.fn(), |
| 13 | + refresh: vi.fn(), |
| 14 | +})); |
15 | 15 | vi.mock("~/lib/zipPreview", () => ({ |
16 | | - loadZipPreview: async () => ({ |
17 | | - entries, |
18 | | - readImage: async (entry: ZipPreviewEntry) => new Blob([entry.name], { type: "image/png" }), |
19 | | - }), |
| 16 | + loadZipPreview: load, |
20 | 17 | readZipPreviewResponse: vi.fn(), |
21 | 18 | })); |
22 | 19 | vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "light" }) })); |
23 | | -vi.mock("~/components/chat/PierreEntryIcon", () => ({ PierreEntryIcon: () => null })); |
24 | | -vi.mock("./fileSurfaceChrome", () => ({ |
25 | | - FileSurfaceFailure: ({ message }: { message: string }) => <div role="alert">{message}</div>, |
26 | | - FileSurfaceLoading: () => <div role="status">Loading</div>, |
| 20 | +vi.mock("~/assets/assetUrls", () => ({ useAssetUrlRefresh: () => refresh })); |
| 21 | +vi.mock("~/hooks/useCopyToClipboard", () => ({ |
| 22 | + useCopyToClipboard: () => ({ copyToClipboard: vi.fn(), isCopied: false }), |
| 23 | +})); |
| 24 | +vi.mock("~/hooks/useSettings", () => ({ |
| 25 | + useClientSettings: () => false, |
| 26 | + useUpdateClientSettings: () => vi.fn(), |
| 27 | +})); |
| 28 | +vi.mock("~/components/ui/toast", () => ({ toastManager: { add: vi.fn() } })); |
| 29 | +vi.mock("~/components/ChatMarkdown", () => ({ |
| 30 | + default: ({ text }: { text: string }) => <article>{text}</article>, |
| 31 | +})); |
| 32 | +vi.mock("./ReadOnlySourcePreview", () => ({ |
| 33 | + default: ({ text }: { text: string }) => <pre>{text}</pre>, |
27 | 34 | })); |
28 | 35 | vi.mock("~/components/ui/scroll-area", () => ({ |
29 | 36 | ScrollArea: ({ children }: { children: ReactNode }) => children, |
30 | 37 | })); |
| 38 | +vi.mock("./fileSurfaceChrome", () => ({ |
| 39 | + FILE_SURFACE_SUBHEADER_CLASS: "", |
| 40 | + FileSurfaceAction: ({ label, onPress }: { label: string; onPress: () => void }) => ( |
| 41 | + <button aria-label={label} onClick={onPress} /> |
| 42 | + ), |
| 43 | + FileSurfaceFailure: ({ message }: { message: string }) => <div role="alert">{message}</div>, |
| 44 | + FileSurfaceLoading: () => <div role="status">Loading</div>, |
| 45 | + FileSurfaceNotice: ({ children }: { children: ReactNode }) => <div>{children}</div>, |
| 46 | +})); |
31 | 47 |
|
32 | | -let root: Root; |
33 | | -let container: HTMLDivElement; |
34 | | -let intersect: (isIntersecting: boolean) => void; |
35 | | -const revoked: string[] = []; |
| 48 | +function textFile(contents: string) { |
| 49 | + const file = new Blob([contents]); |
| 50 | + vi.spyOn(file, "stream").mockImplementation( |
| 51 | + () => |
| 52 | + new ReadableStream({ |
| 53 | + start(controller) { |
| 54 | + controller.enqueue(new TextEncoder().encode(contents)); |
| 55 | + controller.close(); |
| 56 | + }, |
| 57 | + }), |
| 58 | + ); |
| 59 | + return file; |
| 60 | +} |
| 61 | + |
| 62 | +const entry = (path: string): ZipPreviewEntry => ({ |
| 63 | + path, |
| 64 | + name: path.split("/").at(-1)!, |
| 65 | + isDirectory: path.endsWith("/"), |
| 66 | + sizeBytes: 3, |
| 67 | + mimeType: null, |
| 68 | +}); |
36 | 69 |
|
37 | | -const flush = () => act(async () => {}); |
38 | | -const button = (label: string) => |
39 | | - [...container.querySelectorAll("button")].find( |
40 | | - (element) => |
41 | | - element.getAttribute("aria-label") === label || element.textContent?.startsWith(label), |
42 | | - )!; |
43 | | -const press = (key: string) => |
44 | | - act(() => { |
45 | | - document.activeElement!.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); |
| 70 | +let renderer: ReactTestRenderer; |
| 71 | +const model = () => |
| 72 | + renderer.root.findByType(FileTree).props.model as ReturnType< |
| 73 | + typeof import("@pierre/trees/react").useFileTree |
| 74 | + >["model"]; |
| 75 | +const select = async (path: string) => { |
| 76 | + await act(async () => model().getItem(path)!.select()); |
| 77 | +}; |
| 78 | +const open = async (entries: ZipPreviewEntry[] = [entry("photos/a.png"), entry("src/app.ts")]) => { |
| 79 | + load.mockResolvedValue({ entries, readFile }); |
| 80 | + await act(async () => { |
| 81 | + renderer = create( |
| 82 | + <ZipArchivePreview name="project.zip" file={new Blob()} url="blob:zip" onRetry={() => {}} />, |
| 83 | + ); |
46 | 84 | }); |
| 85 | +}; |
| 86 | +const back = async () => { |
| 87 | + await act(async () => |
| 88 | + renderer.root.findByProps({ "aria-label": "Back to ZIP files" }).props.onClick(), |
| 89 | + ); |
| 90 | +}; |
47 | 91 |
|
48 | | -beforeEach(async () => { |
| 92 | +beforeEach(() => { |
49 | 93 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
50 | 94 | let next = 0; |
51 | | - vi.stubGlobal("URL", { |
52 | | - ...URL, |
53 | | - createObjectURL: () => `blob:${next++}`, |
54 | | - revokeObjectURL: (url: string) => revoked.push(url), |
55 | | - }); |
56 | | - vi.stubGlobal( |
57 | | - "IntersectionObserver", |
58 | | - class { |
59 | | - constructor(callback: (entries: { isIntersecting: boolean }[]) => void) { |
60 | | - intersect = (isIntersecting) => callback([{ isIntersecting }]); |
61 | | - } |
62 | | - observe() {} |
63 | | - disconnect() {} |
64 | | - }, |
65 | | - ); |
66 | | - revoked.length = 0; |
67 | | - container = document.createElement("div"); |
68 | | - document.body.append(container); |
69 | | - root = createRoot(container); |
70 | | - await act(() => |
71 | | - root.render( |
72 | | - <ZipArchivePreview name="photos.zip" file={new Blob()} url="blob:zip" onRetry={() => {}} />, |
73 | | - ), |
74 | | - ); |
75 | | - await flush(); |
| 95 | + vi.spyOn(URL, "createObjectURL").mockImplementation(() => `blob:${next++}`); |
| 96 | + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); |
| 97 | + readFile.mockReset().mockImplementation(async (entry: ZipPreviewEntry) => textFile(entry.name)); |
76 | 98 | }); |
77 | | - |
78 | 99 | afterEach(async () => { |
79 | | - await act(() => root.unmount()); |
80 | | - container.remove(); |
| 100 | + await act(async () => renderer?.unmount()); |
| 101 | + vi.restoreAllMocks(); |
81 | 102 | vi.unstubAllGlobals(); |
82 | 103 | }); |
83 | 104 |
|
84 | | -describe("ZipArchivePreview", () => { |
85 | | - it("releases thumbnails that leave the viewport and reloads them on return", async () => { |
86 | | - await act(() => button("photos").click()); |
87 | | - await act(() => intersect(true)); |
88 | | - await flush(); |
89 | | - expect(container.querySelector("img")?.getAttribute("src")).toBe("blob:0"); |
| 105 | +describe("ZIP file browsing", () => { |
| 106 | + it("does not extract or decode 2,000 images while browsing or searching the tree", async () => { |
| 107 | + await open(Array.from({ length: 2000 }, (_, index) => entry(`photos/image-${index}.png`))); |
| 108 | + await act(async () => model().setSearch("image-1999")); |
| 109 | + expect(model().getSearchMatchingPaths()).toContain("photos/image-1999.png"); |
| 110 | + expect(readFile).not.toHaveBeenCalled(); |
| 111 | + expect(URL.createObjectURL).not.toHaveBeenCalled(); |
| 112 | + expect(renderer.root.findAllByType("img")).toHaveLength(0); |
| 113 | + |
| 114 | + await select("photos/image-1999.png"); |
| 115 | + expect(readFile).toHaveBeenCalledTimes(1); |
| 116 | + expect(renderer.root.findByType("img").props.alt).toBe("image-1999.png"); |
| 117 | + }); |
90 | 118 |
|
91 | | - await act(() => intersect(false)); |
92 | | - expect(container.querySelector("img")).toBeNull(); |
93 | | - expect(revoked).toEqual(["blob:0"]); |
| 119 | + it.each([ |
| 120 | + ["src/app.ts", "export const answer = 42;", "pre"], |
| 121 | + ["logs/app.log", "server ready", "pre"], |
| 122 | + ["README.md", "# ZIP project", "article"], |
| 123 | + ] as const)("opens %s with the shared text viewer", async (path, contents, tag) => { |
| 124 | + readFile.mockResolvedValue(textFile(contents)); |
| 125 | + await open([entry(path)]); |
| 126 | + await select(path); |
| 127 | + expect(renderer.root.findByType(tag).children).toEqual([contents]); |
| 128 | + await back(); |
| 129 | + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:0"); |
| 130 | + await select(path); |
| 131 | + expect(renderer.root.findByType(tag).children).toEqual([contents]); |
| 132 | + }); |
94 | 133 |
|
95 | | - await act(() => intersect(true)); |
96 | | - await flush(); |
97 | | - expect(container.querySelector("img")?.getAttribute("src")).toBe("blob:1"); |
| 134 | + it("shows CSV contents as a table", async () => { |
| 135 | + readFile.mockResolvedValue(textFile("name,value\nexample,42")); |
| 136 | + await open([entry("data.csv")]); |
| 137 | + await select("data.csv"); |
| 138 | + expect(renderer.root.findAllByType("th").map((cell) => cell.children.join(""))).toEqual([ |
| 139 | + "name", |
| 140 | + "value", |
| 141 | + ]); |
| 142 | + expect(renderer.root.findAllByType("td").map((cell) => cell.children.join(""))).toEqual([ |
| 143 | + "example", |
| 144 | + "42", |
| 145 | + ]); |
98 | 146 | }); |
99 | 147 |
|
100 | | - it("keeps keyboard back navigation after the clicked tile unmounts", async () => { |
101 | | - await act(() => button("photos").click()); |
102 | | - await act(() => intersect(true)); |
103 | | - await flush(); |
104 | | - await act(() => button("Open a.png").click()); |
105 | | - await flush(); |
106 | | - expect(container.querySelector("img[alt='a.png']")).not.toBeNull(); |
| 148 | + it("aborts stale extraction and releases the selected file on return", async () => { |
| 149 | + let finish!: (file: Blob) => void; |
| 150 | + let pendingSignal!: AbortSignal; |
| 151 | + readFile.mockImplementationOnce((_entry: ZipPreviewEntry, signal: AbortSignal) => { |
| 152 | + pendingSignal = signal; |
| 153 | + return new Promise<Blob>((resolve) => { |
| 154 | + finish = resolve; |
| 155 | + }); |
| 156 | + }); |
| 157 | + await open(); |
| 158 | + await select("photos/a.png"); |
| 159 | + await select("src/app.ts"); |
| 160 | + expect(pendingSignal.aborted).toBe(true); |
| 161 | + await act(async () => finish(new Blob(["stale image"]))); |
| 162 | + expect(renderer.root.findAllByType("img")).toHaveLength(0); |
| 163 | + expect(renderer.root.findByType("pre").children).toEqual(["app.ts"]); |
| 164 | + expect(URL.createObjectURL).toHaveBeenCalledTimes(1); |
| 165 | + await back(); |
| 166 | + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:0"); |
| 167 | + }); |
107 | 168 |
|
108 | | - await press("Escape"); |
109 | | - expect(container.querySelector("img[alt='a.png']")).toBeNull(); |
110 | | - expect(button("Open a.png")).toBeDefined(); |
| 169 | + it.each(["Escape", "Backspace"])( |
| 170 | + "returns to the tree with %s without consuming text input", |
| 171 | + async (key) => { |
| 172 | + await open(); |
| 173 | + await select("src/app.ts"); |
| 174 | + const keyDown = (editable: boolean) => ({ |
| 175 | + key, |
| 176 | + target: { closest: () => (editable ? {} : null) }, |
| 177 | + preventDefault: vi.fn(), |
| 178 | + stopPropagation: vi.fn(), |
| 179 | + }); |
| 180 | + const editable = keyDown(true); |
| 181 | + await act(async () => renderer.root.findByProps({ tabIndex: -1 }).props.onKeyDown(editable)); |
| 182 | + expect(editable.preventDefault).not.toHaveBeenCalled(); |
| 183 | + expect(renderer.root.findByType("pre").children).toEqual(["app.ts"]); |
| 184 | + const navigation = keyDown(false); |
| 185 | + await act(async () => |
| 186 | + renderer.root.findByProps({ tabIndex: -1 }).props.onKeyDown(navigation), |
| 187 | + ); |
| 188 | + expect(navigation.preventDefault).toHaveBeenCalled(); |
| 189 | + expect(renderer.root.findAllByType("pre")).toHaveLength(0); |
| 190 | + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:0"); |
| 191 | + await select("src/app.ts"); |
| 192 | + expect(renderer.root.findByType("pre").children).toEqual(["app.ts"]); |
| 193 | + }, |
| 194 | + ); |
| 195 | + |
| 196 | + it("keeps a file and folder with the same name accessible without tree path collisions", async () => { |
| 197 | + await open([ |
| 198 | + entry("same"), |
| 199 | + entry("same/"), |
| 200 | + entry("same/app.ts"), |
| 201 | + entry("same (file)"), |
| 202 | + entry("same (file)/"), |
| 203 | + entry("same (file)/nested.txt"), |
| 204 | + ]); |
| 205 | + expect(model().getItem("same/")?.isDirectory()).toBe(true); |
| 206 | + await select("same (file) (file)"); |
| 207 | + expect(readFile).toHaveBeenLastCalledWith( |
| 208 | + expect.objectContaining({ path: "same" }), |
| 209 | + expect.any(AbortSignal), |
| 210 | + ); |
| 211 | + await back(); |
| 212 | + await select("same (file) (file) (file)"); |
| 213 | + expect(readFile).toHaveBeenLastCalledWith( |
| 214 | + expect.objectContaining({ path: "same (file)" }), |
| 215 | + expect.any(AbortSignal), |
| 216 | + ); |
| 217 | + await back(); |
| 218 | + await select("same/app.ts"); |
| 219 | + expect(renderer.root.findByType("pre").children).toEqual(["app.ts"]); |
| 220 | + }); |
111 | 221 |
|
112 | | - await press("Backspace"); |
113 | | - expect(button("photos")).toBeDefined(); |
114 | | - expect(container.querySelector("[aria-current='page']")?.textContent).toBe("photos.zip"); |
| 222 | + it("shows extraction failures without losing the file tree", async () => { |
| 223 | + readFile.mockRejectedValueOnce(new Error("File preview supports files up to 10 MiB.")); |
| 224 | + await open(); |
| 225 | + await select("photos/a.png"); |
| 226 | + expect(renderer.root.findByProps({ role: "alert" }).children).toEqual([ |
| 227 | + "File preview supports files up to 10 MiB.", |
| 228 | + ]); |
| 229 | + await back(); |
| 230 | + await select("src/app.ts"); |
| 231 | + expect(renderer.root.findByType("pre").children).toEqual(["app.ts"]); |
115 | 232 | }); |
116 | 233 | }); |
0 commit comments