1import * as NodeEvents from "node:events";
2
3import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts";
4import { chromium, type Browser, type BrowserContext, type BrowserType } from "playwright-core";
5import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7import { ServerBrowserContexts } from "./ServerBrowserContexts.ts";
8
9const launches = vi.hoisted(() => ({
10 launch: vi.fn<BrowserType["launch"]>(),
11 persistent: vi.fn<BrowserType["launchPersistentContext"]>(),
12 mkdir: vi.fn().mockResolvedValue(undefined),
13 rm: vi.fn().mockResolvedValue(undefined),
14}));
15vi.spyOn(chromium, "launch").mockImplementation(launches.launch);
16vi.spyOn(chromium, "launchPersistentContext").mockImplementation(launches.persistent);
17vi.mock("node:fs/promises", () => ({ mkdir: launches.mkdir, rm: launches.rm }));
18
19const makeContext = () => {
20 const events = new NodeEvents.EventEmitter();
21 let closed = false;
22 return {
23 on: vi.fn((event: string, listener: () => void) => events.on(event, listener)),
24 pages: vi.fn(() => []),
25 newPage: vi.fn(async () => ({ once: vi.fn() })),
26 isClosed: () => closed,
27 close: vi.fn(async () => {
28 if (!closed) {
29 closed = true;
30 events.emit("close");
31 }
32 }),
33 };
34};
35
36const makeBrowser = () => {
37 const events = new NodeEvents.EventEmitter();
38 const contexts: ReturnType<typeof makeContext>[] = [];
39 return {
40 created: contexts,
41 contexts: () => contexts.filter((context) => !context.isClosed()),
42 on: vi.fn((event: string, listener: () => void) => events.on(event, listener)),
43 newContext: vi.fn(async () => {
44 const context = makeContext();
45 contexts.push(context);
46 return context as unknown as BrowserContext;
47 }),
48 close: vi.fn(async () => {
49 for (const context of contexts) await context.close();
50 events.emit("disconnected");
51 }),
52 };
53};
54
55const options = () => ({
56 profilesDir: "/test/profiles",
57 executable: vi.fn(async () => "/test/chromium"),
58 env: {},
59});
60
61const withLauncher = () => {
62 const release = vi.fn(async () => {});
63 return {
64 release,
65 configuration: {
66 ...options(),
67 launcher: vi.fn(async (executable: string) => ({ path: `${executable}-launcher`, release })),
68 },
69 };
70};
71
72beforeEach(() => {
73 launches.launch.mockReset();
74 launches.persistent.mockReset();
75 launches.mkdir.mockClear();
76 launches.rm.mockClear();
77});
78
79describe("ServerBrowserContexts", () => {
80 it("closes the shared browser only after it holds no context for the idle period", async () => {
81 vi.useFakeTimers();
82 try {
83 const first = makeBrowser();
84 const second = makeBrowser();
85 launches.launch
86 .mockResolvedValueOnce(first as unknown as Browser)
87 .mockResolvedValueOnce(second as unknown as Browser);
88 const pool = new ServerBrowserContexts({ ...options(), idleBrowserMs: 10 });
89 const agent = await pool.contextFor("default", "agent");
90 await pool.scratchPage();
91 await agent.close();
92 await vi.advanceTimersByTimeAsync(10);
93 expect(first.close).not.toHaveBeenCalled();
94 await first.created[1]!.close();
95 await vi.advanceTimersByTimeAsync(9);
96 const next = await pool.contextFor("default", "next");
97 await vi.advanceTimersByTimeAsync(10);
98 expect(first.close).not.toHaveBeenCalled();
99 await next.close();
100 await vi.advanceTimersByTimeAsync(10);
101 expect(first.close).toHaveBeenCalledOnce();
102 await pool.contextFor("default", "later");
103 expect(launches.launch).toHaveBeenCalledTimes(2);
104 expect(second.newContext).toHaveBeenCalledOnce();
105 await pool.close();
106 } finally {
107 vi.useRealTimers();
108 }
109 });
110
111 it("closes a scratch context when creating its page fails", async () => {
112 const browser = makeBrowser();
113 launches.launch.mockResolvedValue(browser as unknown as Browser);
114 const pool = new ServerBrowserContexts(options());
115 const peer = await pool.contextFor("default", "peer");
116 const context = await browser.newContext();
117 const failure = new Error("newPage failed");
118 browser.created[1]!.newPage.mockRejectedValueOnce(failure);
119 browser.newContext.mockResolvedValueOnce(context);
120 try {
121 await expect(pool.scratchPage()).rejects.toBe(failure);
122 expect(context.close).toHaveBeenCalledOnce();
123 expect(browser.contexts()).toEqual([peer]);
124 expect(peer.close).not.toHaveBeenCalled();
125 expect(browser.close).not.toHaveBeenCalled();
126 } finally {
127 await pool.close();
128 }
129 });
130
131 it("lazily shares one browser across concurrent isolated sessions and reuses each context", async () => {
132 const browser = makeBrowser();
133 const launch = Promise.withResolvers<Browser>();
134 const started = Promise.withResolvers<void>();
135 launches.launch.mockImplementation(() => {
136 started.resolve();
137 return launch.promise;
138 });
139 const { configuration, release } = withLauncher();
140 const pool = new ServerBrowserContexts(configuration);
141 expect(configuration.executable).not.toHaveBeenCalled();
142 const first = pool.contextFor("default", "agent-a");
143 const again = pool.contextFor("default", "agent-a");
144 const second = pool.contextFor("default", "agent-b");
145 const incognito = pool.contextFor(INCOGNITO_BROWSER_PROFILE_ID);
146 await started.promise;
147 // Playwright may retry the same path until the launch settles.
148 expect(release).not.toHaveBeenCalled();
149 launch.resolve(browser as unknown as Browser);
150 const [a, reused, b, privateContext] = await Promise.all([first, again, second, incognito]);
151 expect(release).toHaveBeenCalledTimes(1);
152 expect(a).toBe(reused);
153 expect(new Set([a, b, privateContext]).size).toBe(3);
154 expect(launches.launch).toHaveBeenCalledExactlyOnceWith(
155 expect.objectContaining({ executablePath: "/test/chromium-launcher" }),
156 );
157 expect(launches.persistent).not.toHaveBeenCalled();
158 expect(browser.newContext).toHaveBeenCalledWith({
159 viewport: { width: 1280, height: 800 },
160 deviceScaleFactor: 2,
161 });
162 await pool.close();
163 expect(browser.close).toHaveBeenCalledTimes(1);
164 for (const context of browser.created) expect(context.close).toHaveBeenCalled();
165 });
166
167 it("keeps human profiles persistent without sharing their storage with agents", async () => {
168 const human = makeContext();
169 const browser = makeBrowser();
170 launches.persistent.mockResolvedValue(human as unknown as BrowserContext);
171 launches.launch.mockResolvedValue(browser as unknown as Browser);
172 const { configuration, release } = withLauncher();
173 const pool = new ServerBrowserContexts(configuration);
174 const persistent = await pool.contextFor("work/team");
175 expect(await pool.contextFor("work/team")).toBe(persistent);
176 expect(await pool.contextFor("work/team", "agent")).not.toBe(persistent);
177 expect(launches.persistent).toHaveBeenCalledExactlyOnceWith(
178 "/test/profiles/work%2Fteam",
179 expect.objectContaining({ chromiumSandbox: true, executablePath: "/test/chromium-launcher" }),
180 );
181 expect(release).toHaveBeenCalledTimes(2);
182 expect(launches.mkdir).toHaveBeenCalledWith("/test/profiles/work%2Fteam", { recursive: true });
183 await pool.close();
184 expect(human.close).toHaveBeenCalledTimes(1);
185 });
186
187 it("diagnoses a launcher failure against the real executable", async () => {
188 const diagnosed = new Error("diagnosed");
189 const diagnose = vi.fn(async () => diagnosed);
190 const pool = new ServerBrowserContexts({
191 ...options(),
192 diagnose,
193 launcher: vi.fn(async () => {
194 throw new Error("EACCES");
195 }),
196 });
197 await expect(pool.contextFor("default", "agent")).rejects.toBe(diagnosed);
198 expect(diagnose).toHaveBeenCalledExactlyOnceWith("/test/chromium", expect.any(Error));
199 expect(launches.launch).not.toHaveBeenCalled();
200 });
201
202 it("creates no launcher for a profile whose storage cannot be created", async () => {
203 launches.mkdir.mockRejectedValueOnce(new Error("EACCES"));
204 const { configuration } = withLauncher();
205 const pool = new ServerBrowserContexts(configuration);
206 await expect(pool.contextFor("work/team")).rejects.toThrow("EACCES");
207 expect(configuration.launcher).not.toHaveBeenCalled();
208 });
209
210 it("clears a profile by closing its open context before deleting its storage", async () => {
211 const human = makeContext();
212 const browser = makeBrowser();
213 launches.persistent.mockResolvedValue(human as unknown as BrowserContext);
214 launches.launch.mockResolvedValue(browser as unknown as Browser);
215 const onContextClose = vi.fn();
216 const pool = new ServerBrowserContexts({ ...options(), onContextClose });
217 const persistent = await pool.contextFor("work/team");
218 const agent = await pool.contextFor("work/team", "agent");
219 launches.rm.mockImplementationOnce(async () => {
220 expect(human.close).toHaveBeenCalledTimes(1);
221 });
222 await pool.clearProfile("work/team");
223 expect(onContextClose).toHaveBeenCalledExactlyOnceWith(persistent);
224 expect(launches.rm).toHaveBeenCalledExactlyOnceWith("/test/profiles/work%2Fteam", {
225 recursive: true,
226 force: true,
227 });
228 expect(await pool.contextFor("work/team", "agent")).toBe(agent);
229 expect(browser.created[0]?.close).not.toHaveBeenCalled();
230 launches.persistent.mockResolvedValue(makeContext() as unknown as BrowserContext);
231 expect(await pool.contextFor("work/team")).not.toBe(persistent);
232 await pool.clearProfile("never-opened");
233 expect(launches.rm).toHaveBeenLastCalledWith("/test/profiles/never-opened", {
234 recursive: true,
235 force: true,
236 });
237 await pool.close();
238 });
239
240 it("waits for the context to close when the same profile is cleared concurrently", async () => {
241 const human = makeContext();
242 const closed = Promise.withResolvers<void>();
243 human.close.mockImplementation(async () => closed.promise);
244 launches.persistent.mockResolvedValue(human as unknown as BrowserContext);
245 const pool = new ServerBrowserContexts(options());
246 await pool.contextFor("work/team");
247
248 const first = pool.clearProfile("work/team");
249 const second = pool.clearProfile("work/team");
250 const deletionsBeforeClose = launches.rm.mock.calls.length;
251 closed.resolve();
252 await Promise.all([first, second]);
253
254 expect(deletionsBeforeClose).toBe(0);
255 expect(human.close).toHaveBeenCalledTimes(1);
256 expect(launches.rm).toHaveBeenCalledExactlyOnceWith("/test/profiles/work%2Fteam", {
257 recursive: true,
258 force: true,
259 });
260 await pool.close();
261 });
262
263 it("opens a profile requested during its clear only after the storage is deleted", async () => {
264 launches.persistent.mockImplementation(async () => makeContext() as unknown as BrowserContext);
265 const configuration = options();
266 const pool = new ServerBrowserContexts(configuration);
267 await pool.contextFor("work/team");
268 const removing = Promise.withResolvers<void>();
269 const removed = Promise.withResolvers<void>();
270 let deleted = false;
271 launches.rm.mockImplementationOnce(async () => {
272 removing.resolve();
273 await removed.promise;
274 deleted = true;
275 });
276 const launchedAfterDelete: Array<boolean> = [];
277 configuration.executable.mockImplementation(async () => {
278 launchedAfterDelete.push(deleted);
279 return "/test/chromium";
280 });
281 const clearing = pool.clearProfile("work/team");
282 const reopening = pool.contextFor("work/team");
283 await removing.promise;
284 removed.resolve();
285 await Promise.all([clearing, reopening]);
286 expect(launchedAfterDelete).toEqual([true]);
287 await pool.close();
288 });
289
290 it.each([undefined, "default"])(
291 "never retries a failed %s launch with the sandbox disabled",
292 async (profile) => {
293 const launch = profile ? launches.persistent : launches.launch;
294 launch.mockRejectedValue(new Error("sandbox unavailable"));
295 const pool = new ServerBrowserContexts(options());
296 await expect(pool.contextFor(profile ?? INCOGNITO_BROWSER_PROFILE_ID)).rejects.toThrow(
297 "sandbox unavailable",
298 );
299 expect(launch).toHaveBeenCalledTimes(1);
300 expect(
301 profile ? launches.persistent.mock.calls[0]?.[1] : launches.launch.mock.calls[0]?.[0],
302 ).toMatchObject({ chromiumSandbox: true });
303 await pool.close();
304 },
305 );
306
307 it("allows only an explicit sandbox opt-out", async () => {
308 const browser = makeBrowser();
309 launches.launch.mockResolvedValue(browser as unknown as Browser);
310 const pool = new ServerBrowserContexts({
311 ...options(),
312 env: { T3CODE_SERVER_BROWSER_SANDBOX: "0" },
313 });
314 await pool.contextFor(INCOGNITO_BROWSER_PROFILE_ID);
315 expect(launches.launch).toHaveBeenCalledWith(
316 expect.objectContaining({ chromiumSandbox: false }),
317 );
318 await pool.close();
319 });
320
321 it("retries a failed startup on a later request with sandboxing still enabled", async () => {
322 const browser = makeBrowser();
323 launches.launch
324 .mockRejectedValueOnce(new Error("executable unavailable"))
325 .mockResolvedValue(browser as unknown as Browser);
326 const pool = new ServerBrowserContexts(options());
327 await expect(pool.contextFor("default", "agent")).rejects.toThrow("executable unavailable");
328 await pool.contextFor("default", "agent");
329 expect(launches.launch).toHaveBeenCalledTimes(2);
330 expect(launches.launch).toHaveBeenLastCalledWith(
331 expect.objectContaining({ chromiumSandbox: true }),
332 );
333 await pool.close();
334 });
335
336 it("cleans up a browser whose launch finishes during shutdown and rejects future work", async () => {
337 const browser = makeBrowser();
338 const launch = Promise.withResolvers<Browser>();
339 launches.launch.mockReturnValue(launch.promise);
340 const pool = new ServerBrowserContexts(options());
341 const opening = expect(pool.contextFor("default", "agent")).rejects.toThrow("closed");
342 const closing = pool.close();
343 launch.resolve(browser as unknown as Browser);
344 await Promise.all([opening, closing]);
345 await expect(pool.contextFor("default", "agent")).rejects.toThrow("closed");
346 await pool.close();
347 expect(browser.close).toHaveBeenCalledTimes(1);
348 expect(browser.created[0]?.close).toHaveBeenCalled();
349 });
350});