Skip to content
Source
Sign inSign up

filePreview.test.ts

1,436 lines56.9 KB

1import {
2 act,
3 createElement,
4 Fragment,
5 useEffect,
6 useLayoutEffect,
7 useMemo,
8 type ReactNode,
9} from "react";
10import { createRoot } from "react-dom/client";
11import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
12​
13import { inlineFilePreviewKind, isPdfFile } from "./filePreview";
14import { ThreadInlineFilePreview } from "../features/threads/ThreadInlineFilePreview";
15import { AudioFilePreview } from "../features/files/AudioFilePreview";
16import { MediaVideoPlayer } from "../components/MediaVideoPlayer";
17import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
18import { acquireAudioSession, activateAudioSession, releaseAudioSession } from "./audioSession";
19import { ThreadFeed } from "../features/threads/ThreadFeed";
20import type { ThreadFeedEntry } from "./threadActivity";
21import type { MarkdownImageRenderer } from "../native/SelectableMarkdownText";
22​
23const audio = vi.hoisted(() => ({
24 status: {
25 playing: false,
26 isLoaded: false,
27 error: null,
28 didJustFinish: false,
29 currentTime: 0,
30 duration: 10,
31 isBuffering: false,
32 },
33 player: {
34 play: vi.fn(),
35 pause: vi.fn((_deactivateSession?: boolean) => {}),
36 release: vi.fn(),
37 replace: vi.fn(),
38 seekTo: vi.fn(async () => {}),
39 },
40 refresh: vi.fn<() => Promise<string | null>>(),
41 setAudioMode: vi.fn(async (_mode: unknown) => {}),
42 setActive: vi.fn(async (_active: boolean) => {}),
43 setSessionOwner: vi.fn((_owned: boolean) => {}),
44 sessionOwned: false,
45 enabled: true,
46 played: vi.fn(),
47 video: {
48 playing: false,
49 playbackRequested: false,
50 status: "readyToPlay",
51 play: vi.fn(),
52 pause: vi.fn(),
53 replaceAsync: vi.fn(async (_source: unknown) => {}),
54 addListener: vi.fn(),
55 },
56 videoListeners: new Set<(event: { isPlaying: boolean }) => void>(),
57 videoStatusListeners: new Set<(event: { status: string }) => void>(),
58 videoEndListeners: new Set<() => void>(),
59 videoRequestListeners: new Set<(event: { isPlaybackRequested: boolean }) => void>(),
60 navigate: vi.fn(),
61 query: vi.fn(),
62}));
63vi.mock("expo-audio", () => ({
64 AudioModule: { setAudioSessionOwner: audio.setSessionOwner },
65 useAudioPlayer: () => audio.player,
66 createAudioPlayer: () => audio.player,
67 useAudioPlayerStatus: () => audio.status,
68 setAudioModeAsync: audio.setAudioMode,
69 setIsAudioActiveAsync: audio.setActive,
70}));
71vi.mock("@react-navigation/native", () => ({
72 useIsFocused: () => true,
73 useNavigation: () => ({ navigate: audio.navigate }),
74 useRoute: () => ({ name: "Thread", params: {} }),
75 useFocusEffect: (callback: () => (() => void) | undefined) => useEffect(callback, [callback]),
76}));
77vi.mock("react-native", () => ({
78 Platform: { OS: "android", select: (values: { android?: unknown }) => values.android },
79 StyleSheet: { create: (styles: unknown) => styles },
80 useWindowDimensions: () => ({ width: 390, height: 844, fontScale: 1 }),
81 Text: ({ children }: { children?: ReactNode }) => createElement("span", null, children),
82 AppState: { currentState: "active", addEventListener: () => ({ remove() {} }) },
83 AccessibilityInfo: {
84 isReduceMotionEnabled: async () => true,
85 addEventListener: () => ({ remove() {} }),
86 },
87 View: ({ children }: { children?: ReactNode }) => createElement("div", null, children),
88 Pressable: (props: {
89 children?: ReactNode;
90 onPress: () => void;
91 disabled?: boolean;
92 accessibilityLabel?: string;
93 accessibilityState?: { busy: boolean };
94 }) =>
95 createElement(
96 "button",
97 {
98 onClick: props.onPress,
99 disabled: props.disabled,
100 "aria-label": props.accessibilityLabel,
101 "aria-busy": props.accessibilityState?.busy,
102 },
103 props.children,
104 ),
105}));
106vi.mock("../components/PierreEntryIcon", () => ({ PierreEntryIcon: () => null }));
107vi.mock("../components/ui/AppText", () => ({
108 AppText: ({ children }: { children?: ReactNode }) => createElement("span", null, children),
109}));
110vi.mock("../components/ui/AppSymbol", () => ({ SymbolView: () => null }));
111vi.mock("../components/ui/Button", () => ({
112 Button: (props: { label: string; onPress(): void; disabled?: boolean }) =>
113 createElement("button", { onClick: props.onPress, disabled: props.disabled }, props.label),
114}));
115vi.mock("expo", () => ({
116 useEvent: (_player: unknown, _event: unknown, initial: unknown) => initial,
117 useReleasingSharedObjectWithLifecycle: (
118 lifecycle: {
119 factory: () => typeof audio.player;
120 release: (player: typeof audio.player) => void;
121 },
122 dependencies: ReadonlyArray<unknown>,
123 ) => {
124 const player = useMemo(lifecycle.factory, dependencies);
125 useEffect(() => () => lifecycle.release(player), [player]);
126 return player;
127 },
128}));
129vi.mock("expo-video", () => ({
130 useVideoPlayer: () => audio.video,
131 VideoView: () => null,
132}));
133vi.mock("../components/VideoThumbnailImage", () => ({ VideoThumbnailImage: () => null }));
134vi.mock("../components/ui/AppActivityIndicator", () => ({ AppActivityIndicator: () => null }));
135vi.mock("../components/MediaActionsMenu", () => ({
136 MediaActionsMenu: ({ children }: { children?: ReactNode }) => children,
137}));
138vi.mock("../state/mediaActions", () => ({
139 useMediaActions: () => ({ sharing: false, actions: [] }),
140}));
141vi.mock("./videoAudioSession", () => ({
142 useMediaAudioSessionHold() {},
143 useFullscreenVideoOpen: () => false,
144}));
145vi.mock("../state/assets", () => ({
146 useRefreshAssetUrl: () => audio.refresh,
147 useAssetUrlState: () => ({ _tag: "Success", url: "https://example.com/clip.mp4" }),
148 assetEnvironment: {},
149}));
150vi.mock("../features/threads/ThreadMarkdownVideo", async (importOriginal) => {
151 const { createContext } = await import("react");
152 const actual = await importOriginal<typeof import("../features/threads/ThreadMarkdownVideo")>();
153 return {
154 ...actual,
155 ThreadMediaVisibleContext: createContext(true),
156 ThreadMarkdownVideo: (props: Parameters<typeof actual.ThreadMarkdownVideo>[0]) =>
157 createElement(actual.ThreadMarkdownVideo, { ...props, thumbnailVisible: true }),
158 };
159});
160​
161vi.mock("@t3tools/client-runtime/state/runtime", async (importOriginal) => ({
162 ...(await importOriginal<typeof import("@t3tools/client-runtime/state/runtime")>()),
163 executeAtomQuery: (...args: unknown[]) => audio.query(...args),
164}));
165vi.mock("../state/projects", () => ({
166 projectEnvironment: {
167 searchEntries: (input: unknown) => input,
168 fileMetadata: (input: unknown) => input,
169 },
170}));
171vi.mock("../state/server", () => ({ serverEnvironment: {} }));
172vi.mock("../state/session", () => ({
173 readEnvironmentScope: () => true,
174 usePreparedConnection: () => ({ _tag: "Some", value: { httpBaseUrl: "https://example.com" } }),
175}));
176vi.mock("../state/atom-registry", () => ({ appAtomRegistry: {} }));
177vi.mock("../state/threads", () => ({ environmentThreadShells: {}, threadEnvironment: {} }));
178vi.mock("../state/entities", () => ({
179 useLiveThreadLinkLabels: (text: string) => text,
180 useThreadChipIcons: () => new Map(),
181}));
182vi.mock("../state/use-thread-selection", () => ({
183 useThreadSelection: () => ({ selectedThread: null }),
184}));
185vi.mock("../state/presentation", () => ({
186 useEnvironmentPresentation: () => ({ isReady: true, presentation: null }),
187}));
188vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() }));
189vi.mock("../state/use-atom-command", () => ({}));
190vi.mock("../state/v2-item-support", () => ({}));
191vi.mock("../native/SelectableMarkdownText", () => ({
192 SelectableMarkdownText: (props: {
193 markdown: string;
194 onLinkPress(href: string): void;
195 renderImage?: MarkdownImageRenderer;
196 }) => {
197 const embed = props.markdown.match(/!\[([^\]]*)\]\(([^)]+)\)/);
198 return createElement(
199 "div",
200 null,
201 createElement("button", { onClick: () => props.onLinkPress("target.ts") }, props.markdown),
202 embed && props.renderImage?.({ href: embed[2]!, alt: embed[1]!, title: null }),
203 );
204 },
205}));
206vi.mock("@legendapp/list/keyboard", () => ({
207 KeyboardAwareLegendList: (props: {
208 data: ThreadFeedEntry[];
209 renderItem(info: { item: ThreadFeedEntry; index: number }): ReactNode;
210 }) =>
211 props.data.map((item, index) =>
212 createElement("div", { key: item.id }, props.renderItem({ item, index })),
213 ),
214}));
215vi.mock("@legendapp/list/react-native", () => ({
216 useViewabilityAmount: (callback: (token: { sizeVisible: number }) => void) =>
217 useEffect(() => callback({ sizeVisible: 1 }), [callback]),
218}));
219vi.mock("react-native-reanimated", () => {
220 const transition = { delay: () => transition, duration: () => transition };
221 return {
222 default: { View: ({ children }: { children?: ReactNode }) => children },
223 FadeIn: transition,
224 FadeInDown: transition,
225 FadeInUp: transition,
226 };
227});
228vi.mock("react-native-safe-area-context", () => ({
229 useSafeAreaInsets: () => ({ top: 0, bottom: 0 }),
230}));
231vi.mock("expo-haptics", () => ({ selectionAsync: async () => {} }));
232vi.mock("expo-image", () => ({}));
233vi.mock("uniwind", () => ({}));
234vi.mock("../components/ui/Dialog", () => ({}));
235vi.mock("../components/ui/NativePresentation", () => ({
236 PresentationSource: ({ children }: { children?: ReactNode }) => children,
237}));
238vi.mock("../components/ui/Menu", () => ({
239 Menu: ({ children }: { children?: ReactNode }) => children,
240}));
241vi.mock("../components/ui/FilePreviewGallery", () => ({
242 FilePreviewGallery: ({ children }: { children?: ReactNode }) => children,
243}));
244vi.mock("../components/ui/FilePreviewModal", () => ({
245 FilePreviewModal: (props: { source: { name: string } | null }) =>
246 props.source ? createElement("div", { "data-file-preview": props.source.name }) : null,
247}));
248vi.mock("../components/ui/VideoPreviewModal", () => ({ VideoPreviewModal: () => null }));
249vi.mock("../components/ComposerContextSheet", () => ({}));
250vi.mock("../native/useOpenPullRequest", () => ({ useOpenPullRequest: () => vi.fn() }));
251vi.mock("../native/useMarkdownImageSource", () => ({ useMarkdownImageSource: () => vi.fn() }));
252vi.mock("../native/useEnrichedLinkVariants", () => ({
253 enrichedThreadLinkCustomization: () => undefined,
254 enrichedContextLinkCustomization: () => undefined,
255 withChipIcon: (value: unknown) => value,
256}));
257vi.mock("../native/NativeScrollViewMarker", () => ({
258 NativeScrollViewMarker: ({ children }: { children?: ReactNode }) => children,
259}));
260vi.mock("../native/NativeWorkspaceColumns", () => ({
261 useNativeWorkspaceColumnsSupported: () => false,
262}));
263vi.mock("../native/native-layout-metrics", () => ({
264 useDeviceType: () => "phone",
265 useNativeColumnLayoutMetrics: () => null,
266}));
267vi.mock("../features/layout/workspace-content-width", () => ({
268 useWorkspaceSettledContentWidth: () => null,
269}));
270vi.mock("../features/settings/appearance/AppearancePreferencesProvider", () => ({
271 useAppearancePreferences: () => ({ appearance: { baseFontSize: 14 }, themeAppearance: "dark" }),
272}));
273vi.mock("./useUniwindTheme", () => ({
274 useUniwindTheme: () => new Proxy({}, { get: () => "#000000" }),
275}));
276vi.mock("./composerContextClipboard", () => ({}));
277vi.mock("./copyTextWithHaptic", () => ({}));
278vi.mock("./openExternalUrl", () => ({}));
279vi.mock("./attachmentDownload", () => ({}));
280vi.mock("./uuid", () => ({}));
281vi.mock("../features/threads/thread-context-divider", () => ({}));
282vi.mock("../features/threads/thread-handoff-row", () => ({ ThreadModelSwitchDivider: () => null }));
283vi.mock("../features/threads/ThreadGuestShareBar", () => ({ useThreadGuestShare: () => null }));
284vi.mock("../features/threads/SecretRequestCard", () => ({}));
285vi.mock("../features/threads/LinkRequestCard", () => ({ LinkRequestCard: () => null }));
286vi.mock("../features/threads/worktree-setup-card", () => ({}));
287vi.mock("../features/threads/CopyTextButton", () => ({ CopyTextButton: () => null }));
288vi.mock("../features/threads/thread-work-log", () => ({ THREAD_DISCLOSURE_TRANSITION_MS: 100 }));
289vi.mock("../features/threads/HtmlRenderWebView", () => ({}));
290vi.mock("../features/threads/McpAppWebView", () => ({}));
291vi.mock("../features/layout/AdaptiveWorkspaceLayout", () => ({
292 useWorkspacePaneState: () => ({ fileInspector: { supported: false } }),
293}));
294vi.mock("../features/threads/thread-workflow-opening", () => ({ useWorkflowOpening: () => null }));
295vi.mock("../features/threads/useFileChipShare", () => ({ useFileChipShare: () => vi.fn() }));
296vi.mock("../features/threads/ThreadMarkdownImage", async () => {
297 const { createContext } = await import("react");
298 return { MarkdownImageAvailableWidthContext: createContext(390) };
299});
300vi.mock("../features/threads/ZoomableImagePressable", async () => {
301 const { Pressable } = await import("react-native");
302 return { ZoomableImagePressable: Pressable };
303});
304vi.mock("../features/review/ReviewCommentCard", () => ({ useReviewCommentColors: () => ({}) }));
305vi.mock("../features/prompt-links/prompt-link-actions", () => ({}));
306vi.mock("@react-navigation/elements", async () => {
307 const { createContext } = await import("react");
308 return { HeaderHeightContext: createContext(0) };
309});
310​
311describe("ThreadInlineFilePreview playback", () => {
312 let root: ReturnType<typeof createRoot> | undefined;
313 beforeEach(() => {
314 audio.sessionOwned = false;
315 audio.setSessionOwner.mockImplementation((owned) => {
316 audio.sessionOwned = owned;
317 });
318 audio.player.pause.mockReset();
319 audio.enabled = true;
320 audio.setActive.mockImplementation(async (enabled) => {
321 audio.enabled = enabled;
322 });
323 // Expo's play() silently does nothing while its global gate is disabled.
324 audio.player.play.mockImplementation(() => {
325 if (audio.enabled) audio.played();
326 });
327 audio.video.addListener.mockImplementation(
328 (event: string, listener: (event: { isPlaying: boolean }) => void) => {
329 if (event === "playbackRequestedChange") {
330 const requestListener = listener as unknown as (event: {
331 isPlaybackRequested: boolean;
332 }) => void;
333 audio.videoRequestListeners.add(requestListener);
334 return { remove: () => audio.videoRequestListeners.delete(requestListener) };
335 }
336 if (event === "statusChange") {
337 const statusListener = listener as unknown as (event: { status: string }) => void;
338 audio.videoStatusListeners.add(statusListener);
339 return { remove: () => audio.videoStatusListeners.delete(statusListener) };
340 }
341 if (event === "playToEnd") {
342 const endListener = listener as unknown as () => void;
343 audio.videoEndListeners.add(endListener);
344 return { remove: () => audio.videoEndListeners.delete(endListener) };
345 }
346 audio.videoListeners.add(listener);
347 return { remove: () => audio.videoListeners.delete(listener) };
348 },
349 );
350 audio.video.play.mockImplementation(() => {
351 audio.video.playing = true;
352 audio.video.playbackRequested = true;
353 for (const listener of audio.videoRequestListeners) listener({ isPlaybackRequested: true });
354 for (const listener of audio.videoListeners) listener({ isPlaying: audio.video.playing });
355 });
356 audio.video.pause.mockImplementation(() => {
357 const wasRequested = audio.video.playbackRequested;
358 const wasPlaying = audio.video.playing;
359 audio.video.playbackRequested = false;
360 audio.video.playing = false;
361 if (wasRequested) {
362 for (const listener of audio.videoRequestListeners)
363 listener({ isPlaybackRequested: false });
364 }
365 if (wasPlaying) {
366 for (const listener of audio.videoListeners) listener({ isPlaying: false });
367 }
368 });
369 });
370 afterEach(async () => {
371 await act(async () => root?.unmount());
372 root = undefined;
373 vi.clearAllMocks();
374 audio.refresh.mockReset();
375 audio.setAudioMode.mockReset();
376 audio.setActive.mockReset();
377 audio.video.status = "readyToPlay";
378 audio.status = {
379 playing: false,
380 isLoaded: false,
381 error: null,
382 didJustFinish: false,
383 currentTime: 0,
384 duration: 10,
385 isBuffering: false,
386 };
387 });
388​
389 it.each(
390 (["user", "assistant"] as const).flatMap((role) =>
391 (["attachment", "markdown"] as const).flatMap((origin) =>
392 (["audio", "video"] as const).flatMap((kind) =>
393 (kind === "video"
394 ? ["initial play", "native resume", "continued playback"]
395 : ["initial play"]
396 ).map((playback) => ({
397 role,
398 origin,
399 kind,
400 playback,
401 outcome: playback === "continued playback" ? "preserves" : "cancels",
402 })),
403 ),
404 ),
405 ),
406 )(
407 "$outcome a pending file lookup on $role $origin $kind $playback",
408 async ({ role, origin, kind, playback }) => {
409 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
410 const environmentId = EnvironmentId.make("environment-1");
411 const threadId = ThreadId.make("thread-1");
412 const messageId = MessageId.make("message-1");
413 const timestamp = "2026-10-20T00:00:00.000Z";
414 const name = kind === "audio" ? "clip.mp3" : "clip.mp4";
415 const mimeType = kind === "audio" ? "audio/mpeg" : "video/mp4";
416 const uri = `https://example.com/${name}`;
417 const feed: ThreadFeedEntry[] = [
418 {
419 type: "message",
420 id: messageId,
421 createdAt: timestamp,
422 message: {
423 id: messageId,
424 role,
425 text: `open target.ts${origin === "markdown" ? `\n\n![${name}](${uri})` : ""}`,
426 attachments:
427 origin === "attachment"
428 ? [{ type: "file", id: "clip", name, mimeType, sizeBytes: 12 }]
429 : [],
430 createdAt: timestamp,
431 updatedAt: timestamp,
432 runId: null,
433 streaming: false,
434 visibility: "local",
435 sourceThreadId: threadId,
436 },
437 },
438 ];
439 let finishLookup!: (value: unknown) => void;
440 const lookup = new Promise((resolve) => {
441 finishLookup = resolve;
442 });
443 audio.query
444 .mockResolvedValue({ _tag: "Success", value: { kind: "file" } })
445 .mockReturnValueOnce(lookup);
446 audio.refresh.mockResolvedValue(uri);
447 const container = document.createElement("div");
448 root = createRoot(container);
449 await act(async () =>
450 root!.render(
451 createElement(ThreadFeed, {
452 environmentId,
453 threadId,
454 detailRef: null,
455 threadTitle: "preview",
456 workspaceRoot: "/repo",
457 feed,
458 queuedMessages: [],
459 dispatchingMessageId: null,
460 onEditPendingMessage: null,
461 contentPresentation: { kind: "ready" },
462 latestRun: null,
463 activeWorkStartedAt: null,
464 listRef: { current: null },
465 freeze: { value: false } as Parameters<typeof ThreadFeed>[0]["freeze"],
466 contentInsetEndAdjustment: { value: 0 } as Parameters<
467 typeof ThreadFeed
468 >[0]["contentInsetEndAdjustment"],
469 anchorMessageId: null,
470 keyboardOffset: 0,
471 }),
472 ),
473 );
474 const link = Array.from(container.querySelectorAll("button")).find((button) =>
475 button.textContent?.startsWith("open target.ts"),
476 )!;
477 const playLabel = kind === "audio" ? "Play audio" : `Play ${name}`;
478 const play = () =>
479 container.querySelector<HTMLButtonElement>(`[aria-label="${playLabel}"]`)!.click();
480 if (playback !== "initial play") {
481 await act(async () => play());
482 expect(audio.video.playing).toBe(true);
483 if (playback === "native resume") {
484 await act(async () => audio.video.pause());
485 expect(audio.video.playing).toBe(false);
486 } else {
487 await act(async () => {
488 for (const listener of audio.videoRequestListeners)
489 listener({ isPlaybackRequested: true });
490 });
491 }
492 }
493 await act(async () => link.click());
494 const signal: AbortSignal = audio.query.mock.calls[0]![2].signal;
495 expect(signal.aborted).toBe(false);
496 expect(audio.navigate).not.toHaveBeenCalled();
497 await act(async () => {
498 if (playback === "native resume") {
499 // Native resume requests playback before buffering advances the clock.
500 audio.video.playbackRequested = true;
501 for (const listener of audio.videoRequestListeners)
502 listener({ isPlaybackRequested: true });
503 expect(audio.video.playing).toBe(false);
504 } else if (playback === "continued playback") {
505 for (const isPlaying of [false, true, false, true]) {
506 audio.video.playing = isPlaying;
507 for (const listener of audio.videoListeners) listener({ isPlaying });
508 for (const listener of audio.videoRequestListeners)
509 listener({ isPlaybackRequested: true });
510 }
511 expect(signal.aborted).toBe(false);
512 } else play();
513 });
514 expect(kind === "audio" ? audio.played : audio.video.play).toHaveBeenCalled();
515 expect(audio.sessionOwned).toBe(true);
516 await act(async () =>
517 finishLookup({
518 _tag: "Success",
519 value: { entries: [{ path: "src/target.ts", kind: "file" }] },
520 }),
521 );
522 if (playback === "continued playback") {
523 expect(audio.navigate).toHaveBeenCalledExactlyOnceWith("ThreadFile", {
524 environmentId,
525 threadId,
526 path: ["src", "target.ts"],
527 });
528 expect(signal.aborted).toBe(false);
529 } else {
530 expect(audio.navigate).not.toHaveBeenCalled();
531 expect(signal.aborted).toBe(true);
532 }
533 expect(container.querySelector(`[aria-label="${playLabel}"]`)).toBeNull();
534 if (kind === "audio")
535 expect(container.querySelector('[aria-label="Pause audio"]')).not.toBeNull();
536 else {
537 if (playback === "native resume") {
538 audio.video.playing = true;
539 for (const listener of audio.videoListeners) listener({ isPlaying: true });
540 }
541 expect(audio.video.playing).toBe(true);
542 }
543 },
544 );
545​
546 it.each(
547 ["takeover", "pause", "finish"].flatMap((release) => [
548 ["inline", "video", release],
549 ["viewer", "video", release],
550 ["inline", "native video controls", release],
551 ["viewer", "native video controls", release],
552 ["inline", "recording", release],
553 ["viewer", "recording", release],
554 ]),
555 )(
556 "keeps the shared session active when %s audio yields to %s after %s",
557 async (source, successor, release) => {
558 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
559 audio.status = { ...audio.status, isLoaded: true };
560 const container = document.createElement("div");
561 root = createRoot(container);
562 const render = (playVideo = false, showAudio = true) =>
563 root!.render(
564 createElement(
565 "div",
566 null,
567 showAudio
568 ? source === "inline"
569 ? createElement(ThreadInlineFilePreview, {
570 source: {
571 kind: "document",
572 name: "sound.mp3",
573 uri: "https://example.com/sound.mp3",
574 },
575 onOpen() {},
576 })
577 : createElement(AudioFilePreview, {
578 uri: "https://example.com/sound.mp3",
579 onRetry() {},
580 })
581 : null,
582 createElement(MediaVideoPlayer, {
583 uri: "https://example.com/clip.mp4",
584 name: "clip.mp4",
585 thumbnailKey: "clip",
586 expanded: true,
587 autoPlay: playVideo,
588 }),
589 ),
590 );
591 await act(async () => render());
592 await act(async () =>
593 [...container.querySelectorAll("button")]
594 .find(
595 (button) =>
596 button.getAttribute("aria-label") === "Play audio" || button.textContent === "Play",
597 )!
598 .click(),
599 );
600 expect(audio.played).toHaveBeenCalledOnce();
601 let sessionActive = true;
602 const deactivated = vi.fn();
603 // Match expo-audio iOS: the queued callback checks ownership at execution time.
604 const queueDeactivation = () =>
605 setTimeout(() => {
606 if (audio.sessionOwned) return;
607 sessionActive = false;
608 audio.video.playing = false;
609 deactivated();
610 }, 100);
611 audio.setSessionOwner.mockImplementation((owned) => {
612 audio.sessionOwned = owned;
613 if (!owned) queueDeactivation();
614 });
615 audio.player.pause.mockImplementation((deactivateSession) => {
616 if (deactivateSession !== false) queueDeactivation();
617 });
618 vi.useFakeTimers();
619 let recording: ReturnType<typeof acquireAudioSession> | undefined;
620 try {
621 if (release === "pause") {
622 await act(async () =>
623 [...container.querySelectorAll("button")]
624 .find(
625 (button) =>
626 button.getAttribute("aria-label") === "Pause audio" ||
627 button.textContent === "Pause",
628 )!
629 .click(),
630 );
631 } else if (release === "finish") {
632 // Native completion queues deactivation before JS receives didJustFinish.
633 queueDeactivation();
634 audio.status = { ...audio.status, playing: false, didJustFinish: true };
635 await act(async () => render());
636 }
637 if (release !== "takeover") {
638 expect(vi.getTimerCount()).toBeGreaterThan(0);
639 await vi.advanceTimersByTimeAsync(50);
640 }
641 await act(async () => {
642 if (successor === "recording") {
643 recording = acquireAudioSession("recording");
644 await activateAudioSession(recording);
645 } else if (successor === "native video controls") audio.video.play();
646 else render(true);
647 });
648 if (successor !== "recording") expect(audio.video.playing).toBe(true);
649 expect(audio.player.pause).toHaveBeenCalled();
650 // Cleanup of the old native ref must not interrupt the successor either.
651 await act(async () => render(successor === "video", false));
652 await vi.advanceTimersByTimeAsync(200);
653 expect(sessionActive).toBe(true);
654 expect(deactivated).not.toHaveBeenCalled();
655 expect(audio.setActive).not.toHaveBeenCalledWith(false);
656 if (successor !== "recording") expect(audio.video.playing).toBe(true);
657 else
658 expect(audio.setAudioMode).toHaveBeenLastCalledWith(
659 expect.objectContaining({ allowsRecording: true }),
660 );
661 await act(async () => root!.unmount());
662 root = undefined;
663 if (recording) await releaseAudioSession(recording);
664 recording = undefined;
665 await vi.advanceTimersByTimeAsync(200);
666 expect(sessionActive).toBe(false);
667 } finally {
668 if (recording) await releaseAudioSession(recording);
669 vi.clearAllTimers();
670 vi.useRealTimers();
671 audio.player.pause.mockReset();
672 }
673 },
674 );
675​
676 it.each(["inline", "viewer"])("stops video before starting %s audio", async (source) => {
677 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
678 audio.status = { ...audio.status, isLoaded: true };
679 const container = document.createElement("div");
680 root = createRoot(container);
681 await act(async () =>
682 root!.render(
683 createElement(
684 "div",
685 null,
686 createElement(MediaVideoPlayer, {
687 uri: "https://example.com/clip.mp4",
688 name: "clip.mp4",
689 thumbnailKey: "clip",
690 expanded: true,
691 autoPlay: true,
692 }),
693 source === "inline"
694 ? createElement(ThreadInlineFilePreview, {
695 source: {
696 kind: "document",
697 name: "sound.mp3",
698 uri: "https://example.com/sound.mp3",
699 },
700 onOpen() {},
701 })
702 : createElement(AudioFilePreview, {
703 uri: "https://example.com/sound.mp3",
704 onRetry() {},
705 }),
706 ),
707 ),
708 );
709 expect(audio.video.playing).toBe(true);
710 audio.video.pause.mockClear();
711 await act(async () =>
712 [...container.querySelectorAll("button")]
713 .find(
714 (button) =>
715 button.getAttribute("aria-label") === "Play audio" || button.textContent === "Play",
716 )!
717 .click(),
718 );
719 expect(audio.video.playing).toBe(false);
720 expect(audio.video.pause.mock.invocationCallOrder[0]).toBeLessThan(
721 audio.player.play.mock.invocationCallOrder[0]!,
722 );
723 expect(audio.played).toHaveBeenCalledOnce();
724 expect(audio.setActive).toHaveBeenLastCalledWith(true);
725 });
726​
727 it("plays inline, opens the file viewer, and yields viewer playback to recording", async () => {
728 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
729 audio.status = { ...audio.status, isLoaded: true };
730 const container = document.createElement("div");
731 root = createRoot(container);
732 const openViewer = () =>
733 root!.render(
734 createElement(AudioFilePreview, { uri: "https://example.com/sound.mp3", onRetry() {} }),
735 );
736 await act(async () =>
737 root!.render(
738 createElement(ThreadInlineFilePreview, {
739 source: { kind: "document", name: "sound.mp3", uri: "https://example.com/sound.mp3" },
740 onOpen: openViewer,
741 }),
742 ),
743 );
744 await act(async () =>
745 container.querySelector<HTMLButtonElement>('button[aria-label="Play audio"]')!.click(),
746 );
747 expect(audio.played).toHaveBeenCalledOnce();
748 await act(async () =>
749 container.querySelector<HTMLButtonElement>('button[aria-label="Open sound.mp3"]')!.click(),
750 );
751 expect(audio.enabled).toBe(true);
752 const play = [...container.querySelectorAll("button")].find(
753 (button) => button.textContent === "Play",
754 )!;
755 await act(async () => play.click());
756 expect(audio.played).toHaveBeenCalledTimes(2);
757 audio.player.pause.mockClear();
758 let recording!: ReturnType<typeof acquireAudioSession>;
759 await act(async () => {
760 recording = acquireAudioSession("recording");
761 });
762 expect(audio.player.pause).toHaveBeenCalledOnce();
763 await activateAudioSession(recording);
764 await act(async () => play.click());
765 expect(audio.played).toHaveBeenCalledTimes(2);
766 await releaseAudioSession(recording);
767 await act(async () => play.click());
768 expect(audio.played).toHaveBeenCalledTimes(3);
769 expect(audio.enabled).toBe(true);
770 });
771​
772 it.each(["pause", "unmount", "recording"])(
773 "does not restart the file viewer after %s during replay seeking",
774 async (action) => {
775 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
776 audio.status = { ...audio.status, isLoaded: true, didJustFinish: true, currentTime: 10 };
777 const seek = Promise.withResolvers<void>();
778 audio.player.seekTo.mockReturnValueOnce(seek.promise);
779 const container = document.createElement("div");
780 root = createRoot(container);
781 await act(async () =>
782 root!.render(
783 createElement(AudioFilePreview, { uri: "https://example.com/sound.mp3", onRetry() {} }),
784 ),
785 );
786 const play = [...container.querySelectorAll("button")].find(
787 (button) => button.textContent === "Play",
788 )!;
789 await act(async () => play.click());
790 expect(play.textContent).toBe("Pause");
791 let recording: ReturnType<typeof acquireAudioSession> | undefined;
792 if (action === "pause") await act(async () => play.click());
793 else if (action === "recording") {
794 await act(async () => {
795 recording = acquireAudioSession("recording");
796 });
797 expect(play.textContent).toBe("Play");
798 await activateAudioSession(recording!);
799 } else {
800 await act(async () => root!.unmount());
801 root = undefined;
802 }
803 await act(async () => seek.resolve());
804 expect(audio.played).not.toHaveBeenCalled();
805 if (recording) await releaseAudioSession(recording);
806 },
807 );
808​
809 it.each(["buffering", "seek"])(
810 "keeps file viewer ownership through %s and native resume",
811 async (transition) => {
812 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
813 audio.status = { ...audio.status, isLoaded: true };
814 const container = document.createElement("div");
815 root = createRoot(container);
816 const render = () =>
817 root!.render(
818 createElement(AudioFilePreview, {
819 uri: "https://example.com/sound.mp3",
820 onRetry() {},
821 }),
822 );
823 await act(async () => render());
824 await act(async () =>
825 [...container.querySelectorAll("button")]
826 .find((button) => button.textContent === "Play")!
827 .click(),
828 );
829 audio.status = { ...audio.status, playing: true };
830 await act(async () => render());
831 audio.player.pause.mockClear();
832 if (transition === "seek") {
833 await act(async () =>
834 [...container.querySelectorAll("button")]
835 .find((button) => button.textContent === "+15s")!
836 .click(),
837 );
838 expect(audio.player.seekTo).toHaveBeenCalledWith(10);
839 }
840 audio.status = { ...audio.status, playing: false, isBuffering: transition === "buffering" };
841 await act(async () => render());
842 expect(audio.player.pause).not.toHaveBeenCalled();
843 expect(
844 [...container.querySelectorAll("button")].map((button) => button.textContent),
845 ).toContain("Pause");
846 audio.status = { ...audio.status, playing: true, isBuffering: false };
847 await act(async () => render());
848 expect(audio.played).toHaveBeenCalledOnce();
849 let recording!: ReturnType<typeof acquireAudioSession>;
850 await act(async () => {
851 recording = acquireAudioSession("recording");
852 });
853 expect(audio.player.pause).toHaveBeenCalledOnce();
854 await releaseAudioSession(recording);
855 },
856 );
857​
858 it.each(["pause", "pause while buffering", "pause while loading", "end", "error"])(
859 "releases file viewer ownership on %s",
860 async (action) => {
861 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
862 audio.status = { ...audio.status, isLoaded: true };
863 const container = document.createElement("div");
864 root = createRoot(container);
865 const render = () =>
866 root!.render(
867 createElement(AudioFilePreview, {
868 uri: "https://example.com/sound.mp3",
869 onRetry() {},
870 }),
871 );
872 await act(async () => render());
873 await act(async () =>
874 [...container.querySelectorAll("button")]
875 .find((button) => button.textContent === "Play")!
876 .click(),
877 );
878 audio.status = { ...audio.status, playing: true };
879 await act(async () => render());
880 if (action.startsWith("pause")) {
881 if (action !== "pause") {
882 audio.status = {
883 ...audio.status,
884 playing: false,
885 isBuffering: true,
886 isLoaded: action !== "pause while loading",
887 };
888 await act(async () => render());
889 }
890 await act(async () =>
891 [...container.querySelectorAll("button")]
892 .find((button) => button.textContent === "Pause")!
893 .click(),
894 );
895 } else {
896 audio.status = { ...audio.status, playing: false, didJustFinish: action === "end" };
897 if (action === "error") Object.assign(audio.status, { error: "Playback failed" });
898 await act(async () => render());
899 }
900 expect(audio.player.pause).toHaveBeenLastCalledWith();
901 audio.status = { ...audio.status, playing: false, isBuffering: false, isLoaded: true };
902 await act(async () => render());
903 expect(
904 [...container.querySelectorAll("button")].map((button) => button.textContent),
905 ).toContain("Play");
906 audio.player.pause.mockClear();
907 const recording = acquireAudioSession("recording");
908 expect(audio.player.pause).not.toHaveBeenCalled();
909 expect(audio.setActive).not.toHaveBeenCalledWith(false);
910 await releaseAudioSession(recording);
911 },
912 );
913​
914 it.each(["buffering", "seek"])(
915 "keeps video ownership through %s and resume, including recording takeover during the next wait",
916 async (transition) => {
917 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
918 root = createRoot(document.createElement("div"));
919 await act(async () =>
920 root!.render(
921 createElement(MediaVideoPlayer, {
922 uri: "https://example.com/clip.mp4",
923 name: "clip.mp4",
924 thumbnailKey: "clip",
925 expanded: true,
926 autoPlay: true,
927 }),
928 ),
929 );
930 audio.video.pause.mockClear();
931 // iOS can keep readyToPlay during a seek's evaluatingBufferingRate wait.
932 if (transition === "buffering") {
933 audio.video.status = "loading";
934 for (const listener of audio.videoStatusListeners) listener({ status: "loading" });
935 }
936 audio.video.playing = false;
937 for (const listener of audio.videoListeners) listener({ isPlaying: false });
938 expect(audio.video.pause).not.toHaveBeenCalled();
939 // Buffer readiness can precede the native playing=true event.
940 audio.video.status = "readyToPlay";
941 for (const listener of audio.videoStatusListeners) listener({ status: "readyToPlay" });
942 expect(audio.video.pause).not.toHaveBeenCalled();
943 audio.video.playing = true;
944 for (const listener of audio.videoListeners) listener({ isPlaying: true });
945 expect(audio.video.play).toHaveBeenCalledOnce();
946 audio.video.playing = false;
947 for (const listener of audio.videoListeners) listener({ isPlaying: false });
948 const recording = acquireAudioSession("recording");
949 expect(audio.video.pause).toHaveBeenCalledOnce();
950 audio.video.play();
951 expect(audio.video.playing).toBe(false);
952 await releaseAudioSession(recording);
953 audio.video.play();
954 expect(audio.video.playing).toBe(true);
955 },
956 );
957​
958 it.each(["pause", "pause while buffering", "end", "error"])(
959 "releases video ownership on %s",
960 async (action) => {
961 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
962 root = createRoot(document.createElement("div"));
963 await act(async () =>
964 root!.render(
965 createElement(MediaVideoPlayer, {
966 uri: "https://example.com/clip.mp4",
967 name: "clip.mp4",
968 thumbnailKey: "clip",
969 expanded: true,
970 autoPlay: true,
971 }),
972 ),
973 );
974 await act(async () => {
975 if (action.startsWith("pause")) {
976 if (action === "pause while buffering") {
977 audio.video.status = "loading";
978 audio.video.playing = false;
979 for (const listener of audio.videoListeners) listener({ isPlaying: false });
980 }
981 audio.video.pause();
982 } else if (action === "end") {
983 for (const listener of audio.videoEndListeners) listener();
984 } else {
985 audio.video.status = "error";
986 for (const listener of audio.videoStatusListeners) listener({ status: "error" });
987 }
988 });
989 audio.video.pause.mockClear();
990 const recording = acquireAudioSession("recording");
991 expect(audio.video.pause).not.toHaveBeenCalled();
992 await releaseAudioSession(recording);
993 },
994 );
995​
996 it("keeps thread video playback and native controls working after audio releases", async () => {
997 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
998 const recording = acquireAudioSession("recording");
999 await activateAudioSession(recording);
1000 await releaseAudioSession(recording);
1001 const modes = audio.setAudioMode.mock.calls.length;
1002 const activations = audio.setActive.mock.calls.length;
1003 const container = document.createElement("div");
1004 root = createRoot(container);
1005 await act(async () =>
1006 root!.render(
1007 createElement(MediaVideoPlayer, {
1008 uri: "https://example.com/clip.mp4",
1009 name: "clip.mp4",
1010 thumbnailKey: "clip",
1011 expanded: true,
1012 autoPlay: true,
1013 }),
1014 ),
1015 );
1016 expect(audio.video.playing).toBe(true);
1017 expect(audio.setAudioMode).toHaveBeenCalledTimes(modes);
1018 expect(audio.setActive).toHaveBeenCalledTimes(activations);
1019 audio.video.pause();
1020 audio.video.play();
1021 expect(audio.video.playing).toBe(true);
1022 const takeover = acquireAudioSession("recording");
1023 expect(audio.video.playing).toBe(false);
1024 await activateAudioSession(takeover);
1025 audio.video.play();
1026 expect(audio.video.playing).toBe(false);
1027 await releaseAudioSession(takeover);
1028 audio.video.play();
1029 expect(audio.video.playing).toBe(true);
1030 await act(async () => root!.unmount());
1031 root = undefined;
1032 expect(audio.video.playing).toBe(false);
1033 });
1034​
1035 it.each(["play", "cancel", "unmount", "mode failure", "activation failure"])(
1036 "reactivates audio after voice release and honors %s during activation",
1037 async (intent) => {
1038 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
1039 const recording = acquireAudioSession("recording");
1040 await activateAudioSession(recording);
1041 await releaseAudioSession(recording);
1042 vi.clearAllMocks();
1043 let activate: () => void = () => {};
1044 if (intent === "mode failure") {
1045 audio.setAudioMode.mockRejectedValueOnce(new Error("Playback mode unavailable"));
1046 } else if (intent === "activation failure") {
1047 audio.setActive.mockRejectedValueOnce(new Error("Audio session unavailable"));
1048 } else {
1049 audio.setActive.mockImplementationOnce(
1050 () => new Promise((resolve) => (activate = resolve)),
1051 );
1053 audio.status = { ...audio.status, isLoaded: true };
1054 const container = document.createElement("div");
1055 root = createRoot(container);
1056 act(() =>
1057 root!.render(
1058 createElement(ThreadInlineFilePreview, {
1059 source: { kind: "document", name: "sound.mp3", uri: "https://example.com/sound.mp3" },
1060 onOpen() {},
1061 }),
1062 ),
1063 );
1064 const button = container.querySelector<HTMLButtonElement>('button[aria-label="Play audio"]')!;
1065 await act(async () => button.click());
1066 expect(audio.setAudioMode).toHaveBeenLastCalledWith({
1067 allowsRecording: false,
1068 playsInSilentMode: true,
1069 interruptionMode: "doNotMix",
1070 shouldPlayInBackground: false,
1071 });
1072 expect(audio.player.play).not.toHaveBeenCalled();
1073 if (intent.endsWith("failure")) {
1074 expect(container.textContent).toContain("Unavailable · tap play to retry");
1075 expect(button.getAttribute("aria-label")).toBe("Retry audio");
1076 expect(audio.setActive).not.toHaveBeenCalledWith(false);
1077 return;
1079 expect(audio.setActive).toHaveBeenLastCalledWith(true);
1080 expect(audio.setAudioMode.mock.invocationCallOrder[0]).toBeLessThan(
1081 audio.setActive.mock.invocationCallOrder[0]!,
1082 );
1083 if (intent === "cancel") await act(async () => button.click());
1084 else if (intent === "unmount") {
1085 act(() => root!.unmount());
1086 root = undefined;
1087 expect(audio.player.release).not.toHaveBeenCalled();
1089 await act(async () => activate());
1090 if (intent === "play") {
1091 expect(audio.player.play).toHaveBeenCalledOnce();
1092 expect(button.getAttribute("aria-label")).toBe("Pause audio");
1093 } else {
1094 expect(audio.player.play).not.toHaveBeenCalled();
1095 expect(audio.setActive).toHaveBeenLastCalledWith(true);
1096 expect(audio.player.pause).toHaveBeenCalled();
1097 if (intent === "unmount") {
1098 expect(audio.player.release).toHaveBeenCalledOnce();
1099 expect(audio.player.pause.mock.invocationCallOrder[0]).toBeLessThan(
1100 audio.player.release.mock.invocationCallOrder[0]!,
1101 );
1103 if (intent === "cancel") expect(button.getAttribute("aria-label")).toBe("Play audio");
1105 },
1106 );
1107​
1108 it.each(["playing", "URL pending", "mode pending", "activation pending"])(
1109 "yields to recording while %s and plays again after recording stops",
1110 async (phase) => {
1111 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
1112 const url = Promise.withResolvers<string>();
1113 const native = Promise.withResolvers<void>();
1114 audio.refresh.mockReturnValueOnce(url.promise);
1115 audio.status = { ...audio.status, isLoaded: phase !== "URL pending" };
1116 if (phase === "mode pending") audio.setAudioMode.mockReturnValueOnce(native.promise);
1117 if (phase === "activation pending") audio.setActive.mockReturnValueOnce(native.promise);
1118 const container = document.createElement("div");
1119 root = createRoot(container);
1120 act(() =>
1121 root!.render(
1122 createElement(ThreadInlineFilePreview, {
1123 source: {
1124 kind: "document",
1125 name: "sound.mp3",
1126 environmentId: EnvironmentId.make("environment-1"),
1127 resource: {
1128 _tag: "workspace-file",
1129 threadId: ThreadId.make("thread-1"),
1130 path: "/repo/sound.mp3",
1131 },
1132 },
1133 onOpen() {},
1134 }),
1135 ),
1136 );
1137 const button = container.querySelector<HTMLButtonElement>('button[aria-label="Play audio"]')!;
1138 await act(async () => button.click());
1139 expect(audio.player.play).toHaveBeenCalledTimes(phase === "playing" ? 1 : 0);
1140 expect(audio.refresh).toHaveBeenCalledTimes(phase === "URL pending" ? 1 : 0);
1141 let recording!: ReturnType<typeof acquireAudioSession>;
1142 await act(async () => {
1143 recording = acquireAudioSession("recording");
1144 });
1145 expect(audio.player.pause).toHaveBeenCalled();
1146 expect(button.getAttribute("aria-label")).toBe("Play audio");
1147 native.resolve();
1148 await activateAudioSession(recording);
1149 expect(audio.setAudioMode).toHaveBeenLastCalledWith({
1150 allowsRecording: true,
1151 interruptionMode: "doNotMix",
1152 playsInSilentMode: true,
1153 shouldPlayInBackground: false,
1154 });
1155 const modeCalls = audio.setAudioMode.mock.calls.length;
1156 const activationCalls = audio.setActive.mock.calls.length;
1157 await act(async () => url.resolve("https://example.com/sound.mp3"));
1158 await act(async () => button.click());
1159 expect(audio.setAudioMode).toHaveBeenCalledTimes(modeCalls);
1160 expect(audio.setActive).toHaveBeenCalledTimes(activationCalls);
1161 expect(audio.setActive).not.toHaveBeenCalledWith(false);
1162 expect(audio.player.play).toHaveBeenCalledTimes(phase === "playing" ? 1 : 0);
1163 await releaseAudioSession(recording);
1164 audio.status = { ...audio.status, isLoaded: true };
1165 // Refresh the native status before the next tap.
1166 await act(async () =>
1167 root!.render(
1168 createElement(ThreadInlineFilePreview, {
1169 source: { kind: "document", name: "sound.mp3", uri: "https://example.com/sound.mp3" },
1170 onOpen() {},
1171 }),
1172 ),
1173 );
1174 const replay = container.querySelector<HTMLButtonElement>('button[aria-label="Play audio"]')!;
1175 await act(async () => replay.click());
1176 expect(audio.player.play).toHaveBeenCalledTimes(phase === "playing" ? 2 : 1);
1177 expect(audio.setAudioMode).toHaveBeenLastCalledWith({
1178 allowsRecording: false,
1179 interruptionMode: "doNotMix",
1180 playsInSilentMode: true,
1181 shouldPlayInBackground: false,
1182 });
1183 expect(audio.setActive).toHaveBeenLastCalledWith(true);
1184 },
1185 );
1186​
1187 it.each(["cancel", "unmount", "replay, stale first", "replay, latest first"])(
1188 "honors the latest audio intent after %s during URL resolution",
1189 async (intent) => {
1190 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
1191 const container = document.createElement("div");
1192 root = createRoot(container);
1193 const resolutions: Array<(uri: string) => void> = [];
1194 audio.refresh.mockImplementation(() => new Promise((resolve) => resolutions.push(resolve)));
1195 act(() =>
1196 root!.render(
1197 createElement(ThreadInlineFilePreview, {
1198 source: {
1199 kind: "document",
1200 name: "sound.mp3",
1201 environmentId: EnvironmentId.make("environment-1"),
1202 resource: {
1203 _tag: "workspace-file",
1204 threadId: ThreadId.make("thread-1"),
1205 path: "/repo/sound.mp3",
1206 },
1207 },
1208 onOpen() {},
1209 }),
1210 ),
1211 );
1212 const button = container.querySelector<HTMLButtonElement>('button[aria-label="Play audio"]')!;
1213 await act(async () => button.click());
1214 if (intent !== "unmount") {
1215 await act(async () => button.click());
1216 expect(button.getAttribute("aria-label")).toBe("Play audio");
1218 const replay = intent.startsWith("replay");
1219 if (replay) {
1220 await act(async () => button.click());
1221 expect(button.getAttribute("aria-label")).toBe("Pause audio");
1222 expect(audio.refresh).toHaveBeenCalledTimes(2);
1223 } else if (intent === "unmount") {
1224 act(() => root!.unmount());
1225 root = undefined;
1227 const latestFirst = intent === "replay, latest first";
1228 if (latestFirst) await act(async () => resolutions[1]!("https://example.com/latest.mp3"));
1229 await act(async () => resolutions[0]!("https://example.com/stale.mp3"));
1230 expect(audio.player.play).toHaveBeenCalledTimes(latestFirst ? 1 : 0);
1231 expect(audio.player.replace).toHaveBeenCalledTimes(latestFirst ? 1 : 0);
1232 if (replay) {
1233 expect(button.getAttribute("aria-label")).toBe("Pause audio");
1234 if (!latestFirst) {
1235 expect(container.textContent).toContain("Loading…");
1236 await act(async () => resolutions[1]!("https://example.com/latest.mp3"));
1238 expect(audio.player.replace).toHaveBeenCalledExactlyOnceWith({
1239 uri: "https://example.com/latest.mp3",
1240 });
1241 expect(audio.player.play).toHaveBeenCalledOnce();
1243 },
1244 );
1245​
1246 it.each([false, true])(
1247 "cancels a second tap while buffering with isLoaded=%s",
1248 async (isLoaded) => {
1249 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
1250 const container = document.createElement("div");
1251 root = createRoot(container);
1252 audio.status = { ...audio.status, isLoaded, isBuffering: isLoaded };
1253 let resolve: (uri: string) => void = () => {};
1254 audio.refresh.mockImplementation(
1255 () =>
1256 new Promise((done) => {
1257 resolve = done;
1258 }),
1259 );
1260 const preview = createElement(ThreadInlineFilePreview, {
1261 source: {
1262 kind: "document",
1263 name: "sound.mp3",
1264 environmentId: EnvironmentId.make("environment-1"),
1265 resource: {
1266 _tag: "workspace-file",
1267 threadId: ThreadId.make("thread-1"),
1268 path: "/repo/sound.mp3",
1269 },
1270 },
1271 onOpen() {},
1272 });
1273 act(() => root!.render(preview));
1274 const button = container.querySelector<HTMLButtonElement>('button[aria-label="Play audio"]')!;
1275 await act(async () => button.click());
1276 expect(audio.player.play).toHaveBeenCalledTimes(isLoaded ? 1 : 0);
1277 expect(button.disabled).toBe(false);
1278 expect(button.getAttribute("aria-label")).toBe("Pause audio");
1279 expect(button.getAttribute("aria-busy")).toBe("true");
1280 await act(async () => button.click());
1281 expect(audio.player.pause).toHaveBeenCalled();
1282 expect(button.getAttribute("aria-label")).toBe("Play audio");
1283 expect(button.getAttribute("aria-busy")).toBe("false");
1284 if (!isLoaded) await act(async () => resolve("https://example.com/sound.mp3"));
1285 expect(audio.player.play).toHaveBeenCalledTimes(isLoaded ? 1 : 0);
1286 if (!isLoaded) {
1287 expect(audio.player.replace).not.toHaveBeenCalled();
1288 audio.refresh.mockResolvedValue("https://example.com/sound.mp3");
1290 await act(async () => button.click());
1291 expect(audio.player.play).toHaveBeenCalledTimes(isLoaded ? 2 : 1);
1292 audio.status = { ...audio.status, isLoaded: true, didJustFinish: true, currentTime: 10 };
1293 await act(async () =>
1294 root!.render(createElement(ThreadInlineFilePreview, { ...preview.props })),
1295 );
1296 expect(button.getAttribute("aria-label")).toBe("Play audio");
1297 await act(async () => button.click());
1298 expect(audio.player.seekTo).toHaveBeenCalledWith(0);
1299 expect(audio.player.play).toHaveBeenCalledTimes(isLoaded ? 3 : 2);
1300 },
1301 );
1302});
1303​
1304describe("PDF preview detection", () => {
1305 it.each([
1306 [{ name: "download", mimeType: "APPLICATION/PDF; charset=binary" }, true],
1307 [{ name: "https://example.com/report.pdf?signature=abc#page=2" }, true],
1308 [{ name: "report.pdf", mimeType: "text/plain" }, false],
1309 [{ name: "https://example.com/page?download=report.pdf" }, false],
1310 ])("classifies %j as %s", (file, expected) => {
1311 expect(isPdfFile(file)).toBe(expected);
1312 });
1313});
1314​
1315describe("inline file previews", () => {
1316 it.each([
1317 [{ name: "sound.MP3" }, "audio"],
1318 [{ name: "sound", mimeType: "audio/wav" }, "audio"],
1319 [{ name: "report.PDF", mimeType: "text/plain" }, null],
1320 [{ name: "report.csv" }, null],
1321 [{ name: "report.tsv" }, null],
1322 [{ name: "report", mimeType: "text/csv; charset=utf-8" }, null],
1323 [{ name: "report.csv", mimeType: "image/png" }, null],
1324 [{ name: "image.png" }, null],
1325 [{ name: "clip.mp4" }, null],
1326 [{ name: "file.zip" }, null],
1327 [{ name: "report.docx" }, null],
1328 [{ name: "report.xlsx" }, null],
1329 [{ name: "report.pptx" }, null],
1330 [{ name: "download", mimeType: "application/pdf" }, null],
1331 [{ name: "sound.mp3", mimeType: "application/pdf" }, null],
1332 ])("classifies %j as %s", (file, expected) => {
1333 expect(inlineFilePreviewKind(file)).toBe(expected);
1334 });
1335});
1336​
1337describe("ThreadFeed previews", () => {
1338 it("does not commit an open preview over a different thread", async () => {
1339 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
1340 const environmentId = EnvironmentId.make("environment-1");
1341 const messageId = MessageId.make("message-1");
1342 const timestamp = "2026-10-20T00:00:00.000Z";
1343 const feedFor = (threadId: ThreadId): ThreadFeedEntry[] => [
1345 type: "message",
1346 id: messageId,
1347 createdAt: timestamp,
1348 message: {
1349 id: messageId,
1350 role: "user",
1351 text: "report",
1352 attachments: [
1354 type: "file",
1355 id: "report",
1356 name: "report.pdf",
1357 mimeType: "application/pdf",
1358 sizeBytes: 12,
1359 },
1360 ],
1361 createdAt: timestamp,
1362 updatedAt: timestamp,
1363 runId: null,
1364 streaming: false,
1365 visibility: "local",
1366 sourceThreadId: threadId,
1367 },
1368 },
1369 ];
1370 const container = document.createElement("div");
1371 // Records what each commit shows before passive effects get a chance to run.
1372 const committedPreviews: Array<string | null> = [];
1373 function CommitProbe() {
1374 useLayoutEffect(() => {
1375 committedPreviews.push(
1376 container.querySelector("[data-file-preview]")?.getAttribute("data-file-preview") ?? null,
1377 );
1378 });
1379 return null;
1381 const element = (
1382 threadId: ThreadId,
1383 contentPresentation: Parameters<typeof ThreadFeed>[0]["contentPresentation"] = {
1384 kind: "ready",
1385 },
1386 ) =>
1387 createElement(
1388 Fragment,
1389 null,
1390 createElement(ThreadFeed, {
1391 environmentId,
1392 threadId,
1393 detailRef: null,
1394 threadTitle: "preview",
1395 workspaceRoot: "/repo",
1396 feed: feedFor(threadId),
1397 queuedMessages: [],
1398 dispatchingMessageId: null,
1399 onEditPendingMessage: null,
1400 contentPresentation,
1401 latestRun: null,
1402 activeWorkStartedAt: null,
1403 listRef: { current: null },
1404 freeze: { value: false } as Parameters<typeof ThreadFeed>[0]["freeze"],
1405 contentInsetEndAdjustment: { value: 0 } as Parameters<
1406 typeof ThreadFeed
1407 >[0]["contentInsetEndAdjustment"],
1408 anchorMessageId: null,
1409 keyboardOffset: 0,
1410 }),
1411 createElement(CommitProbe),
1412 );
1413 const root = createRoot(container);
1414 try {
1415 await act(async () => root.render(element(ThreadId.make("thread-1"))));
1416 await act(async () =>
1417 container.querySelector<HTMLButtonElement>('[aria-label="Open report.pdf"]')!.click(),
1418 );
1419 expect(container.querySelector('[data-file-preview="report.pdf"]')).not.toBeNull();
1420 committedPreviews.length = 0;
1421 await act(async () => root.render(element(ThreadId.make("thread-2"))));
1422 expect(committedPreviews[0]).toBeNull();
1423​
1424 // A preview hidden by a presentation change stays closed when the content comes back.
1425 await act(async () =>
1426 container.querySelector<HTMLButtonElement>('[aria-label="Open report.pdf"]')!.click(),
1427 );
1428 expect(container.querySelector('[data-file-preview="report.pdf"]')).not.toBeNull();
1429 await act(async () => root.render(element(ThreadId.make("thread-2"), { kind: "loading" })));
1430 await act(async () => root.render(element(ThreadId.make("thread-2"))));
1431 expect(container.querySelector("[data-file-preview]")).toBeNull();
1432 } finally {
1433 await act(async () => root.unmount());
1435 });
1436});