1import {
2 EnvironmentId,
3 ThreadId,
4 type DeviceServiceState,
5 type ScopedThreadRef,
6} from "@t3tools/contracts";
7import type { ReactNode } from "react";
8import { describe, expect, it, vi } from "vite-plus/test";
9
10import type { RightPanelSurface } from "~/rightPanelStore";
11
12import { act, render } from "../../../test/render";
13
14import { DevicePanel } from "./DevicePanel";
15import "~/index.css";
16
17type CommandResult =
18 | { _tag: "Success"; value: { hostId: string; deviceId: string } }
19 | { _tag: "Failure"; cause: unknown };
20const mocks = vi.hoisted(() => ({
21 open: vi.fn<(request: unknown) => Promise<CommandResult>>(),
22 close: vi.fn<(request: unknown) => Promise<CommandResult>>(),
23 list: vi.fn<(request: unknown) => Promise<CommandResult>>(),
24 openDevice: vi.fn(),
25 closeSurface: vi.fn(),
26}));
27const phone = {
28 hostId: "local",
29 id: "phone",
30 name: "Phone",
31 platform: "ios",
32 version: "iOS 19",
33 booted: false,
34 physical: false,
35} as const;
36const deviceState: DeviceServiceState = {
37 hosts: [
38 {
39 id: "local",
40 kind: "local",
41 label: "This Mac",
42 platforms: [{ platform: "ios", available: true }],
43 hubInstalled: true,
44 agentDeviceInstalled: true,
45 },
46 ],
47 hostStatus: "ready",
48 hostStatuses: { local: { status: "ready" } },
49 devices: [phone],
50 sessions: [
51 {
52 threadId: ThreadId.make("thread-1"),
53 hostId: "local",
54 deviceId: "phone",
55 platform: "ios",
56 openedAt: "2026-01-01T00:00:00.000Z",
57 },
58 ],
59 onboardingCompleted: true,
60 agentAccessEnabled: true,
61 hubBasePath: "/api/device-hub",
62 revision: 1,
63};
64let currentState = deviceState;
65// One probe per environment, however many views mount it; each probe sends one list.
66vi.mock("~/state/device", async () => {
67 const Effect = await import("effect/Effect");
68 const { AsyncResult, Atom } = await import("effect/reactivity");
69 return {
70 deviceEnvironment: { list: "list", open: "open", close: "close" },
71 deviceHostProbeAtom: Atom.family((environmentId: string) =>
72 Atom.make(() => Effect.promise(() => mocks.list({ environmentId, input: {} }))),
73 ),
74 NO_DEVICE_PROBE_ATOM: Atom.make(AsyncResult.initial<void, never>()),
75 useDeviceState: () => ({ state: currentState, loaded: true }),
76 };
77});
78vi.mock("~/state/use-atom-command", () => ({
79 useAtomCommand: (command: string) =>
80 command === "open"
81 ? mocks.open
82 : command === "close"
83 ? mocks.close
84 : command === "list"
85 ? mocks.list
86 : () => Promise.resolve({ _tag: "Success", value: {} }),
87}));
88vi.mock("~/state/query", () => ({ formatEnvironmentQueryError: () => "Device failed" }));
89vi.mock("~/rightPanelStore", () => ({
90 useRightPanelStore: {
91 getState: () => ({
92 byThreadKey: {},
93 openDevice: mocks.openDevice,
94 closeSurface: mocks.closeSurface,
95 }),
96 },
97 selectThreadRightPanelState: () => ({ isOpen: false, activeSurfaceId: null, surfaces: [] }),
98}));
99vi.mock("~/components/device/DeviceHostUpdates", () => ({ DeviceHostUpdates: () => null }));
100vi.mock("~/components/device/DeviceWorkspace", () => ({
101 DeviceWorkspace: ({ onPowerOff }: { onPowerOff: () => void }) => (
102 <button onClick={onPowerOff}>Power off</button>
103 ),
104}));
105vi.mock("~/components/preview/PreviewPanelShell", () => ({
106 PreviewPanelShell: ({ children }: { children: ReactNode }) => (
107 <div className="flex h-full min-h-0 flex-col">{children}</div>
108 ),
109}));
110
111const threadA = {
112 environmentId: EnvironmentId.make("environment-a"),
113 threadId: ThreadId.make("thread-1"),
114};
115// The panel stays mounted across both kinds of switch.
116const switches = [
117 ["another thread", { environmentId: threadA.environmentId, threadId: ThreadId.make("thread-2") }],
118 [
119 "the same thread id in another environment",
120 { ...threadA, environmentId: EnvironmentId.make("environment-b") },
121 ],
122] as const;
123const threadB = switches[0][1];
124const picker: Extract<RightPanelSurface, { kind: "device" }> = { id: "device", kind: "device" };
125const phoneTarget = { hostId: "local", deviceId: "phone", platform: "ios", name: "Phone" };
126const opened = { _tag: "Success", value: { hostId: "local", deviceId: "phone" } } as const;
127const failed = { _tag: "Failure", cause: new Error("boom") } as const;
128
129const deferred = () => {
130 let settle!: (result: CommandResult) => void;
131 const promise = new Promise<CommandResult>((resolve) => (settle = resolve));
132 return { promise, settle };
133};
134
135let container: HTMLDivElement;
136// Fixed key and a changing threadRef, as RightPanelSurfaceContent mounts it.
137const devicePanel = (threadRef: ScopedThreadRef, surface = picker) => (
138 <DevicePanel
139 key="device-panel"
140 mode="embedded"
141 threadRef={threadRef}
142 surface={surface}
143 visible
144 onDismissSetup={() => undefined}
145 />
146);
147const startButton = () => container.querySelector<HTMLButtonElement>('[aria-label="Start Phone"]');
148const click = (button: HTMLButtonElement | null) =>
149 act(async () => {
150 button!.click();
151 });
152const errors = () => container.querySelectorAll('[role="alert"]');
153
154function setup() {
155 vi.resetAllMocks();
156 mocks.list.mockResolvedValue({ _tag: "Success", value: { hostId: "local", deviceId: "" } });
157 currentState = deviceState;
158 container = document.createElement("div");
159 container.style.width = "480px";
160 container.style.height = "800px";
161}
162
163describe("DevicePanel thread switches", () => {
164 it("keeps the latest pick pending across switches away and back", async () => {
165 setup();
166 const first = deferred();
167 const second = deferred();
168 const latest = deferred();
169 mocks.open
170 .mockReturnValueOnce(first.promise)
171 .mockReturnValueOnce(second.promise)
172 .mockReturnValueOnce(latest.promise);
173 await using screen = await render(devicePanel(threadA), { container });
174 await click(startButton());
175 await screen.rerender(devicePanel(threadB));
176 await click(startButton());
177 await screen.rerender(devicePanel(threadA));
178 await click(startButton());
179
180 await act(async () => first.settle(failed));
181
182 expect(startButton()).toBeNull();
183 expect(errors()).toHaveLength(0);
184 expect(mocks.openDevice).not.toHaveBeenCalled();
185
186 await act(async () => second.settle(opened));
187
188 expect(startButton()).toBeNull();
189 expect(mocks.openDevice.mock.calls).toEqual([[threadB, phoneTarget]]);
190
191 await act(async () => latest.settle(opened));
192
193 expect(startButton()?.disabled).toBe(false);
194 expect(errors()).toHaveLength(0);
195 expect(mocks.openDevice.mock.calls).toEqual([
196 [threadB, phoneTarget],
197 [threadA, phoneTarget],
198 ]);
199 });
200});
201
202describe("DevicePanel host probe", () => {
203 it("probes the hosts once while setup shows inside the panel", async () => {
204 setup();
205 currentState = { ...deviceState, onboardingCompleted: false };
206 // An environment no other test probed, so its probe starts fresh.
207 const threadRef = { ...threadA, environmentId: EnvironmentId.make("environment-setup") };
208 await using _screen = await render(devicePanel(threadRef), { container });
209
210 await vi.waitFor(() => expect(mocks.list).toHaveBeenCalled());
211 expect(mocks.list.mock.calls).toEqual([
212 [{ environmentId: threadRef.environmentId, input: {} }],
213 ]);
214 });
215});
216
217describe("DevicePanel discovery warning", () => {
218 it("a dismissal hides only the cause it was made for", async () => {
219 setup();
220 const withCause = (detail: string): DeviceServiceState => ({
221 ...deviceState,
222 hostStatuses: { local: { status: "ready", detail, incompletePlatforms: ["ios"] } },
223 });
224 const warning = "iOS list on This Mac may be incomplete.";
225 currentState = withCause("first cause");
226 await using screen = await render(devicePanel(threadA), { container });
227
228 await screen.getByRole("button", { name: "Dismiss device warning for This Mac" }).click();
229 await screen.rerender(devicePanel(threadA));
230 await expect.element(screen.getByText(warning)).not.toBeInTheDocument();
231
232 currentState = withCause("second cause");
233 await screen.rerender(devicePanel(threadA));
234 await expect.element(screen.getByText(warning)).toBeVisible();
235 });
236});