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 close: vi.fn(async () => {
26 if (!closed) {
27 closed = true;
28 events.emit("close");
29 }
30 }),
31 };
32};
33
34const makeBrowser = () => {
35 const events = new NodeEvents.EventEmitter();
36 const contexts: ReturnType<typeof makeContext>[] = [];
37 return {
38 contexts,
39 on: vi.fn((event: string, listener: () => void) => events.on(event, listener)),
40 newContext: vi.fn(async () => {
41 const context = makeContext();
42 contexts.push(context);
43 return context as unknown as BrowserContext;
44 }),
45 close: vi.fn(async () => {
46 for (const context of contexts) await context.close();
47 events.emit("disconnected");
48 }),
49 };
50};
51
52const options = () => ({
53 profilesDir: "/test/profiles",
54 executable: vi.fn(async () => "/test/chromium"),
55 env: {},
56});
57
58beforeEach(() => {
59 launches.launch.mockReset();
60 launches.persistent.mockReset();
61 launches.mkdir.mockClear();
62 launches.rm.mockClear();
63});
64
65describe("ServerBrowserContexts", () => {
66 it("lazily shares one browser across concurrent isolated sessions and reuses each context", async () => {
67 const browser = makeBrowser();
68 const launch = Promise.withResolvers<Browser>();
69 launches.launch.mockReturnValue(launch.promise);
70 const configuration = options();
71 const pool = new ServerBrowserContexts(configuration);
72 expect(configuration.executable).not.toHaveBeenCalled();
73 const first = pool.contextFor("default", "agent-a");
74 const again = pool.contextFor("default", "agent-a");
75 const second = pool.contextFor("default", "agent-b");
76 const incognito = pool.contextFor(INCOGNITO_BROWSER_PROFILE_ID);
77 launch.resolve(browser as unknown as Browser);
78 const [a, reused, b, privateContext] = await Promise.all([first, again, second, incognito]);
79 expect(a).toBe(reused);
80 expect(new Set([a, b, privateContext]).size).toBe(3);
81 expect(launches.launch).toHaveBeenCalledTimes(1);
82 expect(launches.persistent).not.toHaveBeenCalled();
83 expect(browser.newContext).toHaveBeenCalledWith({
84 viewport: { width: 1280, height: 800 },
85 deviceScaleFactor: 2,
86 });
87 await pool.close();
88 expect(browser.close).toHaveBeenCalledTimes(1);
89 for (const context of browser.contexts) expect(context.close).toHaveBeenCalled();
90 });
91
92 it("keeps human profiles persistent without sharing their storage with agents", async () => {
93 const human = makeContext();
94 const browser = makeBrowser();
95 launches.persistent.mockResolvedValue(human as unknown as BrowserContext);
96 launches.launch.mockResolvedValue(browser as unknown as Browser);
97 const pool = new ServerBrowserContexts(options());
98 const persistent = await pool.contextFor("work/team");
99 expect(await pool.contextFor("work/team")).toBe(persistent);
100 expect(await pool.contextFor("work/team", "agent")).not.toBe(persistent);
101 expect(launches.persistent).toHaveBeenCalledExactlyOnceWith(
102 "/test/profiles/work%2Fteam",
103 expect.objectContaining({ chromiumSandbox: true, executablePath: "/test/chromium" }),
104 );
105 expect(launches.mkdir).toHaveBeenCalledWith("/test/profiles/work%2Fteam", { recursive: true });
106 await pool.close();
107 expect(human.close).toHaveBeenCalledTimes(1);
108 });
109
110 it("clears a profile by closing its open context before deleting its storage", async () => {
111 const human = makeContext();
112 const browser = makeBrowser();
113 launches.persistent.mockResolvedValue(human as unknown as BrowserContext);
114 launches.launch.mockResolvedValue(browser as unknown as Browser);
115 const onContextClose = vi.fn();
116 const pool = new ServerBrowserContexts({ ...options(), onContextClose });
117 const persistent = await pool.contextFor("work/team");
118 const agent = await pool.contextFor("work/team", "agent");
119 launches.rm.mockImplementationOnce(async () => {
120 expect(human.close).toHaveBeenCalledTimes(1);
121 });
122 await pool.clearProfile("work/team");
123 expect(onContextClose).toHaveBeenCalledExactlyOnceWith(persistent);
124 expect(launches.rm).toHaveBeenCalledExactlyOnceWith("/test/profiles/work%2Fteam", {
125 recursive: true,
126 force: true,
127 });
128 expect(await pool.contextFor("work/team", "agent")).toBe(agent);
129 expect(browser.contexts[0]?.close).not.toHaveBeenCalled();
130 launches.persistent.mockResolvedValue(makeContext() as unknown as BrowserContext);
131 expect(await pool.contextFor("work/team")).not.toBe(persistent);
132 await pool.clearProfile("never-opened");
133 expect(launches.rm).toHaveBeenLastCalledWith("/test/profiles/never-opened", {
134 recursive: true,
135 force: true,
136 });
137 await pool.close();
138 });
139
140 it("opens a profile requested during its clear only after the storage is deleted", async () => {
141 launches.persistent.mockImplementation(async () => makeContext() as unknown as BrowserContext);
142 const configuration = options();
143 const pool = new ServerBrowserContexts(configuration);
144 await pool.contextFor("work/team");
145 const removing = Promise.withResolvers<void>();
146 const removed = Promise.withResolvers<void>();
147 let deleted = false;
148 launches.rm.mockImplementationOnce(async () => {
149 removing.resolve();
150 await removed.promise;
151 deleted = true;
152 });
153 // Launching starts by resolving the executable; record whether storage was gone by then.
154 const launchedAfterDelete: Array<boolean> = [];
155 configuration.executable.mockImplementation(async () => {
156 launchedAfterDelete.push(deleted);
157 return "/test/chromium";
158 });
159 const clearing = pool.clearProfile("work/team");
160 const reopening = pool.contextFor("work/team");
161 await removing.promise;
162 removed.resolve();
163 await Promise.all([clearing, reopening]);
164 expect(launchedAfterDelete).toEqual([true]);
165 await pool.close();
166 });
167
168 it.each([undefined, "default"])(
169 "never retries a failed %s launch with the sandbox disabled",
170 async (profile) => {
171 const launch = profile ? launches.persistent : launches.launch;
172 launch.mockRejectedValue(new Error("sandbox unavailable"));
173 const pool = new ServerBrowserContexts(options());
174 await expect(pool.contextFor(profile ?? INCOGNITO_BROWSER_PROFILE_ID)).rejects.toThrow(
175 "sandbox unavailable",
176 );
177 expect(launch).toHaveBeenCalledTimes(1);
178 expect(
179 profile ? launches.persistent.mock.calls[0]?.[1] : launches.launch.mock.calls[0]?.[0],
180 ).toMatchObject({ chromiumSandbox: true });
181 await pool.close();
182 },
183 );
184
185 it("allows only an explicit sandbox opt-out", async () => {
186 const browser = makeBrowser();
187 launches.launch.mockResolvedValue(browser as unknown as Browser);
188 const pool = new ServerBrowserContexts({
189 ...options(),
190 env: { T3CODE_SERVER_BROWSER_SANDBOX: "0" },
191 });
192 await pool.contextFor(INCOGNITO_BROWSER_PROFILE_ID);
193 expect(launches.launch).toHaveBeenCalledWith(
194 expect.objectContaining({ chromiumSandbox: false }),
195 );
196 await pool.close();
197 });
198
199 it("retries a failed startup on a later request with sandboxing still enabled", async () => {
200 const browser = makeBrowser();
201 launches.launch
202 .mockRejectedValueOnce(new Error("executable unavailable"))
203 .mockResolvedValue(browser as unknown as Browser);
204 const pool = new ServerBrowserContexts(options());
205 await expect(pool.contextFor("default", "agent")).rejects.toThrow("executable unavailable");
206 await pool.contextFor("default", "agent");
207 expect(launches.launch).toHaveBeenCalledTimes(2);
208 expect(launches.launch).toHaveBeenLastCalledWith(
209 expect.objectContaining({ chromiumSandbox: true }),
210 );
211 await pool.close();
212 });
213
214 it("evicts a closed context without closing other agents or relaunching chromium", async () => {
215 const browser = makeBrowser();
216 launches.launch.mockResolvedValue(browser as unknown as Browser);
217 const onContextClose = vi.fn();
218 const pool = new ServerBrowserContexts({ ...options(), onContextClose });
219 const a = await pool.contextFor("default", "agent-a");
220 const b = await pool.contextFor("default", "agent-b");
221 await a.close();
222 expect(onContextClose).toHaveBeenCalledWith(a);
223 expect(await pool.contextFor("default", "agent-a")).not.toBe(a);
224 expect(await pool.contextFor("default", "agent-b")).toBe(b);
225 expect(browser.close).not.toHaveBeenCalled();
226 expect(launches.launch).toHaveBeenCalledTimes(1);
227 await pool.close();
228 });
229
230 it("cleans up a browser whose launch finishes during shutdown and rejects future work", async () => {
231 const browser = makeBrowser();
232 const launch = Promise.withResolvers<Browser>();
233 launches.launch.mockReturnValue(launch.promise);
234 const pool = new ServerBrowserContexts(options());
235 const opening = expect(pool.contextFor("default", "agent")).rejects.toThrow("closed");
236 const closing = pool.close();
237 launch.resolve(browser as unknown as Browser);
238 await Promise.all([opening, closing]);
239 await expect(pool.contextFor("default", "agent")).rejects.toThrow("closed");
240 await pool.close();
241 expect(browser.close).toHaveBeenCalledTimes(1);
242 expect(browser.contexts[0]?.close).toHaveBeenCalled();
243 });
244});