Skip to content
Source
Sign inSign up

SessionControl.test.ts

358 lines14.4 KB

1import { describe, expect, it } from "vite-plus/test";
2​
3import { BrowserControlInterrupted, SessionControl } from "./SessionControl.ts";
4​
5describe("SessionControl", () => {
6 it("replaces waiting hover work without changing the running action or delaying the following click", async () => {
7 const control = new SessionControl("agent");
8 await control.take("viewer");
9 const started = Promise.withResolvers<void>();
10 const finish = Promise.withResolvers<void>();
11 const events: Array<number | string> = [];
12 const running = control.humanLatest("viewer", "hover:0", undefined, async () => {
13 events.push("running");
14 started.resolve();
15 await finish.promise;
16 });
17 await started.promise;
18 const moves = Array.from({ length: 120 }, (_, position) =>
19 control.humanLatest("viewer", "hover:0", undefined, async () => {
20 events.push(position);
21 }),
22 );
23 const click = control.human("viewer", async () => {
24 events.push("click");
25 });
26 finish.resolve();
27 await Promise.all([running, ...moves, click]);
28 expect(events).toEqual(["running", 119, "click"]);
29 });
30​
31 it.each(["human", "system", "modifiers"] as const)(
32 "does not merge waiting hover work across a %s action",
33 async (barrier) => {
34 const control = new SessionControl("agent");
35 await control.take("viewer");
36 const finish = Promise.withResolvers<void>();
37 const events: string[] = [];
38 const running = control.human("viewer", () => finish.promise);
39 const before = control.humanLatest("viewer", "hover:0", undefined, async () => {
40 events.push("before");
41 });
42 const recordBarrier = async () => {
43 events.push("barrier");
44 };
45 const between =
46 barrier === "modifiers"
47 ? control.humanLatest("viewer", "hover:2", undefined, recordBarrier)
48 : barrier === "system"
49 ? control.system(recordBarrier)
50 : control.human("viewer", recordBarrier);
51 const after = control.humanLatest("viewer", "hover:0", undefined, async () => {
52 events.push("after");
53 });
54 finish.resolve();
55 await Promise.all([running, before, between, after]);
56 expect(events).toEqual(["before", "barrier", "after"]);
57 },
58 );
59​
60 it("does not let another viewer replace or split the owner's waiting hover", async () => {
61 const control = new SessionControl("agent");
62 await control.take("viewer");
63 const finish = Promise.withResolvers<void>();
64 const events: string[] = [];
65 const running = control.human("viewer", () => finish.promise);
66 const stale = control.humanLatest("viewer", "hover", undefined, async () => {
67 events.push("stale");
68 });
69 await expect(
70 control.humanLatest("other", "hover", undefined, async () => {
71 events.push("other");
72 }),
73 ).rejects.toThrow(BrowserControlInterrupted);
74 const latest = control.humanLatest("viewer", "hover", undefined, async () => {
75 events.push("latest");
76 });
77 finish.resolve();
78 await Promise.all([running, stale, latest]);
79 expect(events).toEqual(["latest"]);
80 });
81​
82 it.each(["release", "disconnect"] as const)(
83 "does not revive waiting hover work after %s and retaking control",
84 async (handoff) => {
85 const control = new SessionControl("agent");
86 await control.take("viewer");
87 const started = Promise.withResolvers<void>();
88 const finish = Promise.withResolvers<void>();
89 const events: string[] = [];
90 const running = control.human("viewer", () => {
91 started.resolve();
92 return finish.promise;
93 });
94 await started.promise;
95 const stale = expect(
96 control.humanLatest("viewer", "hover", undefined, async () => {
97 events.push("stale");
98 }),
99 ).rejects.toThrow(BrowserControlInterrupted);
100 const released = control[handoff]("viewer");
101 const retaken = control.take("viewer");
102 const fresh = control.humanLatest("viewer", "hover", undefined, async () => {
103 events.push("fresh");
104 });
105 const latest = control.humanLatest("viewer", "hover", undefined, async () => {
106 events.push("latest");
107 });
108 finish.resolve();
109 await Promise.all([running, stale, released, retaken, fresh, latest]);
110 expect(events).toEqual(["latest"]);
111 },
112 );
113​
114 it("rejects every caller of a merged hover when the tab closes", async () => {
115 const control = new SessionControl("agent");
116 await control.take("viewer");
117 const started = Promise.withResolvers<void>();
118 const finish = Promise.withResolvers<void>();
119 const events: string[] = [];
120 const running = control.human("viewer", () => {
121 started.resolve();
122 return finish.promise;
123 });
124 await started.promise;
125 const waiting = [1, 2].map((position) =>
126 expect(
127 control.humanLatest("viewer", "hover", undefined, async () => {
128 events.push(`hover ${position}`);
129 }),
130 ).rejects.toThrow("closed"),
131 );
132 const closed = control.close();
133 await expect(
134 control.humanLatest("viewer", "hover", undefined, async () => {
135 events.push("after close");
136 }),
137 ).rejects.toThrow("closed");
138 finish.resolve();
139 await Promise.all([running, ...waiting, closed]);
140 expect(events).toEqual([]);
141 });
142​
143 it("shares a merged dispatch failure and accepts fresh hover work afterwards", async () => {
144 const control = new SessionControl("agent");
145 await control.take("viewer");
146 const finish = Promise.withResolvers<void>();
147 const events: string[] = [];
148 const running = control.human("viewer", () => finish.promise);
149 const first = expect(
150 control.humanLatest("viewer", "hover", undefined, async () => {
151 events.push("superseded");
152 }),
153 ).rejects.toThrow("dispatch failed");
154 const last = expect(
155 control.humanLatest("viewer", "hover", undefined, async () => {
156 throw new Error("dispatch failed");
157 }),
158 ).rejects.toThrow("dispatch failed");
159 finish.resolve();
160 await Promise.all([running, first, last]);
161 await control.humanLatest("viewer", "hover", undefined, async () => {
162 events.push("recovered");
163 });
164 expect(events).toEqual(["recovered"]);
165 });
166​
167 it("waits for tracked navigation before dispatching the latest human hover", async () => {
168 const control = new SessionControl("agent");
169 await control.take("viewer");
170 const committed = Promise.withResolvers<void>();
171 const events: string[] = [];
172 const navigation = control.human("viewer", async () => {
173 control.track(
174 committed.promise.then(() => {
175 events.push("navigation committed");
176 }),
177 );
178 return "started";
179 });
180 const first = control.humanLatest("viewer", "hover", undefined, async () => {
181 events.push("superseded");
182 });
183 const latest = control.humanLatest("viewer", "hover", undefined, async () => {
184 events.push("latest");
185 });
186 await expect(navigation).resolves.toBe("started");
187 expect(events).toEqual([]);
188 committed.resolve();
189 await Promise.all([first, latest]);
190 expect(events).toEqual(["navigation committed", "latest"]);
191 });
192​
193 it("returns an action result before tracked navigation but drains it before already queued actions", async () => {
194 const control = new SessionControl("agent");
195 const committed = Promise.withResolvers<void>();
196 const events: string[] = [];
197 const navigation = control.agent("agent", async () => {
198 control.track(committed.promise.then(() => events.push("navigation committed")));
199 return "started";
200 });
201 const next = control.agent("agent", async () => events.push("next action"));
202 await expect(navigation).resolves.toBe("started");
203 expect(events).toEqual([]);
204 committed.resolve();
205 await next;
206 expect(events).toEqual(["navigation committed", "next action"]);
207 });
208​
209 it("handles tracked navigation failures without poisoning the next action", async () => {
210 const control = new SessionControl("agent");
211 const committed = Promise.withResolvers<void>();
212 await control.agent("agent", async () => control.track(committed.promise));
213 committed.reject(new Error("navigation failed"));
214 await expect(control.agent("agent", async () => "resumed")).resolves.toBe("resumed");
215 });
216​
217 it("only lets the assigned agent act and restores that agent after human control", async () => {
218 const control = new SessionControl("agent-a");
219 await expect(control.agent("agent-b", async () => "wrong agent")).rejects.toThrow(
220 BrowserControlInterrupted,
221 );
222 await expect(control.agent("agent-a", async () => "first")).resolves.toBe("first");
223 await control.take("viewer-a");
224 await expect(control.agent("agent-a", async () => "racing")).rejects.toThrow(
225 BrowserControlInterrupted,
226 );
227 await expect(control.human("viewer-a", async () => "typed")).resolves.toBe("typed");
228 await control.release("viewer-a");
229 await expect(control.agent("agent-a", async () => "resumed")).resolves.toBe("resumed");
230 await expect(control.agent("agent-b", async () => "wrong agent")).rejects.toThrow(
231 BrowserControlInterrupted,
232 );
233 });
234​
235 it("drains running work before takeover and cancels previously queued agent actions", async () => {
236 const control = new SessionControl("agent");
237 const started = Promise.withResolvers<void>();
238 const finish = Promise.withResolvers<void>();
239 const events: string[] = [];
240 const running = control.agent("agent", async () => {
241 events.push("agent started");
242 started.resolve();
243 await finish.promise;
244 events.push("agent finished");
245 });
246 await started.promise;
247 const queued = expect(
248 control.agent("agent", async () => events.push("stale agent action")),
249 ).rejects.toThrow(BrowserControlInterrupted);
250 const takeover = control.take("viewer");
251 const typing = control.human("viewer", async () => events.push("human typed"));
252 await expect(control.agent("agent", async () => "late action")).rejects.toThrow(
253 BrowserControlInterrupted,
254 );
255 expect(events).toEqual(["agent started"]);
256 finish.resolve();
257 await Promise.all([running, queued, takeover, typing]);
258 expect(events).toEqual(["agent started", "agent finished", "human typed"]);
259 });
260​
261 it("does not let a second viewer steal, type, release, or disconnect the owner", async () => {
262 const control = new SessionControl("agent");
263 const takeover = control.take("viewer-a");
264 await expect(control.take("viewer-b")).rejects.toThrow("Another viewer");
265 await takeover;
266 await expect(control.human("viewer-b", async () => "wrong viewer")).rejects.toThrow(
267 BrowserControlInterrupted,
268 );
269 await expect(control.release("viewer-b")).rejects.toThrow("Only the controlling viewer");
270 await control.disconnect("viewer-b");
271 expect(control.controller).toBe("viewer-a");
272 await expect(control.human("viewer-a", async () => "still controlled")).resolves.toBe(
273 "still controlled",
274 );
275 });
276​
277 it("invalidates queued human actions and refs when a viewer disconnects", async () => {
278 let invalidations = 0;
279 const control = new SessionControl("agent", () => invalidations++);
280 await control.take("viewer");
281 const started = Promise.withResolvers<void>();
282 const finish = Promise.withResolvers<void>();
283 const events: string[] = [];
284 const running = control.human("viewer", async () => {
285 started.resolve();
286 await finish.promise;
287 events.push("human finished");
288 });
289 await started.promise;
290 const queued = expect(
291 control.human("viewer", async () => events.push("stale human action")),
292 ).rejects.toThrow(BrowserControlInterrupted);
293 const disconnected = control.disconnect("viewer");
294 const resumed = control.agent("agent", async () => events.push("agent resumed"));
295 expect(control.generation).toBe(2);
296 expect(invalidations).toBe(2);
297 finish.resolve();
298 await Promise.all([running, queued, disconnected, resumed]);
299 expect(events).toEqual(["human finished", "agent resumed"]);
300 });
301​
302 it("does not revive stale actions when takeover is released before running work finishes", async () => {
303 const control = new SessionControl("agent");
304 const started = Promise.withResolvers<void>();
305 const finish = Promise.withResolvers<void>();
306 const running = control.agent("agent", async () => {
307 started.resolve();
308 await finish.promise;
309 });
310 await started.promise;
311 const staleAgent = expect(control.agent("agent", async () => "stale")).rejects.toThrow(
312 BrowserControlInterrupted,
313 );
314 const takeover = expect(control.take("viewer")).rejects.toThrow(BrowserControlInterrupted);
315 const staleHuman = expect(control.human("viewer", async () => "stale")).rejects.toThrow(
316 BrowserControlInterrupted,
317 );
318 const release = control.release("viewer");
319 const resumed = control.agent("agent", async () => "fresh");
320 finish.resolve();
321 await Promise.all([running, staleAgent, takeover, staleHuman, release]);
322 await expect(resumed).resolves.toBe("fresh");
323 });
324​
325 it("closes after running work finishes and never runs queued or new actions", async () => {
326 const control = new SessionControl("agent");
327 const started = Promise.withResolvers<void>();
328 const finish = Promise.withResolvers<void>();
329 const running = control.agent("agent", async () => {
330 started.resolve();
331 await finish.promise;
332 return "finished";
333 });
334 await started.promise;
335 const queued = expect(control.agent("agent", async () => "stale")).rejects.toThrow("closed");
336 const closed = control.close();
337 await expect(control.take("viewer")).rejects.toThrow("closed");
338 await expect(control.agent("agent", async () => "new")).rejects.toThrow("closed");
339 finish.resolve();
340 await expect(running).resolves.toBe("finished");
341 await Promise.all([queued, closed]);
342 await control.disconnect("viewer");
343 await control.close();
344 expect(control.generation).toBe(1);
345 });
346​
347 it("lets any agent act on a tab no agent opened, but only while no human controls it", async () => {
348 const control = new SessionControl(null);
349 await expect(control.agent("agent-a", async () => "acted")).resolves.toBe("acted");
350 await control.take("viewer-a");
351 await expect(control.agent("agent-a", async () => "racing")).rejects.toMatchObject({
352 reason: "humanControl",
353 });
354 await expect(control.observe(async () => "read")).resolves.toBe("read");
355 await control.release("viewer-a");
356 await expect(control.agent("agent-b", async () => "acted")).resolves.toBe("acted");
357 });
358});