返回 DeepSeek-Reasonix
browserRuntime.native.ts
根目录 / desktop / electron / src / main / browser / browserRuntime.native.ts
1 import assert from "node:assert/strict";
2 import { app, BrowserWindow, nativeImage, webContents } from "electron";
3 import { createServer } from "node:http";
4 import { mkdtemp, rm, writeFile, copyFile } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { ElectronGuestViewFactory } from "./electronGuestViews.js";
8 import { DocumentRegistry } from "./documents.js";
9 import { takeSnapshot } from "./snapshot.js";
10 import { captureScreenshot } from "./screenshot.js";
11 import type { Logger } from "../log.js";
12 import { BrowserSurfaceManager } from "./surfaceManager.js";
13 import { GrantRegistry } from "./grants.js";
14 import { BrowserRecorder } from "./recorder.js";
15 import { locateRef, resolveRef } from "./refResolver.js";
16 import { uploadFiles } from "./upload.js";
17 import { BrowserDiagnosticExport } from "./diagnosticExport.js";
18 import { withBrowserDiagnosticRequest } from "./diagnosticContext.js";
19
20 app.on("window-all-closed", () => {});
21 app.on("before-quit", () => console.log("native browser: quitting"));
22 await app.whenReady();
23 const server = createServer((req, res) => {
24 if (req.url === "/child") { res.end('<button aria-label="Child action" onclick="window.clicked=true">Child action</button>'); return; }
25 res.end('<html><body><button aria-label="Save">Save</button><input value="retained"><script>window.identity="same-page"</script></body></html>');
26 });
27 await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
28 const address = server.address();
29 assert.ok(address && typeof address === "object");
30 const directory = await mkdtemp(join(tmpdir(), "reasonix-native-browser-"));
31 const win = new BrowserWindow({ show: false, width: 800, height: 600 });
32 const background = process.platform === "darwin";
33 const recordingSeconds = Number(process.env.REASONIX_BROWSER_RECORD_SECONDS ?? 1);
34 assert.ok(Number.isInteger(recordingSeconds) && recordingSeconds >= 1 && recordingSeconds <= 90);
35 if (!background) win.showInactive();
36 const factory = new ElectronGuestViewFactory({ window: () => win, preloadPath: join(__dirname, "guest-preload.cjs"), log: { warn: console.warn } as Logger });
37 const protocolTrace: unknown[] = [];
38 if (process.env.REASONIX_BROWSER_TRACE_RPC === "1") {
39 const create = factory.create.bind(factory);
40 let request = 0;
41 factory.create = (...args) => {
42 const guest = create(...args), send = guest.page.debugger.sendCommand.bind(guest.page.debugger);
43 const pageId = guest.page.id;
44 guest.page.debugger.on("message", (_event, method, params, sessionId?: string) => {
45 if (!["Target.attachedToTarget", "Target.detachedFromTarget", "Inspector.detached", "Page.frameAttached", "Page.frameDetached", "Page.frameNavigated", "Runtime.executionContextsCleared"].includes(method)) return;
46 const data = params as { sessionId?: string; frameId?: string; reason?: string; waitingForDebugger?: boolean; frame?: { id?: string } };
47 protocolTrace.push({ at: performance.now(), pageId, event: method, sessionId, childSession: data.sessionId, frameId: data.frameId ?? data.frame?.id, reason: data.reason, waitingForDebugger: data.waitingForDebugger });
48 });
49 guest.page.debugger.sendCommand = async (method, params, sessionId) => {
50 const id = ++request, started = performance.now();
51 const target = sessionId ? "child" : "root";
52 protocolTrace.push({ at: started, id, pageId, method, target, phase: "start" });
53 try { return await send(method, params, sessionId); }
54 finally { protocolTrace.push({ at: performance.now(), id, pageId, method, target, phase: "end", milliseconds: Math.round(performance.now() - started) }); }
55 };
56 return guest;
57 };
58 }
59 const view = factory.create("native-browser-test");
60 let releaseInitialObservation: (() => void) | undefined;
61 let targetAttachments = 0;
62 const originalSend = view.page.debugger.sendCommand.bind(view.page.debugger);
63 view.page.debugger.sendCommand = async (method, params, sessionId) => {
64 if (method === "Target.attachToTarget") targetAttachments++;
65 return originalSend(method, params, sessionId);
66 };
67 // page_not_ready is refused before dispatch and asks the caller to observe
68 // again: a frame load or zoom change is not a rendered-frame boundary.
69 let inputRefusals = 0;
70 const clickObserved = async <T extends { point: { x: number; y: number } }>(observe: (attempt: number) => Promise<T>): Promise<T> => {
71 const deadline = Date.now() + 5000;
72 for (let attempt = 0; ; attempt++) {
73 const observed = await observe(attempt);
74 try { await view.sendMouseInput!({ type: "mouseMove", ...observed.point }); }
75 catch (error) {
76 if ((error as { data?: { kind?: unknown } } | null)?.data?.kind !== "page_not_ready" || Date.now() >= deadline) throw error;
77 inputRefusals++;
78 continue;
79 }
80 await view.sendMouseInput!({ type: "mouseDown", button: "left", clickCount: 1, ...observed.point });
81 await view.sendMouseInput!({ type: "mouseUp", button: "left", clickCount: 1, ...observed.point });
82 return observed;
83 }
84 };
85 try {
86 view.setBounds({ x: 0, y: 0, width: 800, height: 600 });
87 view.setVisible(!background);
88 await view.page.loadURL(`http://127.0.0.1:${address.port}`);
89 const id = view.page.id;
90 // Match the host's observation lease before reading a hidden macOS page.
91 // Keep the observation lease through input, as hostCalls does. iframe load
92 // completion is not a painted-frame boundary, especially for the OOPIF.
93 releaseInitialObservation = view.prepareObservation?.();
94 const snapshot = await takeSnapshot(view.page, "test", 1, "", new DocumentRegistry());
95 assert.match(snapshot.tree, /Save/);
96 await view.page.mainFrame.executeJavaScript(`Promise.all(["http://127.0.0.1:${address.port}/child", "http://localhost:${address.port}/child"].map(url => new Promise(resolve => { const frame = document.createElement("iframe"); frame.onload = resolve; frame.src = url; document.body.append(frame); }))).then(() => true)`);
97 const frameLease = await view.prepareCapture!(new AbortController().signal);
98 const frameDocuments = new DocumentRegistry();
99 for (const frame of view.page.mainFrame.framesInSubtree) await frame.executeJavaScript(`window.fixtureEvents = []; for (const type of ['mousedown', 'mouseup', 'click']) document.addEventListener(type, e => window.fixtureEvents.push({type, x:e.clientX, y:e.clientY, tag:e.target.tagName}));`);
100 // Holds the page moving until the first input is refused, as a slow runner
101 // leaves it unsettled after iframe load; the retry must then land on the target.
102 const unsettled = process.env.REASONIX_BROWSER_UNSETTLED_INPUT === "1";
103 if (unsettled) await view.page.mainFrame.executeJavaScript(`(() => { let offset = 0; window.fixtureUnsettled = setInterval(() => { document.body.style.paddingTop = \`\${++offset % 40}px\`; }, 4); return true; })()`);
104 let observedFrom = 0;
105 const { point, located } = await clickObserved(async attempt => {
106 if (unsettled && attempt === 1) await view.page.mainFrame.executeJavaScript("clearInterval(window.fixtureUnsettled)");
107 observedFrom = targetAttachments;
108 const frames = await takeSnapshot(view.page, "test", 1, "", frameDocuments);
109 assert.equal((frames.tree.match(/Child action/g) ?? []).length, 2, frames.tree);
110 const located = await resolveRef(view.page, frameDocuments.lookup(frames.documentToken)!, "f2e1", true);
111 assert.ok(located.ok, JSON.stringify(located));
112 return { located: located.value, point: { x: Math.round(located.value.element.x + located.value.element.width / 2), y: Math.round(located.value.element.y + located.value.element.height / 2) } };
113 });
114 const clickedFrame = located.frame;
115 const clickedBy = Date.now() + 1000;
116 let clicked = false;
117 while (!clicked && Date.now() < clickedBy) { clicked = await clickedFrame.executeJavaScript("Boolean(window.clicked)") as boolean; if (!clicked) await new Promise(resolve => setTimeout(resolve, 16)); }
118 if (!clicked) console.error(JSON.stringify({ point, scale: view.inputScale?.(), frames: await Promise.all(view.page.mainFrame.framesInSubtree.map(frame => frame.executeJavaScript(`({events:window.fixtureEvents,width:innerWidth,height:innerHeight,rects:[...document.querySelectorAll('iframe,button')].map(e=>({tag:e.tagName,rect:e.getBoundingClientRect().toJSON()}))})`))) }));
119 assert.equal(clicked, true, "cross-origin reference must hit the original element");
120 if (unsettled) assert.ok(inputRefusals > 0, "input on a moving page must be refused before dispatch");
121 await clickedFrame.executeJavaScript(`(() => { const input = document.createElement("input"); input.type = "file"; input.setAttribute("aria-label", "Attach"); input.onchange = () => { window.uploaded = input.files[0]?.name; }; document.body.append(input); })()`);
122 const uploadSnapshot = await takeSnapshot(view.page, "test", 1, "", frameDocuments);
123 const uploadRef = uploadSnapshot.tree.match(/"Attach"[^\n]*ref=(f2e\d+)/)?.[1];
124 assert.ok(uploadRef, uploadSnapshot.tree);
125 const upload = await locateRef(view.page, frameDocuments.lookup(uploadSnapshot.documentToken)!, uploadRef);
126 assert.ok(upload.ok, JSON.stringify(upload));
127 const uploadPath = join(directory, "fixture.txt");
128 await writeFile(uploadPath, "disposable native upload fixture");
129 let uploadDispatches = 0;
130 assert.equal((await uploadFiles(view.page, upload.value, [uploadPath], () => {}, () => { uploadDispatches++; })).executed, true);
131 assert.equal(uploadDispatches, 1);
132 assert.equal(await clickedFrame.executeJavaScript("window.uploaded"), "fixture.txt");
133 // A refused child-frame attempt discards its session, so count from the observation that clicked.
134 assert.ok(targetAttachments - observedFrom <= 1, "snapshot, ref resolution, input and upload must share one OOPIF session");
135 const attachedBeforeIdle = targetAttachments;
136 releaseInitialObservation?.();
137 releaseInitialObservation = undefined;
138 console.log("cross-origin file upload through bounded frame runtime: passed");
139 await clickedFrame.executeJavaScript(`new Promise(resolve => { const frame = document.createElement('iframe'); frame.style.cssText = 'display:block;width:180px;height:70px;margin:8px'; frame.onload = resolve; frame.src = 'http://localhost:${address.port}/child'; document.body.append(frame); }).then(() => true)`);
140 for (const frame of clickedFrame.framesInSubtree) await frame.executeJavaScript(`window.fixtureEvents = []; for (const type of ['mousedown', 'mouseup', 'click']) document.addEventListener(type, e => window.fixtureEvents.push({type, x:e.clientX, y:e.clientY, tag:e.target.tagName}));`);
141 const idleBy = Date.now() + 4000;
142 while (view.page.debugger.isAttached() && Date.now() < idleBy) await new Promise(resolve => setTimeout(resolve, 20));
143 assert.equal(view.page.debugger.isAttached(), false, "idle connection must be released");
144 const afterIdle = await takeSnapshot(view.page, "test", 1, "", new DocumentRegistry());
145 assert.equal((afterIdle.tree.match(/Child action/g) ?? []).length, 3, afterIdle.tree);
146 assert.equal(targetAttachments, attachedBeforeIdle + 1, "idle release must initialize one fresh OOPIF session");
147 console.log("CDP idle release and new observation: passed");
148 frameLease();
149 console.log("same-origin and cross-origin isolated snapshots: passed");
150 assert.ok(view.prepareCapture);
151 const release = await view.prepareCapture(new AbortController().signal);
152 try {
153 const shot = await captureScreenshot(view.page, null, 1, { ref: "", fullPage: false, directory }, { decodePNG: data => nativeImage.createFromBuffer(data).getSize() });
154 assert.ok(shot.width >= 800 && shot.height >= 600);
155 await copyFile(shot.path, join(__dirname, "browser-evidence.png"));
156 if (background) assert.equal(BrowserWindow.getFocusedWindow(), null);
157 console.log(JSON.stringify({ platform: process.platform, electron: process.versions.electron, screenshot: { width: shot.width, height: shot.height }, snapshot: "passed" }));
158 } finally { release(); }
159 assert.equal(view.page.id, id);
160 assert.equal(await view.page.mainFrame.executeJavaScript('window.identity + ":" + document.querySelector("input").value'), "same-page:retained");
161 assert.equal(win.isVisible(), !background);
162 assert.ok(view.setViewport);
163 // Exercise foreground Fit input separately from capture (which temporarily
164 // uses scale 1). Hit both same-process and out-of-process child frames.
165 win.showInactive();
166 view.setVisible(true);
167 const releaseFitObservation = view.prepareObservation?.();
168 try {
169 for (let transition = 0; transition < 3; transition++) {
170 view.setViewport(null);
171 view.page.setZoomFactor(1.25);
172 const { zoomPoint, point: zoomedPoint } = await clickObserved(async () => {
173 const zoomPoint = await view.page.mainFrame.executeJavaScript(`(() => { window.fixtureZoomClicked = false; const button = document.querySelector('button'); button.onclick = () => { window.fixtureZoomClicked = true; }; button.scrollIntoView(); const r = button.getBoundingClientRect(); return {x:r.x+r.width/2,y:r.y+r.height/2}; })()`) as { x: number; y: number };
174 return { zoomPoint, point: { x: Math.round(zoomPoint.x * 1.25), y: Math.round(zoomPoint.y * 1.25) } };
175 });
176 let zoomClicked = false;
177 const zoomDeadline = Date.now() + 1000;
178 while (!zoomClicked && Date.now() < zoomDeadline) {
179 zoomClicked = await view.page.mainFrame.executeJavaScript("Boolean(window.fixtureZoomClicked)") as boolean;
180 if (!zoomClicked) await new Promise(resolve => setTimeout(resolve, 16));
181 }
182 if (!zoomClicked) console.error(JSON.stringify({ zoomPoint, zoomedPoint, frames: await Promise.all(view.page.mainFrame.framesInSubtree.map(frame => frame.executeJavaScript("({width:innerWidth,events:window.fixtureEvents,rect:document.querySelector('button').getBoundingClientRect().toJSON()})"))) }));
183 assert.equal(zoomClicked, true, "browser zoom must be converted exactly once");
184 console.log("natural viewport page zoom input: passed");
185 for (const displayScale of [null, "fit", 0.5, 0.75] as const) {
186 if (displayScale !== null) view.setViewport({ width: 1280, height: 720, scale: displayScale });
187 for (const prefix of ["f1", "f2", "f3"]) {
188 const { target, scale, point } = await clickObserved(async () => {
189 const docs = new DocumentRegistry();
190 const observed = await takeSnapshot(view.page, "test", 1, "", docs);
191 const ref = observed.tree.match(new RegExp(`"Child action"[^\\n]*ref=(${prefix}e\\d+)`))?.[1];
192 assert.ok(ref, observed.tree);
193 const resolved = await resolveRef(view.page, docs.lookup(observed.documentToken)!, ref, true);
194 assert.ok(resolved.ok, JSON.stringify(resolved));
195 await resolved.value.frame.executeJavaScript("window.clicked = false");
196 const scale = view.inputScale?.() ?? 1;
197 return { target: resolved.value, scale, point: { x: Math.round((resolved.value.element.x + resolved.value.element.width / 2) * scale), y: Math.round((resolved.value.element.y + resolved.value.element.height / 2) * scale) } };
198 });
199 const clickDeadline = Date.now() + 1000;
200 let fitClicked = false;
201 while (!fitClicked && Date.now() < clickDeadline) {
202 fitClicked = await target.frame.executeJavaScript("Boolean(window.clicked)") as boolean;
203 if (!fitClicked) await new Promise(resolve => setTimeout(resolve, 16));
204 }
205 if (!fitClicked) console.error(JSON.stringify({ prefix, point, scale, element: target.element, frames: await Promise.all(view.page.mainFrame.framesInSubtree.map(frame => frame.executeJavaScript(`({events:window.fixtureEvents,width:innerWidth,height:innerHeight,rects:[...document.querySelectorAll('iframe,button')].map(e=>({tag:e.tagName,rect:e.getBoundingClientRect().toJSON()}))})`))) }));
206 assert.equal(fitClicked, true, `foreground Fit must hit ${prefix}, scale=${scale}, point=${JSON.stringify(point)}`);
207 const receipt = await target.frame.executeJavaScript(`(() => { const event = window.fixtureEvents.filter(e => e.type === 'click').at(-1); const rect = document.querySelector('button').getBoundingClientRect(); return {event, x:rect.x+rect.width/2, y:rect.y+rect.height/2}; })()`) as { event: { x: number; y: number }; x: number; y: number };
208 assert.ok(Math.abs(receipt.event.x - receipt.x) <= 2 && Math.abs(receipt.event.y - receipt.y) <= 2, `pointer must hit the observed centre, not merely the same large button: ${JSON.stringify({ prefix, scale, receipt })}`);
209 }
210 }
211 }
212 } finally { releaseFitObservation?.(); }
213 console.log("foreground Fit same-origin and cross-origin clicks: passed");
214 view.setVisible(!background);
215 if (background) win.hide();
216 view.setViewport({ width: 393, height: 852, scale: "fit" });
217 const responsive = await view.prepareCapture(new AbortController().signal);
218 try {
219 const viewport = await view.page.mainFrame.executeJavaScript('({width: innerWidth, height: innerHeight, dpr: devicePixelRatio})') as { width: number; height: number; dpr: number };
220 assert.deepEqual(viewport, { width: 393, height: 852, dpr: 1 });
221 const shot = await captureScreenshot(view.page, null, view.inputScale?.() ?? 1, { ref: "", fullPage: false, directory }, { viewport: { width: 393, height: 852 }, pixelRatio: view.capturePixelRatio?.(), decodePNG: data => nativeImage.createFromBuffer(data).getSize() });
222 assert.equal(shot.width, 393); assert.equal(shot.height, 852);
223 console.log("responsive CSS viewport and PNG: passed");
224 } finally { responsive(); }
225 view.destroy();
226 const surfaces = new BrowserSurfaceManager({ views: factory, contentSize: () => ({ width: 800, height: 600 }), onTakeover() {}, onCrash() {}, log: { warn: console.warn } as Logger });
227 surfaces.setLayout({ x: 0, y: 0, width: 800, height: 600 });
228 const tab = await surfaces.open(`http://127.0.0.1:${address.port}`, { taskId: "native-task", sessionId: "native-session", temporary: true });
229 if (!background) surfaces.activate(tab.id);
230 const grants = new GrantRegistry({ generation: () => "native-generation" });
231 const diagnosticScope = "a".repeat(64);
232 const diagnosticExport = new BrowserDiagnosticExport(surfaces, grants, { build: "native-production-bundle", version: process.versions.electron, platform: process.platform });
233 diagnosticExport.bind(grants.install({ grantId: "native-grant", taskId: tab.taskId, sessionId: tab.sessionId, diagnosticScope }));
234 await tab.view.page.mainFrame.executeJavaScript('console.error("HTTPS://user:NATIVE-SECRET@example.test/path?code=NATIVE-SECRET#NATIVE-SECRET HtTp://example.test/path?signature=NATIVE-SECRET")');
235 await tab.view.page.mainFrame.executeJavaScript(`new Promise(resolve => { const frame = document.createElement("iframe"); frame.onload = resolve; frame.src = "http://localhost:${address.port}/child"; document.body.append(frame); }).then(() => true)`);
236 // Match host/browser.snapshot's observation lease. Calling the DOM runtime
237 // directly on an unleased hidden tab exercises Chromium's throttled state,
238 // not the tool path being qualified.
239 const releaseDiagnosticObservation = tab.view.prepareObservation?.();
240 try {
241 const releaseDiagnosticSurface = await tab.view.prepareCapture!(new AbortController().signal);
242 try {
243 const result = await withBrowserDiagnosticRequest({ requestId: "native-request", operationId: "native-operation", method: "host/browser.snapshot", tabId: tab.id }, event => diagnosticExport.trace(diagnosticScope, event), () => takeSnapshot(tab.view.page, tab.id, tab.epoch, "", new DocumentRegistry()));
244 assert.match(result.tree, /Child action/, "diagnostic fixture must actually observe its cross-origin child");
245 } finally { releaseDiagnosticSurface(); }
246 } finally { releaseDiagnosticObservation?.(); }
247 const recorder = new BrowserRecorder(surfaces, grants, __dirname);
248 try {
249 const recording = await recorder.request(tab, "native-grant", "start", "", directory, recordingSeconds);
250 const until = Date.now() + recordingSeconds * 1000 + 15_000;
251 let result = recording;
252 while (!["completed", "interrupted", "cancelled", "failed"].includes(result.state) && Date.now() < until) {
253 await new Promise(resolve => setTimeout(resolve, 100));
254 result = await recorder.request(tab, "native-grant", "status", recording.id, directory);
255 }
256 assert.equal(result.state, "completed", JSON.stringify(result));
257 assert.ok(result.bytes > 32 && result.width && result.height);
258 assert.ok(result.durationMs! >= recordingSeconds * 1000 - 500 && result.durationMs! <= recordingSeconds * 1000 + 10_000, JSON.stringify(result));
259 await copyFile(result.path!, join(__dirname, "browser-recording.webm"));
260 console.log(JSON.stringify({ recording: result.state, bytes: result.bytes, width: result.width, height: result.height, durationMs: result.durationMs }));
261 const interrupted = await recorder.request(tab, "native-grant", "start", "", directory, 20);
262 surfaces.takeover(tab.id, "native takeover test");
263 await recorder.close();
264 const stopped = await recorder.request(tab, "native-grant", "status", interrupted.id, directory);
265 assert.equal(stopped.state, "interrupted"); assert.equal(stopped.path, undefined);
266 console.log("recording takeover: interrupted without publishing an artifact");
267 assert.equal((await recorder.userRequest(tab, "start", directory))?.state, "recording");
268 assert.equal(surfaces.takeoverFromSender(tab.view.page.id, "keydown"), true);
269 assert.equal((await recorder.userRequest(tab, "status", directory))?.state, "recording");
270 const userRecording = await recorder.userRequest(tab, "stop", directory);
271 assert.equal(userRecording?.state, "completed", JSON.stringify(userRecording));
272 assert.ok(userRecording.bytes > 32 && userRecording.path);
273 console.log("user recording: input preserved recording and stop published a WebM");
274 } finally { await recorder.close(); surfaces.destroyAll(); console.log("native browser: recording resources released"); }
275 const diagnosticResult = diagnosticExport.read(diagnosticScope);
276 assert.ok(diagnosticResult.entries.some(row => row.kind === "page"), "native console event must reach the session export");
277 assert.ok(diagnosticResult.entries.some(row => row.command === "Page.createIsolatedWorld" && row.requestId === "native-request"), "isolated-world command must retain its diagnostic request attribution");
278 assert.ok(diagnosticResult.entries.some(row => row.event === "tab_closed"), "closed tab must remain in the export");
279 assert.equal(JSON.stringify(diagnosticResult).includes("NATIVE-SECRET"), false);
280 assert.equal(diagnosticResult.tabs.length, 0);
281 await writeFile(join(__dirname, "browser-diagnostics.json"), JSON.stringify(diagnosticResult, null, 2));
282 diagnosticExport.dispose();
283 console.log("browser diagnostics: native page errors, CDP attribution, closed-tab retention and redaction passed");
284 const cycles = Number(process.env.REASONIX_BROWSER_CYCLES ?? 1);
285 assert.ok(Number.isInteger(cycles) && cycles >= 1 && cycles <= 100);
286 for (let cycle = 0; cycle < cycles; cycle++) {
287 const page = factory.create("native-cycle");
288 page.setBounds({ x: 0, y: 0, width: 640, height: 480 });
289 page.setVisible(!background);
290 try {
291 await page.page.loadURL(`http://127.0.0.1:${address.port}/child`);
292 await page.page.mainFrame.executeJavaScript(`Promise.all(["http://127.0.0.1:${address.port}/child", "http://localhost:${address.port}/child"].map(url => new Promise(resolve => { const frame = document.createElement("iframe"); frame.onload = resolve; frame.src = url; document.body.append(frame); }))).then(() => true)`);
293 const lease = await page.prepareCapture!(new AbortController().signal);
294 try {
295 const documents = new DocumentRegistry();
296 const observed = await takeSnapshot(page.page, "cycle", cycle, "", documents);
297 assert.equal((observed.tree.match(/Child action/g) ?? []).length, 3, observed.tree);
298 const childRef = await resolveRef(page.page, documents.lookup(observed.documentToken)!, "f2e1", false);
299 assert.ok(childRef.ok, JSON.stringify(childRef));
300 await captureScreenshot(page.page, null, 1, { ref: "", fullPage: false, directory });
301 } finally { lease(); }
302 } finally { page.destroy(); }
303 const releasedBy = Date.now() + 2000;
304 while (webContents.getAllWebContents().length > 1 && Date.now() < releasedBy) await new Promise(resolve => setTimeout(resolve, 10));
305 assert.equal(webContents.getAllWebContents().length, 1, `cycle ${cycle + 1}: only owner window may remain`);
306 }
307 console.log(`native browser: ${cycles} lifecycle cycles released all pages`);
308 } finally {
309 releaseInitialObservation?.();
310 view.destroy(); win.destroy(); server.close();
311 await rm(directory, { recursive: true, force: true });
312 const deadline = Date.now() + 5000;
313 while (webContents.getAllWebContents().length && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 20));
314 assert.equal(webContents.getAllWebContents().length, 0, "all native pages and capture/recorder hosts must be released");
315 console.log("native browser: cleanup complete");
316 // Buffer protocol traces to avoid per-command terminal IO perturbing races.
317 if (protocolTrace.length) console.log(protocolTrace.map(row => JSON.stringify(row)).join("\n"));
318 }
319 await writeFile(join(__dirname, "result.json"), JSON.stringify({ completed: true, platform: process.platform, electron: process.versions.electron, chrome: process.versions.chrome, cycles: Number(process.env.REASONIX_BROWSER_CYCLES ?? 1), backgroundCapture: background, isolatedCrossOriginSnapshot: true, crossOriginClick: true, crossOriginUpload: true, crossOriginLifecycleCycles: true, idleReconnect: true, responsive: { width: 393, height: 852, dpr: 1 }, recording: { mime: "video/webm", width: 1280, height: 720, requestedSeconds: recordingSeconds, takeover: "interrupted", userInput: "continued", userStop: "completed" }, remainingWebContents: 0 }));
320
320 lines TYPESCRIPT