Skip to content
Source
Sign inSign up
Open History to see the last commit

ZipArchiveView.test.tsx

318 lines10.9 KB

1import { AsyncResult } from "effect/reactivity";
2import { act, type ComponentProps, type ReactNode } from "react";
3import { createRoot } from "react-dom/client";
4import { expect, it, onTestFinished, vi } from "vite-plus/test";
5import type { ServerConfig } from "@t3tools/contracts";
6​
7import type { ZipTreeRow } from "./zipTree";
8​
9const files = vi.hoisted(() => new Map<string, Uint8Array>());
10const navigation = vi.hoisted(() => ({ remove: null as (() => void) | null }));
11const server = vi.hoisted(() => ({
12 capabilities: { repositoryIdentity: false } as ServerConfig["environment"]["capabilities"],
13 refresh: vi.fn(async () => "file:///first.zip"),
14}));
15​
16vi.mock("../../state/entities", () => ({
17 useEnvironmentServerConfig: () => ({ environment: { capabilities: server.capabilities } }),
18}));
19vi.mock("../../state/assets", () => ({
20 useRefreshAssetUrl: () => server.refresh,
21 useFreshAssetUrl: () => AsyncResult.success({ url: "file:///first.zip", authorizedAt: 0 }),
22}));
23​
24vi.mock("react-native", () => {
25 const Slot = (props: { readonly children?: ReactNode }) => <div>{props.children}</div>;
26 return {
27 View: Slot,
28 Text: Slot,
29 ScrollView: Slot,
30 ActivityIndicator: () => null,
31 Platform: { OS: "ios" },
32 Keyboard: { dismiss: vi.fn() },
33 Pressable: (props: {
34 readonly children?: ReactNode;
35 readonly accessibilityLabel?: string;
36 readonly disabled?: boolean;
37 readonly onPress?: () => void;
38 }) => (
39 <button
40 aria-label={props.accessibilityLabel}
41 disabled={props.disabled}
42 onClick={props.onPress}
43 >
44 {props.children}
45 </button>
46 ),
47 TextInput: (props: {
48 readonly accessibilityLabel?: string;
49 readonly value: string;
50 readonly onChangeText: (value: string) => void;
51 }) => (
52 <input
53 aria-label={props.accessibilityLabel}
54 value={props.value}
55 onChange={(event) => props.onChangeText(event.target.value)}
56 />
57 ),
58 };
59});
60vi.mock("@legendapp/list/react-native", () => ({
61 LegendList: (props: {
62 readonly data: ZipTreeRow[];
63 readonly ListHeaderComponent: ReactNode;
64 readonly ListEmptyComponent: ReactNode;
65 readonly keyExtractor: (row: ZipTreeRow) => string;
66 readonly renderItem: (input: { item: ZipTreeRow }) => ReactNode;
67 }) => (
68 <div>
69 {props.ListHeaderComponent}
70 {props.data.length === 0 ? props.ListEmptyComponent : null}
71 {props.data.map((item) => (
72 <div key={props.keyExtractor(item)}>{props.renderItem({ item })}</div>
73 ))}
74 </div>
75 ),
76}));
77vi.mock("expo-file-system", () => ({
78 Directory: class {
79 create() {}
80 },
81 Paths: { document: { uri: "file:///documents" } },
82 File: class {
83 constructor(private readonly uri: string) {}
84 get exists() {
85 return false;
86 }
87 get size() {
88 return files.get(this.uri)?.byteLength ?? null;
89 }
90 async arrayBuffer() {
91 const bytes = files.get(this.uri);
92 if (!bytes) throw new Error(`Missing ZIP fixture: ${this.uri}`);
93 return Uint8Array.from(bytes).buffer;
94 }
95 },
96}));
97vi.mock("expo/fetch", () => ({ fetch: vi.fn() }));
98vi.mock("expo-crypto", () => ({ randomUUID: vi.fn() }));
99vi.mock("@react-navigation/native", () => ({
100 useNavigation: () => ({ canGoBack: () => false }),
101 usePreventRemove: (enabled: boolean, remove: () => void) => {
102 navigation.remove = enabled ? remove : null;
103 },
104}));
105vi.mock("../../native/NativeScrollViewMarker", () => ({ NativeScrollView: () => null }));
106vi.mock("../../components/ui/AppBar", () => {
107 interface Item {
108 readonly id: string;
109 readonly title?: string;
110 readonly disabled?: boolean;
111 readonly onPress?: () => void;
112 readonly items?: ReadonlyArray<Item>;
113 }
114 const renderItems = (items: ReadonlyArray<Item>): ReactNode =>
115 items.map((item) =>
116 item.items ? (
117 renderItems(item.items)
118 ) : (
119 <button key={item.id} disabled={item.disabled} onClick={item.onPress}>
120 {item.title}
121 </button>
122 ),
123 );
124 return {
125 AppBar: (props: {
126 readonly menus?: ReadonlyArray<{ readonly items: ReadonlyArray<Item> }>;
127 }) => <div>{props.menus?.map((menu) => renderItems(menu.items))}</div>,
128 };
129});
130vi.mock("../../components/ui/useBarInset", () => ({
131 useContentTopInset: () => 0,
132 useContentBottomInset: () => 0,
133}));
134vi.mock("../../components/ui/AppSymbol", () => ({ SymbolView: () => null }));
135vi.mock("../../components/ui/Progress", () => ({ Progress: () => null }));
136vi.mock("../../components/PierreEntryIcon", () => ({ PierreEntryIcon: () => null }));
137vi.mock("../../components/ui/FilePreviewModal", () => ({ FilePreviewModal: () => null }));
138vi.mock("../../lib/copyTextWithHaptic", () => ({}));
139vi.mock("./SourceFileSurface", () => ({
140 SourceFileSurface: (props: { readonly contents: string }) => <pre>{props.contents}</pre>,
141}));
142vi.mock("./AudioFilePreview", () => ({ AudioFilePreview: () => null }));
143vi.mock("./FileMarkdownPreview", () => ({ FileMarkdownPreview: () => null }));
144vi.mock("./WorkspaceFileWebPreview", () => ({ WorkspaceFileWebPreview: () => null }));
145vi.mock("../threads/HtmlRenderWebView", () => ({ HtmlRenderWebView: () => null }));
146vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({
147 useAppearancePreferences: () => ({
148 appearance: { codeWordBreak: false },
149 themeVariables: { "--color-icon": "#000000" },
150 setCodeWordBreak: () => {},
151 }),
152}));
153​
154import { ZipArchiveView } from "./ZipArchiveView";
155import { AttachmentFileScreen } from "./AttachmentFileScreen";
156import { appAtomRegistry } from "../../state/atom-registry";
157import { composerDraftsAtom } from "../../state/use-composer-drafts";
158​
159function setup() {
160 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
161 vi.useFakeTimers({ toFake: ["setImmediate", "clearImmediate"] });
162 files.clear();
163 navigation.remove = null;
164 server.capabilities = { repositoryIdentity: false };
165 server.refresh.mockClear();
166 appAtomRegistry.set(composerDraftsAtom, {});
167 files.set(
168 "file:///first.zip",
169 Uint8Array.from(
170 atob(
171 "UEsDBBQAAAAAAAAAIVyzyFShFgAAABYAAAAJAAAAZmlyc3QudHh0Rmlyc3QgYXJjaGl2ZSBjb250ZW50c1BLAQIUAxQAAAAAAAAAIVyzyFShFgAAABYAAAAJAAAAAAAAAAAAAACAAQAAAABmaXJzdC50eHRQSwUGAAAAAAEAAQA3AAAAPQAAAAAA",
172 ),
173 (character) => character.charCodeAt(0),
174 ),
175 );
176​
177 const container = document.createElement("div");
178 const root = createRoot(container);
179 onTestFinished(() => {
180 act(() => root.unmount());
181 files.clear();
182 vi.useRealTimers();
183 vi.unstubAllGlobals();
184 });
185 const props: ComponentProps<typeof ZipArchiveView> = {
186 name: "archive.zip",
187 subtitle: "ZIP",
188 sizeBytes: 0,
189 uri: "file:///first.zip",
190 isLocal: true,
191 refresh: async () => null,
192 environmentId: null,
193 draftKey: null,
194 maxAttachmentBytes: undefined,
195 onSave: () => {},
196 onRemove: null,
197 };
198 return {
199 container,
200 root,
201 props,
202 render: async (input = props) => {
203 await act(async () => root.render(<ZipArchiveView {...input} />));
204 },
205 settle: async (assert: () => void) => {
206 await act(async () => vi.runAllTimersAsync());
207 assert();
208 },
209 };
211​
212it("opens a ZIP chat attachment as a browsable archive on the attachment file screen", async () => {
213 const screen = setup();
214 server.capabilities = {
215 repositoryIdentity: false,
216 attachmentUploads: true,
217 fileAttachments: { maxUploadBytes: 1024 },
218 };
219 const { fetch } = await import("expo/fetch");
220 vi.mocked(fetch, { partial: true }).mockImplementation(async () => ({
221 ok: true,
222 body: new Response(Uint8Array.from(files.get("file:///first.zip")!)).body,
223 }));
224 await act(async () =>
225 screen.root.render(
226 <AttachmentFileScreen
227 route={{
228 params: {
229 environmentId: "environment-1",
230 threadId: "thread-1",
231 attachmentId: "attachment-1",
232 name: "archive.zip",
233 mimeType: "application/zip",
234 sizeBytes: "200",
235 },
236 }}
237 />,
238 ),
239 );
240 await screen.settle(() =>
241 expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(),
242 );
243 await act(async () =>
244 screen.container.querySelector<HTMLButtonElement>('[aria-label="first.txt"]')!.click(),
245 );
246 await screen.settle(() =>
247 expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"),
248 );
249 const add = Array.from(screen.container.querySelectorAll("button")).find(
250 (button) => button.textContent === "Add to chat",
251 );
252 expect(add?.disabled).toBe(false);
253});
254​
255it("keeps the ZIP filter and archive open when native Back closes a selected entry", async () => {
256 const screen = setup();
257 await screen.render();
258 await screen.settle(() =>
259 expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(),
260 );
261 const filter = screen.container.querySelector<HTMLInputElement>(
262 '[aria-label="Filter files in ZIP"]',
263 )!;
264 await act(async () => {
265 Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(
266 filter,
267 "first",
268 );
269 filter.dispatchEvent(new Event("input", { bubbles: true }));
270 });
271 await act(async () =>
272 screen.container.querySelector<HTMLButtonElement>('[aria-label="first.txt"]')!.click(),
273 );
274 await screen.settle(() =>
275 expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"),
276 );
277​
278 expect(navigation.remove).not.toBeNull();
279 await act(async () => navigation.remove!());
280​
281 expect(screen.container.querySelector("pre")).toBeNull();
282 expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull();
283 expect(
284 screen.container.querySelector<HTMLInputElement>('[aria-label="Filter files in ZIP"]')?.value,
285 ).toBe("first");
286});
287​
288it("explains why an encrypted ZIP cannot be browsed", async () => {
289 const screen = setup();
290 const bytes = files.get("file:///first.zip")!;
291 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
292 const directory = view.getUint32(bytes.length - 6, true);
293 view.setUint16(directory + 8, view.getUint16(directory + 8, true) | 1, true);
294 await screen.render();
295 await screen.settle(() => expect(screen.container.textContent).toContain("Encrypted zip"));
296 expect(screen.container.querySelector('[aria-label="first.txt"]')).toBeNull();
297});
298​
299it("keeps the archive and selected entry when the parent re-renders with a new refresh", async () => {
300 const screen = setup();
301 await screen.render();
302 await screen.settle(() =>
303 expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull(),
304 );
305 await act(async () =>
306 screen.container.querySelector<HTMLButtonElement>('[aria-label="first.txt"]')!.click(),
307 );
308 await screen.settle(() =>
309 expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"),
310 );
311 files.delete("file:///first.zip");
312​
313 await screen.render({ ...screen.props, refresh: async () => null });
314 await screen.settle(() =>
315 expect(screen.container.querySelector("pre")?.textContent).toBe("First archive contents"),
316 );
317 expect(navigation.remove).not.toBeNull();
318});