1// @vitest-environment jsdom
2
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5const mocks = vi.hoisted(() => ({
6 drafts: vi.fn(() => true),
7 timeline: vi.fn(),
8 recordings: vi.fn(() => new Set<string>()),
9}));
10vi.mock("./composerDraftStore", () => ({ prepareComposerDraftsForReload: mocks.drafts }));
11vi.mock("./components/chat/timelineScrollAnchoring", () => ({
12 persistTimelinePositionsForReload: mocks.timeline,
13}));
14vi.mock("./browser/browserRecording", () => ({
15 readActiveBrowserRecordingTabIds: mocks.recordings,
16}));
17
18import { PREPARE_UI_REFRESH_EVENT, prepareUiRefresh, startAutomaticUiRefresh } from "./uiRefresh";
19
20let focused = true;
21let controller: ReturnType<typeof startAutomaticUiRefresh> | undefined;
22
23beforeEach(() => {
24 vi.useFakeTimers();
25 focused = true;
26 vi.spyOn(document, "hasFocus").mockImplementation(() => focused);
27 Object.defineProperty(document, "hidden", { configurable: true, value: false });
28 document.body.replaceChildren();
29 mocks.drafts.mockReset().mockReturnValue(true);
30 mocks.timeline.mockReset();
31 mocks.recordings.mockReset().mockReturnValue(new Set());
32});
33afterEach(() => {
34 controller?.dispose();
35 controller = undefined;
36 vi.restoreAllMocks();
37 vi.useRealTimers();
38});
39
40describe("automatic UI refresh", () => {
41 it("waits one uninterrupted unfocused minute with an update pending", async () => {
42 const apply = vi.fn(async () => {});
43 controller = startAutomaticUiRefresh({ pending: () => true, apply });
44 focused = false;
45 window.dispatchEvent(new Event("blur"));
46 await vi.advanceTimersByTimeAsync(59_999);
47 expect(apply).not.toHaveBeenCalled();
48 focused = true;
49 window.dispatchEvent(new Event("focus"));
50 await vi.advanceTimersByTimeAsync(60_000);
51 expect(apply).not.toHaveBeenCalled();
52 focused = false;
53 window.dispatchEvent(new Event("blur"));
54 await vi.advanceTimersByTimeAsync(60_000);
55 expect(apply).toHaveBeenCalledOnce();
56 });
57
58 it("resets the ten-minute idle threshold on input, and never applies a nonpending update", async () => {
59 let pending = false;
60 const apply = vi.fn(async () => {
61 pending = false;
62 });
63 controller = startAutomaticUiRefresh({ pending: () => pending, apply });
64 await vi.advanceTimersByTimeAsync(9 * 60_000);
65 window.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }));
66 pending = true;
67 controller.check();
68 await vi.advanceTimersByTimeAsync(10 * 60_000 - 1);
69 expect(apply).not.toHaveBeenCalled();
70 await vi.advanceTimersByTimeAsync(1);
71 expect(apply).toHaveBeenCalledOnce();
72 await vi.advanceTimersByTimeAsync(20 * 60_000);
73 expect(apply).toHaveBeenCalledOnce();
74 });
75
76 it("retries an eligible refresh after unsafe local state clears", async () => {
77 const apply = vi.fn(async () => {});
78 const guest = document.createElement("webview");
79 document.body.append(guest);
80 controller = startAutomaticUiRefresh({ pending: () => true, apply });
81 await vi.advanceTimersByTimeAsync(10 * 60_000);
82 expect(apply).not.toHaveBeenCalled();
83 guest.remove();
84 await vi.advanceTimersByTimeAsync(30_000);
85 expect(apply).toHaveBeenCalledOnce();
86 });
87
88 it("does not interrupt IME composition or outlive its owner", async () => {
89 const apply = vi.fn(async () => {});
90 controller = startAutomaticUiRefresh({ pending: () => true, apply });
91 window.dispatchEvent(new CompositionEvent("compositionstart"));
92 await vi.advanceTimersByTimeAsync(10 * 60_000);
93 expect(apply).not.toHaveBeenCalled();
94 controller.dispose();
95 window.dispatchEvent(new CompositionEvent("compositionend"));
96 await vi.advanceTimersByTimeAsync(20 * 60_000);
97 expect(apply).not.toHaveBeenCalled();
98 });
99});
100
101describe("refresh preservation", () => {
102 it.each(["webview", "iframe", "[data-thread-terminal-drawer]", '[role="dialog"]'])(
103 "defers while %s owns live state",
104 (selector) => {
105 const node = document.createElement(selector.startsWith("[") ? "div" : selector);
106 if (selector === "[data-thread-terminal-drawer]") node.dataset.threadTerminalDrawer = "";
107 if (selector === '[role="dialog"]') node.setAttribute("role", "dialog");
108 document.body.append(node);
109 expect(prepareUiRefresh()).toBe(false);
110 expect(mocks.timeline).not.toHaveBeenCalled();
111 },
112 );
113
114 it("defers when drafts cannot be durably saved or an editor vetoes its snapshot", () => {
115 mocks.drafts.mockReturnValue(false);
116 expect(prepareUiRefresh()).toBe(false);
117 mocks.drafts.mockReturnValue(true);
118 const veto = (event: Event) => event.preventDefault();
119 window.addEventListener(PREPARE_UI_REFRESH_EVENT, veto);
120 expect(prepareUiRefresh()).toBe(false);
121 window.removeEventListener(PREPARE_UI_REFRESH_EVENT, veto);
122 expect(prepareUiRefresh()).toBe(true);
123 expect(mocks.timeline).toHaveBeenCalledOnce();
124 });
125
126 it("keeps unsaved form input, recordings and unhandled scroll positions intact", () => {
127 const input = document.createElement("input");
128 input.value = "unsaved edit";
129 document.body.append(input);
130 expect(prepareUiRefresh()).toBe(false);
131 input.remove();
132 mocks.recordings.mockReturnValue(new Set(["recording"]));
133 expect(prepareUiRefresh()).toBe(false);
134 mocks.recordings.mockReturnValue(new Set());
135 const sidebar = document.createElement("div");
136 sidebar.scrollTop = 140;
137 document.body.append(sidebar);
138 expect(prepareUiRefresh()).toBe(false);
139 sidebar.className = "messages-timeline-scroll";
140 expect(prepareUiRefresh()).toBe(true);
141 });
142
143 it("allows hidden controls that reflect saved selections", () => {
144 const hidden = document.createElement("input");
145 hidden.type = "hidden";
146 hidden.value = "selected-model";
147 const toggle = document.createElement("input");
148 toggle.type = "checkbox";
149 toggle.checked = true;
150 document.body.append(hidden, toggle);
151 expect(prepareUiRefresh()).toBe(true);
152 });
153
154 it("ignores Base UI's hidden text controls while retaining visible edits", () => {
155 for (const value of ["all", "full-access"]) {
156 const hidden = document.createElement("input");
157 hidden.type = "text";
158 hidden.value = value;
159 hidden.setAttribute("aria-hidden", "true");
160 hidden.style.cssText = "position:fixed;left:-1px;top:-1px;width:1px;height:1px";
161 document.body.append(hidden);
162 }
163 expect(prepareUiRefresh()).toBe(true);
164 const edited = document.createElement("input");
165 edited.value = "Unsaved settings edit";
166 document.body.append(edited);
167 expect(prepareUiRefresh()).toBe(false);
168 });
169
170 it("ignores retained closed Base UI popovers but defers for an open dialog", () => {
171 for (const attribute of ["data-closed", "inert", "hidden"]) {
172 const popover = document.createElement("div");
173 popover.setAttribute("role", "dialog");
174 popover.setAttribute(attribute, "");
175 const input = document.createElement("input");
176 input.value = "retained menu selection";
177 popover.append(input);
178 const editor = document.createElement("div");
179 editor.className = "cm-editor";
180 popover.append(editor);
181 popover.scrollTop = 30;
182 document.body.append(popover);
183 }
184 expect(prepareUiRefresh()).toBe(true);
185 const open = document.createElement("div");
186 open.setAttribute("role", "dialog");
187 document.body.append(open);
188 expect(prepareUiRefresh()).toBe(false);
189 });
190});