Skip to content

Commit 7e4ebb3

Browse files
committed
fix(web): browse ZIP files with shared previews
1 parent 8b62658 commit 7e4ebb3

4 files changed

Lines changed: 501 additions & 430 deletions

File tree

Lines changed: 199 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,116 +1,233 @@
1-
// @vitest-environment jsdom
1+
import { FileTree } from "@pierre/trees/react";
22
import { act, type ReactNode } from "react";
3-
import { createRoot, type Root } from "react-dom/client";
3+
import { create, type ReactTestRenderer } from "react-test-renderer";
44
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
55

66
import type { ZipPreviewEntry } from "~/lib/zipPreview";
77

88
import ZipArchivePreview from "./ZipArchivePreview";
99

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+
}));
1515
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,
2017
readZipPreviewResponse: vi.fn(),
2118
}));
2219
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>,
2734
}));
2835
vi.mock("~/components/ui/scroll-area", () => ({
2936
ScrollArea: ({ children }: { children: ReactNode }) => children,
3037
}));
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+
}));
3147

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+
});
3669

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+
);
4684
});
85+
};
86+
const back = async () => {
87+
await act(async () =>
88+
renderer.root.findByProps({ "aria-label": "Back to ZIP files" }).props.onClick(),
89+
);
90+
};
4791

48-
beforeEach(async () => {
92+
beforeEach(() => {
4993
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
5094
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));
7698
});
77-
7899
afterEach(async () => {
79-
await act(() => root.unmount());
80-
container.remove();
100+
await act(async () => renderer?.unmount());
101+
vi.restoreAllMocks();
81102
vi.unstubAllGlobals();
82103
});
83104

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+
});
90118

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+
});
94133

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+
]);
98146
});
99147

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+
});
107168

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+
});
111221

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"]);
115232
});
116233
});

0 commit comments

Comments
 (0)