1import {
2 AuthFilesystemReadScope,
3 EnvironmentId,
4 ThreadId,
5 type AuthSessionState,
6} from "@t3tools/contracts";
7import { AsyncResult } from "effect/reactivity";
8import { createElement, isValidElement, type ReactNode } from "react";
9import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
10
11function deferred<T>() {
12 let resolve!: (value: T | PromiseLike<T>) => void;
13 const promise = new Promise<T>((resolvePromise) => {
14 resolve = resolvePromise;
15 });
16 return { promise, resolve };
17}
18
19const state = vi.hoisted(() => ({
20 sessions: new Map<string, Pick<AuthSessionState, "authenticated" | "scopes">>(),
21 mint: vi.fn(),
22 download: vi.fn(),
23 png: vi.fn(),
24 showMenu: vi.fn(),
25 openFile: vi.fn(),
26 clipboard: vi.fn(),
27 openVideo: vi.fn(),
28 reveal: vi.fn(),
29 local: false,
30 revealLabel: undefined as string | undefined,
31 menuFinished: null as (() => void) | null,
32}));
33
34vi.mock("react", async (importOriginal) => ({
35 ...(await importOriginal<typeof import("react")>()),
36 useCallback: <A,>(callback: A) => callback,
37 useRef: <A,>(current: A) => ({ current }),
38 useState: <A,>(initial: A) => [initial, () => {}],
39}));
40vi.mock("../../fileContextMenu", () => ({
41 useFileContextMenu: () => ({
42 capabilities: { revealLabel: state.revealLabel },
43 activate: state.reveal,
44 }),
45}));
46vi.mock("../../hooks/useCopyToClipboard", () => ({ writeTextToClipboard: vi.fn() }));
47vi.mock("../../localApi", () => ({
48 readLocalApi: () => ({ contextMenu: { show: state.showMenu } }),
49}));
50vi.mock("../../state/assets", () => ({ assetEnvironment: { createUrl: {} } }));
51vi.mock("../../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.mint }));
52vi.mock("../../state/session", () => ({
53 environmentSession: { sessionStateAtom: (environmentId: string) => environmentId },
54 readPreparedConnection: () => ({ httpBaseUrl: "https://host.test" }),
55}));
56vi.mock("../../state/query", () => ({
57 useEnvironmentQuery: (environmentId: string) => ({
58 data: state.sessions.get(environmentId) ?? null,
59 error: null,
60 }),
61}));
62vi.mock("../../rpc/atomRegistry", () => ({
63 appAtomRegistry: {
64 get: (environmentId: string) => {
65 const session = state.sessions.get(environmentId);
66 return session === undefined ? AsyncResult.initial() : AsyncResult.success(session);
67 },
68 },
69}));
70vi.mock("./mediaContent", () => ({ downloadMedia: state.download, readMediaPng: state.png }));
71vi.mock("../ui/tooltip", () => ({
72 Tooltip: "Tooltip",
73 TooltipTrigger: "TooltipTrigger",
74 TooltipPopup: "TooltipPopup",
75}));
76vi.mock("../ui/toast", () => ({
77 stackedThreadToast: <A,>(toast: A) => toast,
78 toastManager: {
79 add: (toast: { type: string }) => {
80 if (toast.type !== "loading") state.menuFinished?.();
81 return "toast";
82 },
83 update: () => state.menuFinished?.(),
84 close: () => state.menuFinished?.(),
85 },
86}));
87
88import { MediaActions, useMediaActions, type MediaActionSource } from "./MediaActions";
89
90const environmentId = EnvironmentId.make("media-environment");
91const otherEnvironmentId = EnvironmentId.make("other-environment");
92const threadId = ThreadId.make("media-thread");
93const granted: Pick<AuthSessionState, "authenticated" | "scopes"> = {
94 authenticated: true,
95 scopes: [AuthFilesystemReadScope],
96};
97const denied: Pick<AuthSessionState, "authenticated" | "scopes"> = {
98 authenticated: true,
99 scopes: [],
100};
101
102function hostSource(_tag: "workspace-file" | "media-file" = "media-file"): MediaActionSource {
103 return {
104 kind: "image",
105 name: "image.png",
106 src: null,
107 asset: { environmentId, resource: { _tag, threadId, path: "/repo/image.png" } },
108 reference: { kind: "file", path: "/repo/image.png", relativePath: "image.png" },
109 onOpenFile: state.openFile,
110 };
111}
112
113function openMenu(source: MediaActionSource) {
114 const find = (node: ReactNode): ((event: unknown) => void) | undefined => {
115 if (Array.isArray(node)) return node.map(find).find((handler) => handler !== undefined);
116 if (!isValidElement<{ children?: ReactNode; onContextMenu?: (event: unknown) => void }>(node))
117 return undefined;
118 return node.props.onContextMenu ?? find(node.props.children);
119 };
120 const handler = find(MediaActions({ source, children: createElement("img") }));
121 if (!handler) throw new Error("Media menu handler missing");
122 handler({
123 defaultPrevented: false,
124 preventDefault() {},
125 stopPropagation() {},
126 currentTarget: { getBoundingClientRect: () => ({ left: 0, bottom: 0 }) },
127 clientX: 1,
128 clientY: 1,
129 });
130}
131
132beforeEach(() => {
133 state.local = false;
134 state.revealLabel = undefined;
135 state.openVideo.mockReset().mockResolvedValue(undefined);
136 state.reveal.mockReset().mockResolvedValue(undefined);
137 vi.stubGlobal("window", {
138 desktopBridge: {
139 openVideo: state.openVideo,
140 supportsLocalTerminal: () => state.local,
141 },
142 });
143 state.sessions.clear();
144 state.sessions.set(environmentId, denied);
145 state.mint
146 .mockReset()
147 .mockResolvedValue(AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 }));
148 state.download.mockReset().mockResolvedValue(undefined);
149 state.png.mockReset().mockResolvedValue(new Blob(["png"], { type: "image/png" }));
150 state.showMenu.mockReset().mockResolvedValue(null);
151 state.openFile.mockReset();
152 state.menuFinished = null;
153 class TestClipboardItem {
154 constructor(readonly items: Record<string, Promise<Blob>>) {}
155 }
156 state.clipboard.mockReset().mockImplementation(async (items: TestClipboardItem[]) => {
157 await Promise.all(items.flatMap((item) => Object.values(item.items)));
158 });
159 vi.stubGlobal("ClipboardItem", TestClipboardItem);
160 vi.stubGlobal("navigator", { clipboard: { write: state.clipboard } });
161});
162
163afterEach(() => vi.unstubAllGlobals());
164
165it.each(["workspace-file", "media-file"] as const)(
166 "waits for the %s grant before enabling host menu actions",
167 (_tag) => {
168 for (const [session, disabled] of [
169 [null, true],
170 [granted, false],
171 [denied, true],
172 ] as const) {
173 if (session === null) state.sessions.delete(environmentId);
174 else state.sessions.set(environmentId, session);
175 openMenu(hostSource(_tag));
176
177 const items = state.showMenu.mock.lastCall![0];
178 expect(items).toEqual(
179 expect.arrayContaining([
180 expect.objectContaining({ id: "save", disabled }),
181 expect.objectContaining({ id: "copy-image", disabled }),
182 expect.objectContaining({ id: "open-file", disabled }),
183 ]),
184 );
185 expect(items).toContainEqual({ id: "copy-full-path", label: "Copy full path" });
186 }
187 expect(state.mint).not.toHaveBeenCalled();
188 expect(state.download).not.toHaveBeenCalled();
189 },
190);
191
192it("keeps nonhost menu actions available with pending or denied host grants", () => {
193 const sources: MediaActionSource[] = [
194 { kind: "image", name: "image.png", src: "https://cdn.test/image.png" },
195 { kind: "image", name: "image.png", src: "blob:local-image" },
196 {
197 kind: "image",
198 name: "image.png",
199 src: null,
200 asset: { environmentId, resource: { _tag: "attachment", attachmentId: "upload" } },
201 },
202 ];
203 for (const session of [null, denied]) {
204 if (session === null) state.sessions.delete(environmentId);
205 else state.sessions.set(environmentId, session);
206 for (const source of sources) {
207 openMenu(source);
208 expect(state.showMenu.mock.lastCall![0]).toEqual(
209 expect.arrayContaining([
210 expect.objectContaining({ id: "save", disabled: false }),
211 expect.objectContaining({ id: "copy-image", disabled: false }),
212 ]),
213 );
214 }
215 }
216});
217
218it.each(["workspace-file", "media-file"] as const)(
219 "disables denied %s byte actions and prevents imperative requests",
220 async (_tag) => {
221 const source = hostSource(_tag);
222 openMenu(source);
223 expect(state.showMenu).toHaveBeenCalledWith(
224 expect.arrayContaining([
225 expect.objectContaining({ id: "save", disabled: true }),
226 expect.objectContaining({ id: "copy-image", disabled: true }),
227 expect.objectContaining({ id: "open-file", disabled: true }),
228 ]),
229 { x: 1, y: 1 },
230 );
231 await expect(useMediaActions(source).save()).rejects.toThrow("cannot read host files");
232 await expect(useMediaActions(source).copyImage()).rejects.toThrow("cannot read host files");
233 expect(state.mint).not.toHaveBeenCalled();
234 expect(state.download).not.toHaveBeenCalled();
235 expect(state.clipboard).not.toHaveBeenCalled();
236 },
237);
238
239it.each(["save", "copy-image", "open-file"])(
240 "rechecks access when %s is selected from an already open native menu",
241 async (action) => {
242 state.sessions.set(environmentId, granted);
243 const choice = deferred<string>();
244 const completed = deferred<void>();
245 state.showMenu.mockReturnValue(choice.promise);
246 state.menuFinished = () => completed.resolve();
247 state.openFile.mockImplementation(() => completed.resolve());
248 openMenu(hostSource());
249
250 state.sessions.set(environmentId, denied);
251 choice.resolve(action);
252 await completed.promise;
253
254 expect(state.mint).not.toHaveBeenCalled();
255 expect(state.download).not.toHaveBeenCalled();
256 expect(state.clipboard).not.toHaveBeenCalled();
257 expect(state.openFile).not.toHaveBeenCalled();
258 },
259);
260
261it("reenables the menu and a retained action when file access is gained", async () => {
262 const actions = useMediaActions(hostSource());
263 state.sessions.set(environmentId, granted);
264 openMenu(hostSource());
265
266 await actions.save();
267
268 expect(state.download).toHaveBeenCalledOnce();
269 expect(state.showMenu).toHaveBeenCalledWith(
270 expect.arrayContaining([expect.objectContaining({ id: "save", disabled: false })]),
271 expect.anything(),
272 );
273});
274
275it.each([false, true])("uses the media environment's grant (allowed: %s)", async (allowed) => {
276 state.sessions.set(environmentId, allowed ? granted : denied);
277 state.sessions.set(otherEnvironmentId, allowed ? denied : granted);
278
279 await useMediaActions(hostSource())
280 .save()
281 .catch(() => {});
282
283 expect(state.mint).toHaveBeenCalledTimes(allowed ? 1 : 0);
284 expect(state.download).toHaveBeenCalledTimes(allowed ? 1 : 0);
285});
286
287it("stops before downloading if access is revoked while minting the URL", async () => {
288 state.sessions.set(environmentId, granted);
289 state.mint.mockImplementation(async () => {
290 state.sessions.set(environmentId, denied);
291 return AsyncResult.success({ relativeUrl: "/api/assets/image.png", expiresAt: 1 });
292 });
293
294 await expect(useMediaActions(hostSource()).save()).rejects.toThrow("cannot read host files");
295
296 expect(state.download).not.toHaveBeenCalled();
297});
298
299it("lets the server authorize an explicit action before the grant resolves", async () => {
300 state.sessions.delete(environmentId);
301
302 await useMediaActions(hostSource()).save();
303
304 expect(state.mint).toHaveBeenCalledOnce();
305 expect(state.download).toHaveBeenCalledOnce();
306});
307
308it("saves uploaded attachments without filesystem access", async () => {
309 await useMediaActions({
310 kind: "image",
311 name: "image.png",
312 src: null,
313 asset: { environmentId, resource: { _tag: "attachment", attachmentId: "upload" } },
314 }).save();
315
316 expect(state.mint).toHaveBeenCalledOnce();
317 expect(state.download).toHaveBeenCalledOnce();
318});
319
320it.each(["https://cdn.test/image.png", "blob:local-image"])(
321 "saves direct media without a host grant: %s",
322 async (src) => {
323 await useMediaActions({ kind: "image", name: "image.png", src }).save();
324
325 expect(state.mint).not.toHaveBeenCalled();
326 expect(state.download).toHaveBeenCalledWith(src, "image.png");
327 },
328);
329
330function videoSource(): MediaActionSource {
331 const source = hostSource();
332 return {
333 ...source,
334 kind: "video",
335 name: "clip.mp4",
336 reference: { kind: "file", path: "/repo/clip.mp4" },
337 asset: { environmentId, resource: { _tag: "media-file", threadId, path: "/repo/clip.mp4" } },
338 };
339}
340
341it("offers Finder only for a confirmed native-local file, and Download for a remote file", () => {
342 state.sessions.set(environmentId, granted);
343 state.revealLabel = "Reveal in Finder";
344 openMenu(videoSource());
345 const remoteItems = state.showMenu.mock.lastCall![0];
346 expect(remoteItems).toContainEqual(
347 expect.objectContaining({ id: "save", label: "Download video" }),
348 );
349 expect(remoteItems).not.toContainEqual(expect.objectContaining({ id: "reveal-in-folder" }));
350 state.local = true;
351 openMenu(videoSource());
352 const localItems = state.showMenu.mock.lastCall![0];
353 expect(localItems).toContainEqual(
354 expect.objectContaining({ id: "reveal-in-folder", label: "Reveal in Finder" }),
355 );
356 expect(localItems).not.toContainEqual(expect.objectContaining({ id: "save" }));
357 expect(localItems).toContainEqual(
358 expect.objectContaining({ id: "open-quicktime", disabled: false }),
359 );
360});
361
362it("opens a fresh signed URL on the client without forwarding an environment path", async () => {
363 state.sessions.set(environmentId, granted);
364 await useMediaActions(videoSource()).openQuickTime();
365 expect(state.mint).toHaveBeenCalledWith({
366 environmentId,
367 input: { resource: videoSource().asset!.resource },
368 });
369 expect(state.openVideo).toHaveBeenCalledWith({
370 url: "https://host.test/api/assets/image.png",
371 name: "clip.mp4",
372 });
373});
374
375it("rechecks file access before QuickTime or Finder actions retained from an old menu", async () => {
376 state.local = true;
377 state.revealLabel = "Reveal in Finder";
378 state.sessions.set(environmentId, granted);
379 const actions = useMediaActions(videoSource());
380 state.sessions.set(environmentId, denied);
381 await expect(actions.openQuickTime()).rejects.toThrow("cannot read host files");
382 await expect(actions.reveal()).rejects.toThrow("cannot read host files");
383 expect(state.openVideo).not.toHaveBeenCalled();
384 expect(state.reveal).not.toHaveBeenCalled();
385});
386
387it("does not reveal remote paths even if they exist on the client too", async () => {
388 state.sessions.set(environmentId, granted);
389 state.revealLabel = "Reveal in Finder";
390 await useMediaActions(videoSource()).reveal();
391 expect(state.reveal).not.toHaveBeenCalled();
392});
393
394it("hides QuickTime when the desktop platform does not expose it", () => {
395 vi.stubGlobal("window", {});
396 state.sessions.set(environmentId, granted);
397 openMenu(videoSource());
398 expect(state.showMenu.mock.lastCall![0]).not.toContainEqual(
399 expect.objectContaining({ id: "open-quicktime" }),
400 );
401});
402
403it("applies file grants and native-local reveal to draft document videos", async () => {
404 const source: MediaActionSource = {
405 ...videoSource(),
406 asset: {
407 environmentId,
408 resource: { _tag: "draft-workspace-file", cwd: "/repo", path: "/repo/clip.mp4" },
409 },
410 };
411 state.local = true;
412 state.revealLabel = "Reveal in Finder";
413 openMenu(source);
414 expect(state.showMenu.mock.lastCall![0]).toEqual(
415 expect.arrayContaining([
416 expect.objectContaining({ id: "reveal-in-folder", disabled: true }),
417 expect.objectContaining({ id: "open-quicktime", disabled: true }),
418 ]),
419 );
420 await expect(useMediaActions(source).openQuickTime()).rejects.toThrow("cannot read host files");
421 expect(state.mint).not.toHaveBeenCalled();
422 state.sessions.set(environmentId, granted);
423 await useMediaActions(source).reveal();
424 expect(state.reveal).toHaveBeenCalledWith("reveal-in-folder", {
425 environmentId,
426 filePath: "/repo/clip.mp4",
427 workspaceRoot: undefined,
428 });
429 await useMediaActions(source).openQuickTime();
430 expect(state.mint).toHaveBeenCalledWith({
431 environmentId,
432 input: { resource: source.asset!.resource },
433 });
434});