1import { describe, expect, it } from "vite-plus/test";
2
3import { BrowserControlInterrupted, SessionControl } from "./SessionControl.ts";
4
5describe("SessionControl", () => {
6 it("returns an action result before tracked navigation but drains it before already queued actions", async () => {
7 const control = new SessionControl("agent");
8 const committed = Promise.withResolvers<void>();
9 const events: string[] = [];
10 const navigation = control.agent("agent", async () => {
11 control.track(committed.promise.then(() => events.push("navigation committed")));
12 return "started";
13 });
14 const next = control.agent("agent", async () => events.push("next action"));
15 await expect(navigation).resolves.toBe("started");
16 expect(events).toEqual([]);
17 committed.resolve();
18 await next;
19 expect(events).toEqual(["navigation committed", "next action"]);
20 });
21
22 it("handles tracked navigation failures without poisoning the next action", async () => {
23 const control = new SessionControl("agent");
24 const committed = Promise.withResolvers<void>();
25 await control.agent("agent", async () => control.track(committed.promise));
26 committed.reject(new Error("navigation failed"));
27 await expect(control.agent("agent", async () => "resumed")).resolves.toBe("resumed");
28 });
29
30 it("only lets the assigned agent act and restores that agent after human control", async () => {
31 const control = new SessionControl("agent-a");
32 await expect(control.agent("agent-b", async () => "wrong agent")).rejects.toThrow(
33 BrowserControlInterrupted,
34 );
35 await expect(control.agent("agent-a", async () => "first")).resolves.toBe("first");
36 await control.take("viewer-a");
37 await expect(control.agent("agent-a", async () => "racing")).rejects.toThrow(
38 BrowserControlInterrupted,
39 );
40 await expect(control.human("viewer-a", async () => "typed")).resolves.toBe("typed");
41 await control.release("viewer-a");
42 await expect(control.agent("agent-a", async () => "resumed")).resolves.toBe("resumed");
43 await expect(control.agent("agent-b", async () => "wrong agent")).rejects.toThrow(
44 BrowserControlInterrupted,
45 );
46 });
47
48 it("drains running work before takeover and cancels previously queued agent actions", async () => {
49 const control = new SessionControl("agent");
50 const started = Promise.withResolvers<void>();
51 const finish = Promise.withResolvers<void>();
52 const events: string[] = [];
53 const running = control.agent("agent", async () => {
54 events.push("agent started");
55 started.resolve();
56 await finish.promise;
57 events.push("agent finished");
58 });
59 await started.promise;
60 const queued = expect(
61 control.agent("agent", async () => events.push("stale agent action")),
62 ).rejects.toThrow(BrowserControlInterrupted);
63 const takeover = control.take("viewer");
64 const typing = control.human("viewer", async () => events.push("human typed"));
65 await expect(control.agent("agent", async () => "late action")).rejects.toThrow(
66 BrowserControlInterrupted,
67 );
68 expect(events).toEqual(["agent started"]);
69 finish.resolve();
70 await Promise.all([running, queued, takeover, typing]);
71 expect(events).toEqual(["agent started", "agent finished", "human typed"]);
72 });
73
74 it("does not let a second viewer steal, type, release, or disconnect the owner", async () => {
75 const control = new SessionControl("agent");
76 const takeover = control.take("viewer-a");
77 await expect(control.take("viewer-b")).rejects.toThrow("Another viewer");
78 await takeover;
79 await expect(control.human("viewer-b", async () => "wrong viewer")).rejects.toThrow(
80 BrowserControlInterrupted,
81 );
82 await expect(control.release("viewer-b")).rejects.toThrow("Only the controlling viewer");
83 await control.disconnect("viewer-b");
84 expect(control.controller).toBe("viewer-a");
85 await expect(control.human("viewer-a", async () => "still controlled")).resolves.toBe(
86 "still controlled",
87 );
88 });
89
90 it("invalidates queued human actions and refs when a viewer disconnects", async () => {
91 let invalidations = 0;
92 const control = new SessionControl("agent", () => invalidations++);
93 await control.take("viewer");
94 const started = Promise.withResolvers<void>();
95 const finish = Promise.withResolvers<void>();
96 const events: string[] = [];
97 const running = control.human("viewer", async () => {
98 started.resolve();
99 await finish.promise;
100 events.push("human finished");
101 });
102 await started.promise;
103 const queued = expect(
104 control.human("viewer", async () => events.push("stale human action")),
105 ).rejects.toThrow(BrowserControlInterrupted);
106 const disconnected = control.disconnect("viewer");
107 const resumed = control.agent("agent", async () => events.push("agent resumed"));
108 expect(control.generation).toBe(2);
109 expect(invalidations).toBe(2);
110 finish.resolve();
111 await Promise.all([running, queued, disconnected, resumed]);
112 expect(events).toEqual(["human finished", "agent resumed"]);
113 });
114
115 it("does not revive stale actions when takeover is released before running work finishes", async () => {
116 const control = new SessionControl("agent");
117 const started = Promise.withResolvers<void>();
118 const finish = Promise.withResolvers<void>();
119 const running = control.agent("agent", async () => {
120 started.resolve();
121 await finish.promise;
122 });
123 await started.promise;
124 const staleAgent = expect(control.agent("agent", async () => "stale")).rejects.toThrow(
125 BrowserControlInterrupted,
126 );
127 const takeover = expect(control.take("viewer")).rejects.toThrow(BrowserControlInterrupted);
128 const staleHuman = expect(control.human("viewer", async () => "stale")).rejects.toThrow(
129 BrowserControlInterrupted,
130 );
131 const release = control.release("viewer");
132 const resumed = control.agent("agent", async () => "fresh");
133 finish.resolve();
134 await Promise.all([running, staleAgent, takeover, staleHuman, release]);
135 await expect(resumed).resolves.toBe("fresh");
136 });
137
138 it("closes after running work finishes and never runs queued or new actions", async () => {
139 const control = new SessionControl("agent");
140 const started = Promise.withResolvers<void>();
141 const finish = Promise.withResolvers<void>();
142 const running = control.agent("agent", async () => {
143 started.resolve();
144 await finish.promise;
145 return "finished";
146 });
147 await started.promise;
148 const queued = expect(control.agent("agent", async () => "stale")).rejects.toThrow("closed");
149 const closed = control.close();
150 await expect(control.take("viewer")).rejects.toThrow("closed");
151 await expect(control.agent("agent", async () => "new")).rejects.toThrow("closed");
152 finish.resolve();
153 await expect(running).resolves.toBe("finished");
154 await Promise.all([queued, closed]);
155 await control.disconnect("viewer");
156 await control.close();
157 expect(control.generation).toBe(1);
158 });
159
160 it("does not let a failed action poison the serial queue", async () => {
161 const control = new SessionControl("agent");
162 await expect(
163 control.agent("agent", async () => {
164 throw new Error("navigation failed");
165 }),
166 ).rejects.toThrow("navigation failed");
167 await expect(control.agent("agent", async () => "recovered")).resolves.toBe("recovered");
168 });
169
170 it("lets any agent act on a tab no agent opened, but only while no human controls it", async () => {
171 const control = new SessionControl(null);
172 await expect(control.agent("agent-a", async () => "acted")).resolves.toBe("acted");
173 await control.take("viewer-a");
174 await expect(control.agent("agent-a", async () => "racing")).rejects.toMatchObject({
175 reason: "humanControl",
176 });
177 await expect(control.observe(async () => "read")).resolves.toBe("read");
178 await control.release("viewer-a");
179 await expect(control.agent("agent-b", async () => "acted")).resolves.toBe("acted");
180 });
181});