1// @vitest-environment jsdom
2import { scopeThreadRef } from "@t3tools/client-runtime/environment";
3import { EnvironmentId, ThreadId, type PreviewSessionSnapshot } from "@t3tools/contracts";
4import { act } from "react";
5import { createRoot, type Root } from "react-dom/client";
6import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
7
8const mocks = vi.hoisted(() => ({
9 navigate: vi.fn(async () => undefined),
10 mute: vi.fn(async (_tabId: string, _muted: boolean) => undefined),
11 open: vi.fn(),
12 toast: vi.fn(),
13}));
14vi.mock("@tanstack/react-router", () => ({ useNavigate: () => mocks.navigate }));
15vi.mock("./previewBridge", () => ({ previewBridge: { setAudioMuted: mocks.mute } }));
16vi.mock("~/rightPanelStore", () => ({
17 useRightPanelStore: { getState: () => ({ openBrowser: mocks.open }) },
18}));
19vi.mock("~/components/ui/toast", () => ({ toastManager: { add: mocks.toast } }));
20
21import { BrowserAudioIndicator } from "./BrowserAudioIndicator";
22import { AppAtomRegistryProvider } from "~/rpc/atomRegistry";
23import {
24 applyPreviewDesktopState,
25 applyPreviewServerSnapshot,
26 readThreadPreviewState,
27 resetPreviewStateForTests,
28 type DesktopPreviewOverlay,
29} from "~/previewStateStore";
30import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
31
32const ref = scopeThreadRef(EnvironmentId.make("env-audio"), ThreadId.make("background-chat"));
33const snapshot: PreviewSessionSnapshot = {
34 threadId: ref.threadId,
35 tabId: "tab-audio",
36 navStatus: { _tag: "Success", url: "https://example.test/", title: "Music" },
37 canGoBack: false,
38 canGoForward: false,
39 updatedAt: "2026-10-14T22:00:00.000Z",
40};
41const overlay: DesktopPreviewOverlay = {
42 hasWebContents: true,
43 canGoBack: false,
44 canGoForward: false,
45 loading: false,
46 zoomFactor: 1,
47 pictureInPicture: false,
48 colorScheme: "system",
49 audioMuted: false,
50 audible: true,
51 controller: "none",
52 favicon: null,
53};
54let container: HTMLDivElement;
55let root: Root;
56
57beforeEach(async () => {
58 vi.clearAllMocks();
59 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
60 resetPreviewStateForTests();
61 container = document.createElement("div");
62 document.body.append(container);
63 root = createRoot(container);
64 await act(() =>
65 root.render(
66 <AppAtomRegistryProvider>
67 <BrowserAudioIndicator />
68 </AppAtomRegistryProvider>,
69 ),
70 );
71});
72afterEach(async () => {
73 await act(() => root.unmount());
74 container.remove();
75});
76
77async function startAudio() {
78 await act(() => {
79 applyPreviewServerSnapshot(ref, snapshot);
80 applyPreviewDesktopState(ref, snapshot.tabId, overlay);
81 });
82}
83
84it("shows background-chat audio and removes the warning when muted, stopped, or disconnected", async () => {
85 expect(container.textContent).toBe("");
86 await startAudio();
87 expect(container.querySelector('[role="status"]')?.textContent).toBe("Browser audio playing");
88 expect(container.textContent).toContain("Music");
89 for (const change of [{ audioMuted: true }, { audible: false }, { hasWebContents: false }]) {
90 await act(() => applyPreviewDesktopState(ref, snapshot.tabId, { ...overlay, ...change }));
91 expect(container.textContent).toBe("");
92 await act(() => applyPreviewDesktopState(ref, snapshot.tabId, overlay));
93 }
94});
95
96it("mutes the sounding tab using its scoped runtime identity", async () => {
97 await startAudio();
98 const runtimeTabId = previewRuntimeTabId(
99 ref,
100 readThreadPreviewState(ref).serverEpoch,
101 snapshot.tabId,
102 );
103 await act(async () =>
104 container.querySelector<HTMLButtonElement>('[aria-label="Mute Music"]')!.click(),
105 );
106 expect(mocks.mute).toHaveBeenCalledExactlyOnceWith(runtimeTabId, true);
107 await act(() => applyPreviewDesktopState(ref, snapshot.tabId, { ...overlay, audioMuted: true }));
108 expect(container.textContent).toBe("");
109});
110
111it("opens the source chat and browser tab", async () => {
112 await startAudio();
113 await act(async () =>
114 container.querySelector<HTMLButtonElement>('[aria-label="Open browser: Music"]')!.click(),
115 );
116 expect(mocks.navigate).toHaveBeenCalledOnce();
117 expect(mocks.open).toHaveBeenCalledExactlyOnceWith(ref, snapshot.tabId);
118});
119
120it("keeps the warning and enables retry after muting fails", async () => {
121 await startAudio();
122 mocks.mute.mockRejectedValueOnce(new Error("guest unavailable"));
123 const mute = container.querySelector<HTMLButtonElement>('[aria-label="Mute Music"]')!;
124 await act(async () => mute.click());
125 expect(mute.disabled).toBe(false);
126 expect(container.textContent).toContain("Browser audio playing");
127 expect(mocks.toast).toHaveBeenCalledExactlyOnceWith({
128 type: "error",
129 title: "Couldn’t mute browser audio",
130 });
131});