1import {
2 BUILT_IN_BROWSER_PROFILES,
3 DEFAULT_BROWSER_PROFILE_ID,
4 DEFAULT_PREVIEW_APPEARANCE,
5 DEFAULT_PREVIEW_ZOOM_FACTOR,
6 EnvironmentId,
7 FILL_PREVIEW_VIEWPORT,
8 ThreadId,
9} from "@t3tools/contracts";
10import { act, createElement, Profiler } from "react";
11import { renderToStaticMarkup } from "react-dom/server";
12import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
13
14const mocks = vi.hoisted(() => ({
15 navigate: vi.fn(async (_tabId: string, _url: string): Promise<void> => undefined),
16 rememberPreviewUrl: vi.fn(),
17 readPreparedConnection: vi.fn(() => ({ httpBaseUrl: "http://172.25.85.75:3773" })),
18 readEnvironmentScope: vi.fn(() => true),
19 setAnnotationSendEnabled: vi.fn(async (): Promise<void> => undefined),
20 cancelPickElement: vi.fn(async () => undefined),
21 submittedUrl: null as ((url: string) => void) | null,
22 emptyStateUrl: null as ((url: string) => void) | null,
23 togglePictureInPicture: null as (() => void) | null,
24 toggleNativePictureInPicture: null as (() => void) | null,
25 pictureInPicturePressed: false,
26 miniPlayerTabId: null as string | null,
27 openMiniPlayer: vi.fn(),
28 closeMiniPlayer: vi.fn(),
29 closeRightPanel: vi.fn(),
30 openPictureInPicture: vi.fn(async (_tabId: string): Promise<void> => undefined),
31 closePictureInPicture: vi.fn(async (_tabId: string): Promise<void> => undefined),
32 pickElement: vi.fn(),
33 addPreviewAnnotation: vi.fn(),
34 addImage: vi.fn(),
35 toggleAnnotation: null as (() => void) | null,
36 pictureInPicture: false,
37 showEmptyState: false,
38 loading: false,
39 serverEpoch: null as string | null,
40 recordingTabIds: new Set<string>(),
41 recordingRuntimeTabId: null as string | null,
42 recordVisitForThread: vi.fn(),
43}));
44
45const EMPTY_HISTORY: never[] = [];
46
47const STUB_BROWSER_DEFAULTS = {
48 viewport: FILL_PREVIEW_VIEWPORT,
49 zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR,
50 appearance: DEFAULT_PREVIEW_APPEARANCE,
51 autoShowFloatingPreview: true,
52 profiles: BUILT_IN_BROWSER_PROFILES,
53 profileId: DEFAULT_BROWSER_PROFILE_ID,
54};
55
56vi.mock("~/browserHistoryStore", () => ({
57 recordVisitForThread: mocks.recordVisitForThread,
58 setTitleForThreadUrl: vi.fn(),
59 removeUrlForThread: vi.fn(),
60 BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT: 50,
61 useThreadRecentHistory: () => EMPTY_HISTORY,
62}));
63
64vi.mock("~/state/session", async (importOriginal) => ({
65 ...(await importOriginal<typeof import("~/state/session")>()),
66 readPreparedConnection: mocks.readPreparedConnection,
67 readEnvironmentScope: mocks.readEnvironmentScope,
68 useEnvironmentScope: mocks.readEnvironmentScope,
69}));
70
71// File-preview errors share a module with asset hooks. Keep the pure URL resolver
72// without importing those hooks and their environment runtime into chrome tests.
73vi.mock("~/assets/assetUrls", async () => {
74 const { resolveAssetUrl } = await import("@t3tools/client-runtime/state/assets");
75 return { resolveAssetUrl };
76});
77
78// Stubbed at the direct dependency rather than letting the real module pull in
79// `useSettings` -> `state/server`, which would drag the whole settings and
80// connection graph into a test that only cares about the browser chrome.
81vi.mock("~/browser/browserDefaults", () => ({
82 useBrowserDefaults: () => STUB_BROWSER_DEFAULTS,
83 getBrowserDefaults: () => STUB_BROWSER_DEFAULTS,
84 browserDefaultOpenViewport: () => FILL_PREVIEW_VIEWPORT,
85 browserDefaultOpenProfileId: () => DEFAULT_BROWSER_PROFILE_ID,
86 browserDefaultTabState: () => ({
87 zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR,
88 colorScheme: DEFAULT_PREVIEW_APPEARANCE,
89 }),
90 browserResponsiveViewportForToggle: () => ({
91 _tag: "freeform" as const,
92 width: 1024,
93 height: 768,
94 }),
95}));
96
97vi.mock("~/composerDraftStore", () => ({
98 useComposerDraftStore: (
99 select: (store: { addPreviewAnnotation: () => void; addImage: () => void }) => unknown,
100 ) =>
101 select({
102 addPreviewAnnotation: mocks.addPreviewAnnotation,
103 addImage: mocks.addImage,
104 }),
105}));
106
107vi.mock("~/localApi", () => ({
108 ensureLocalApi: vi.fn(),
109}));
110
111vi.mock("~/previewStateStore", () => ({
112 rememberPreviewUrl: mocks.rememberPreviewUrl,
113 updatePreviewServerSnapshot: vi.fn(),
114 useThreadPreviewState: () => ({
115 activeTabId: "tab-1",
116 serverEpoch: mocks.serverEpoch,
117 desktopByTabId: {
118 "tab-1": {
119 hasWebContents: true,
120 canGoBack: false,
121 canGoForward: false,
122 loading: mocks.loading,
123 zoomFactor: 1,
124 pictureInPicture: mocks.pictureInPicture,
125 colorScheme: "system",
126 audioMuted: false,
127 audible: false,
128 controller: "none",
129 },
130 },
131 recentlySeenUrls: [],
132 sessions: mocks.showEmptyState
133 ? {}
134 : {
135 "tab-1": {
136 threadId: "thread-1",
137 tabId: "tab-1",
138 navStatus: {
139 _tag: "Success",
140 url: "http://example.com/",
141 title: "Example",
142 },
143 canGoBack: false,
144 canGoForward: false,
145 updatedAt: "2026-07-13T00:00:00.000Z",
146 },
147 },
148 }),
149}));
150
151vi.mock("~/state/environments", () => ({
152 useEnvironment: () => ({ label: "WSL" }),
153 useEnvironmentHttpBaseUrl: () => "http://172.25.85.75:3773",
154 usePrimaryEnvironmentId: () => null,
155}));
156
157vi.mock("~/state/preview", () => ({
158 previewEnvironment: { open: {}, close: {}, resize: {} },
159}));
160
161vi.mock("~/state/use-atom-command", () => ({
162 useAtomCommand: () => vi.fn(),
163}));
164
165vi.mock("~/browser/browserRecording", () => ({
166 findActiveBrowserRecordingRuntimeTabId: () => mocks.recordingRuntimeTabId,
167 isBrowserRecordingStartCancelledError: vi.fn(() => false),
168 startBrowserRecording: vi.fn(),
169 stopBrowserRecording: vi.fn(),
170 useActiveBrowserRecordingTabIds: () => mocks.recordingTabIds,
171}));
172
173vi.mock("~/browser/browserSurfaceStore", () => ({
174 useBrowserSurfaceStore: (
175 select: (state: { byTabId: Record<string, { rect?: unknown }> }) => unknown,
176 ) => select({ byTabId: {} }),
177}));
178
179vi.mock("~/previewMiniPlayerStore", () => {
180 const usePreviewMiniPlayerStore = Object.assign(
181 (select: (state: unknown) => unknown) =>
182 select({
183 byThreadKey: mocks.miniPlayerTabId
184 ? {
185 "environment-1:thread-1": {
186 source: { kind: "browser", tabId: mocks.miniPlayerTabId },
187 position: null,
188 },
189 }
190 : {},
191 }),
192 {
193 getState: () => ({
194 open: mocks.openMiniPlayer,
195 close: mocks.closeMiniPlayer,
196 }),
197 },
198 );
199 return {
200 browserMiniPlayerSource: (tabId: string) => ({ kind: "browser", tabId }),
201 selectThreadPreviewMiniPlayerTabId: (
202 byThreadKey: Record<string, { source: { tabId: string }; position: null }>,
203 ) => byThreadKey["environment-1:thread-1"]?.source.tabId ?? null,
204 usePreviewMiniPlayerStore,
205 };
206});
207
208vi.mock("~/rightPanelStore", () => ({
209 useRightPanelStore: {
210 getState: () => ({ close: mocks.closeRightPanel }),
211 },
212}));
213
214vi.mock("~/components/ui/toast", () => ({
215 stackedThreadToast: vi.fn(),
216 toastManager: { add: vi.fn() },
217}));
218
219vi.mock("./previewBridge", () => ({
220 previewBridge: {
221 navigate: mocks.navigate,
222 pickElement: mocks.pickElement,
223 setAnnotationSendEnabled: mocks.setAnnotationSendEnabled,
224 cancelPickElement: mocks.cancelPickElement,
225 pictureInPicture: {
226 open: mocks.openPictureInPicture,
227 close: mocks.closePictureInPicture,
228 },
229 },
230}));
231
232vi.mock("./PreviewChromeRow", () => ({
233 PreviewChromeRow: (props: {
234 onSubmit: (url: string) => void;
235 onPickElement?: () => void;
236 onPictureInPicture?: () => void;
237 pictureInPicture?: boolean;
238 trailingActions?: {
239 props: { actions?: { toggleNativePictureInPicture?: () => void } };
240 };
241 }) => {
242 mocks.submittedUrl = props.onSubmit;
243 mocks.toggleAnnotation = props.onPickElement ?? null;
244 mocks.togglePictureInPicture = props.onPictureInPicture ?? null;
245 mocks.toggleNativePictureInPicture =
246 props.trailingActions?.props.actions?.toggleNativePictureInPicture ?? null;
247 mocks.pictureInPicturePressed = props.pictureInPicture ?? false;
248 return null;
249 },
250}));
251
252vi.mock("./PreviewEmptyState", () => ({
253 PreviewEmptyState: (props: { onOpenUrl: (url: string) => void }) => {
254 mocks.emptyStateUrl = props.onOpenUrl;
255 return null;
256 },
257}));
258vi.mock("./PreviewMoreMenu", () => ({
259 PreviewMoreMenu: (props: { actions: { toggleNativePictureInPicture?: () => void } }) => {
260 mocks.toggleNativePictureInPicture = props.actions.toggleNativePictureInPicture ?? null;
261 return null;
262 },
263}));
264vi.mock("./PreviewUnreachable", () => ({ PreviewUnreachable: () => null }));
265vi.mock("./ZoomIndicator", () => ({ ZoomIndicator: () => null }));
266vi.mock("./AgentBrowserCursor", () => ({
267 AgentBrowserCursor: () => createElement("agent-cursor"),
268}));
269vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null }));
270vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() }));
271
272import { PreviewView } from "./PreviewView";
273import { toastManager } from "~/components/ui/toast";
274import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
275
276const TEST_THREAD_REF = {
277 environmentId: EnvironmentId.make("environment-1"),
278 threadId: ThreadId.make("thread-1"),
279} as const;
280const TEST_RUNTIME_TAB_ID = previewRuntimeTabId(TEST_THREAD_REF, null, "tab-1");
281
282// ReactDOM needs a host, but this unit suite intentionally has no DOM dependency.
283class TestNode {
284 parentNode: TestNode | null = null;
285 childNodes: TestNode[] = [];
286 readonly nodeName: string;
287 readonly tagName: string;
288 readonly namespaceURI = "http://www.w3.org/1999/xhtml";
289 readonly style = {};
290
291 constructor(
292 name: string,
293 readonly ownerDocument: TestNode | null = null,
294 readonly nodeType = 1,
295 ) {
296 this.nodeName = name.toUpperCase();
297 this.tagName = this.nodeName;
298 }
299
300 set textContent(_value: string) {
301 this.childNodes = [];
302 }
303
304 appendChild(child: TestNode) {
305 child.parentNode = this;
306 this.childNodes.push(child);
307 return child;
308 }
309
310 removeChild(child: TestNode) {
311 this.childNodes.splice(this.childNodes.indexOf(child), 1);
312 child.parentNode = null;
313 return child;
314 }
315
316 createElement(name: string) {
317 return new TestNode(name, this);
318 }
319
320 addEventListener() {}
321 removeEventListener() {}
322 setAttribute() {}
323}
324
325function installTestDom() {
326 const document = new TestNode("#document", null, 9);
327 const window = {
328 document,
329 HTMLIFrameElement: TestNode,
330 setInterval: globalThis.setInterval,
331 clearInterval: globalThis.clearInterval,
332 setTimeout: globalThis.setTimeout,
333 clearTimeout: globalThis.clearTimeout,
334 addEventListener() {},
335 removeEventListener() {},
336 };
337 vi.stubGlobal("document", document);
338 vi.stubGlobal("window", window);
339 vi.stubGlobal("HTMLIFrameElement", window.HTMLIFrameElement);
340 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
341 return document;
342}
343
344describe("PreviewView navigation", () => {
345 beforeEach(() => {
346 mocks.navigate.mockClear();
347 mocks.rememberPreviewUrl.mockClear();
348 mocks.readPreparedConnection.mockClear();
349 mocks.readEnvironmentScope.mockReset().mockReturnValue(true);
350 mocks.setAnnotationSendEnabled.mockClear();
351 mocks.cancelPickElement.mockClear();
352 mocks.submittedUrl = null;
353 mocks.emptyStateUrl = null;
354 mocks.togglePictureInPicture = null;
355 mocks.toggleNativePictureInPicture = null;
356 mocks.pictureInPicturePressed = false;
357 mocks.miniPlayerTabId = null;
358 mocks.openMiniPlayer.mockClear();
359 mocks.closeMiniPlayer.mockClear();
360 mocks.closeRightPanel.mockClear();
361 mocks.openPictureInPicture.mockClear();
362 mocks.closePictureInPicture.mockClear();
363 mocks.pickElement.mockReset();
364 mocks.addPreviewAnnotation.mockClear();
365 vi.mocked(toastManager.add).mockClear();
366 mocks.addImage.mockClear();
367 mocks.toggleAnnotation = null;
368 mocks.pictureInPicture = false;
369 mocks.showEmptyState = false;
370 mocks.loading = false;
371 mocks.serverEpoch = null;
372 mocks.recordingTabIds = new Set();
373 mocks.recordingRuntimeTabId = null;
374 mocks.recordVisitForThread.mockClear();
375 });
376
377 it("shows the cursor in a replacement browser while the old instance still records", async () => {
378 const document = installTestDom();
379 const { createRoot } = await import("react-dom/client");
380 const container = document.createElement("div");
381 const root = createRoot(container as unknown as Element);
382 const hasCursor = (node: TestNode): boolean =>
383 node.nodeName === "AGENT-CURSOR" || node.childNodes.some(hasCursor);
384 mocks.recordingTabIds.add(TEST_RUNTIME_TAB_ID);
385 mocks.recordingRuntimeTabId = TEST_RUNTIME_TAB_ID;
386 try {
387 await act(() => {
388 root.render(<PreviewView threadRef={TEST_THREAD_REF} tabId="tab-1" visible />);
389 });
390 expect(hasCursor(container)).toBe(false);
391 mocks.serverEpoch = "replacement-server";
392 await act(() => {
393 root.render(<PreviewView threadRef={TEST_THREAD_REF} tabId="tab-1" visible />);
394 });
395 expect(hasCursor(container)).toBe(true);
396 expect(mocks.recordingTabIds.has(TEST_RUNTIME_TAB_ID)).toBe(true);
397 } finally {
398 await act(() => root.unmount());
399 vi.unstubAllGlobals();
400 }
401 });
402
403 it("does not rerender while loading time passes", async () => {
404 vi.useFakeTimers();
405 mocks.loading = true;
406 const document = installTestDom();
407 const { createRoot } = await import("react-dom/client");
408 const root = createRoot(document.createElement("div") as unknown as Element);
409 const onRender = vi.fn();
410
411 try {
412 await act(() => {
413 root.render(
414 <Profiler id="preview" onRender={onRender}>
415 <PreviewView threadRef={TEST_THREAD_REF} tabId="tab-1" visible />
416 </Profiler>,
417 );
418 });
419 const initialRenderCount = onRender.mock.calls.length;
420
421 await act(() => vi.advanceTimersByTimeAsync(1_000));
422
423 expect(onRender).toHaveBeenCalledTimes(initialRenderCount);
424 } finally {
425 await act(() => root.unmount());
426 vi.useRealTimers();
427 vi.unstubAllGlobals();
428 }
429 });
430
431 it.each([
432 [
433 "https://localhost:8000/dashboard?mode=test#top",
434 "https://localhost:8000/dashboard?mode=test#top",
435 ],
436 ["localhost:5173/app", "http://localhost:5173/app"],
437 ])("preserves a direct localhost URL in a WSL environment", async (submitted, expected) => {
438 renderToStaticMarkup(
439 <PreviewView
440 threadRef={{
441 environmentId: EnvironmentId.make("environment-1"),
442 threadId: ThreadId.make("thread-1"),
443 }}
444 tabId="tab-1"
445 visible
446 />,
447 );
448
449 expect(mocks.submittedUrl).not.toBeNull();
450 mocks.submittedUrl?.(submitted);
451
452 await vi.waitFor(() =>
453 expect(mocks.navigate).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID, expected),
454 );
455 expect(mocks.rememberPreviewUrl).toHaveBeenCalledWith(
456 {
457 environmentId: "environment-1",
458 threadId: "thread-1",
459 },
460 expected,
461 );
462 });
463
464 it("records a history visit with the normalized requested url on submit", async () => {
465 renderToStaticMarkup(
466 <PreviewView
467 threadRef={{
468 environmentId: EnvironmentId.make("environment-1"),
469 threadId: ThreadId.make("thread-1"),
470 }}
471 tabId="tab-1"
472 visible
473 />,
474 );
475
476 mocks.submittedUrl?.("localhost:3000/admin");
477 await vi.waitFor(() => {
478 expect(mocks.recordVisitForThread).toHaveBeenCalledWith(
479 expect.objectContaining({ threadId: expect.anything() }),
480 "http://localhost:3000/admin",
481 );
482 });
483 });
484
485 it("maps an empty-state localhost server onto the WSL host", async () => {
486 mocks.showEmptyState = true;
487 renderToStaticMarkup(
488 <PreviewView
489 threadRef={{
490 environmentId: EnvironmentId.make("environment-1"),
491 threadId: ThreadId.make("thread-1"),
492 }}
493 tabId="tab-1"
494 visible
495 />,
496 );
497
498 expect(mocks.emptyStateUrl).not.toBeNull();
499 mocks.emptyStateUrl?.("http://localhost:5173/app?mode=test#top");
500
501 await vi.waitFor(() =>
502 expect(mocks.navigate).toHaveBeenCalledWith(
503 TEST_RUNTIME_TAB_ID,
504 "http://172.25.85.75:5173/app?mode=test#top",
505 ),
506 );
507 expect(mocks.rememberPreviewUrl).toHaveBeenCalledWith(
508 {
509 environmentId: "environment-1",
510 threadId: "thread-1",
511 },
512 "http://172.25.85.75:5173/app?mode=test#top",
513 );
514 await vi.waitFor(() =>
515 expect(mocks.recordVisitForThread).toHaveBeenCalledWith(
516 expect.objectContaining({ threadId: expect.anything() }),
517 "http://localhost:5173/app?mode=test#top",
518 ),
519 );
520 });
521
522 it("opens and closes a thread-scoped floating preview for the active tab", async () => {
523 const props = {
524 threadRef: {
525 environmentId: EnvironmentId.make("environment-1"),
526 threadId: ThreadId.make("thread-1"),
527 },
528 tabId: "tab-1",
529 visible: true,
530 } as const;
531
532 renderToStaticMarkup(<PreviewView {...props} />);
533 expect(mocks.pictureInPicturePressed).toBe(false);
534 mocks.togglePictureInPicture?.();
535 expect(mocks.openMiniPlayer).toHaveBeenCalledWith(props.threadRef, {
536 kind: "browser",
537 tabId: "tab-1",
538 });
539 expect(mocks.closeRightPanel).toHaveBeenCalledWith(props.threadRef);
540
541 mocks.miniPlayerTabId = "tab-1";
542 renderToStaticMarkup(<PreviewView {...props} />);
543 expect(mocks.pictureInPicturePressed).toBe(true);
544 mocks.togglePictureInPicture?.();
545 expect(mocks.closeMiniPlayer).toHaveBeenCalledWith(props.threadRef);
546 });
547
548 it("keeps the native preview window as a secondary action", async () => {
549 const props = {
550 threadRef: {
551 environmentId: EnvironmentId.make("environment-1"),
552 threadId: ThreadId.make("thread-1"),
553 },
554 tabId: "tab-1",
555 visible: true,
556 } as const;
557
558 renderToStaticMarkup(<PreviewView {...props} />);
559 mocks.toggleNativePictureInPicture?.();
560 await vi.waitFor(() =>
561 expect(mocks.openPictureInPicture).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID),
562 );
563
564 mocks.pictureInPicture = true;
565 renderToStaticMarkup(<PreviewView {...props} />);
566 mocks.toggleNativePictureInPicture?.();
567 await vi.waitFor(() =>
568 expect(mocks.closePictureInPicture).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID),
569 );
570 });
571
572 it("forwards Cmd/Ctrl+Enter annotations to the composer send path", async () => {
573 const annotation = {
574 id: "annotation-1",
575 pageUrl: "https://example.com/dashboard",
576 pageTitle: "Dashboard",
577 comment: "Tighten this spacing",
578 elements: [],
579 regions: [],
580 strokes: [],
581 styleChanges: [],
582 screenshot: null,
583 createdAt: "2026-07-27T00:00:00.000Z",
584 };
585 const onSendAnnotation = vi.fn();
586 mocks.pickElement.mockResolvedValue({ annotation, submission: "send" });
587
588 renderToStaticMarkup(
589 <PreviewView
590 threadRef={TEST_THREAD_REF}
591 tabId="tab-1"
592 visible
593 onSendAnnotation={onSendAnnotation}
594 />,
595 );
596 mocks.toggleAnnotation?.();
597
598 await vi.waitFor(() => expect(onSendAnnotation).toHaveBeenCalledWith(annotation, null));
599 expect(mocks.addPreviewAnnotation).toHaveBeenCalledWith(TEST_THREAD_REF, annotation);
600 });
601
602 it("retains the annotation locally when its own environment loses access during capture", async () => {
603 const annotation = {
604 id: "annotation-revoked",
605 pageUrl: "https://example.com/dashboard",
606 pageTitle: "Dashboard",
607 comment: "Tighten this spacing",
608 elements: [],
609 regions: [],
610 strokes: [],
611 styleChanges: [],
612 screenshot: null,
613 createdAt: "2026-09-05T00:00:00.000Z",
614 };
615 const onSendAnnotation = vi.fn();
616 let submitPick: (() => void) | undefined;
617 mocks.pickElement.mockImplementationOnce(
618 () =>
619 new Promise((resolve) => {
620 submitPick = () => resolve({ annotation, submission: "send" });
621 }),
622 );
623 const document = installTestDom();
624 const { createRoot } = await import("react-dom/client");
625 const root = createRoot(document.createElement("div") as unknown as Element);
626 const view = () => (
627 <PreviewView
628 threadRef={TEST_THREAD_REF}
629 tabId="tab-1"
630 visible
631 onSendAnnotation={onSendAnnotation}
632 />
633 );
634 try {
635 await act(async () => root.render(view()));
636 await act(async () => mocks.toggleAnnotation?.());
637 expect(submitPick).toBeDefined();
638 expect(mocks.setAnnotationSendEnabled).toHaveBeenLastCalledWith(TEST_RUNTIME_TAB_ID, true);
639
640 mocks.readEnvironmentScope.mockImplementation(
641 (environmentId?: unknown) => environmentId !== TEST_THREAD_REF.environmentId,
642 );
643 await act(async () => root.render(view()));
644 expect(mocks.setAnnotationSendEnabled).toHaveBeenLastCalledWith(TEST_RUNTIME_TAB_ID, false);
645 await act(async () => submitPick!());
646 expect(mocks.addPreviewAnnotation).toHaveBeenCalledWith(TEST_THREAD_REF, annotation);
647 expect(onSendAnnotation).not.toHaveBeenCalled();
648
649 mocks.readEnvironmentScope.mockReturnValue(true);
650 mocks.pickElement.mockResolvedValue({ annotation, submission: "send" });
651 await act(async () => root.render(view()));
652 await act(async () => mocks.toggleAnnotation?.());
653 expect(mocks.setAnnotationSendEnabled).toHaveBeenLastCalledWith(TEST_RUNTIME_TAB_ID, true);
654 expect(onSendAnnotation).toHaveBeenCalledWith(annotation, null);
655 } finally {
656 await act(async () => root.unmount());
657 vi.unstubAllGlobals();
658 }
659 });
660
661 it("does not reopen a cancelled picker after the permission update finishes", async () => {
662 let finishUpdate: (() => void) | undefined;
663 mocks.setAnnotationSendEnabled.mockImplementationOnce(
664 () =>
665 new Promise<void>((resolve) => {
666 finishUpdate = resolve;
667 }),
668 );
669 const document = installTestDom();
670 const { createRoot } = await import("react-dom/client");
671 const root = createRoot(document.createElement("div") as unknown as Element);
672 try {
673 await act(async () =>
674 root.render(<PreviewView threadRef={TEST_THREAD_REF} tabId="tab-1" visible />),
675 );
676 await act(async () => mocks.toggleAnnotation?.());
677 expect(finishUpdate).toBeDefined();
678 expect(mocks.pickElement).not.toHaveBeenCalled();
679 await act(async () => mocks.toggleAnnotation?.());
680 expect(mocks.cancelPickElement).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID);
681 await act(async () => finishUpdate!());
682 expect(mocks.pickElement).not.toHaveBeenCalled();
683 expect(mocks.addPreviewAnnotation).not.toHaveBeenCalled();
684 } finally {
685 await act(async () => root.unmount());
686 vi.unstubAllGlobals();
687 }
688 });
689
690 it("warns when main dropped the crop before handing over the pick", async () => {
691 const annotation = {
692 id: "annotation-3",
693 pageUrl: "https://example.com/dashboard",
694 pageTitle: "Dashboard",
695 comment: "Tighten this spacing",
696 elements: [],
697 regions: [],
698 strokes: [],
699 styleChanges: [],
700 screenshot: null,
701 createdAt: "2026-07-27T00:00:00.000Z",
702 };
703 const onSendAnnotation = vi.fn();
704 mocks.pickElement.mockResolvedValue({ annotation, submission: "send", screenshotFailed: true });
705
706 renderToStaticMarkup(
707 <PreviewView
708 threadRef={TEST_THREAD_REF}
709 tabId="tab-1"
710 visible
711 onSendAnnotation={onSendAnnotation}
712 />,
713 );
714 mocks.toggleAnnotation?.();
715
716 await vi.waitFor(() => expect(onSendAnnotation).toHaveBeenCalledWith(annotation, null));
717 // A null screenshot alone looks like a comment-only pick; the flag is what
718 // separates "no crop requested" from "crop lost to a timeout".
719 expect(toastManager.add).toHaveBeenCalledTimes(1);
720 });
721
722 it("still sends annotation text when the picked element's crop is malformed", async () => {
723 const annotation = {
724 id: "annotation-2",
725 pageUrl: "https://example.com/dashboard",
726 pageTitle: "Dashboard",
727 comment: "Tighten this spacing",
728 elements: [],
729 regions: [],
730 strokes: [],
731 styleChanges: [],
732 screenshot: {
733 dataUrl: "data:image/png;base64,%%%",
734 width: 10,
735 height: 10,
736 cropRect: { x: 0, y: 0, width: 10, height: 10 },
737 },
738 createdAt: "2026-07-27T00:00:00.000Z",
739 };
740 const onSendAnnotation = vi.fn();
741 mocks.pickElement.mockResolvedValue({ annotation, submission: "send" });
742
743 renderToStaticMarkup(
744 <PreviewView
745 threadRef={TEST_THREAD_REF}
746 tabId="tab-1"
747 visible
748 onSendAnnotation={onSendAnnotation}
749 />,
750 );
751 mocks.toggleAnnotation?.();
752
753 // The forwarded and stored annotation both drop the screenshot, so the
754 // prompt does not claim a crop that was never attached.
755 const sent = { ...annotation, screenshot: null };
756 await vi.waitFor(() => expect(onSendAnnotation).toHaveBeenCalledWith(sent, null));
757 expect(mocks.addPreviewAnnotation).toHaveBeenCalledWith(TEST_THREAD_REF, sent);
758 expect(mocks.addImage).not.toHaveBeenCalled();
759 });
760});