| 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 |