diff --git a/docs/browser-capture-harness.md b/docs/browser-capture-harness.md index c598f909f..b6e98ae49 100644 --- a/docs/browser-capture-harness.md +++ b/docs/browser-capture-harness.md @@ -19,6 +19,15 @@ Run it with the repo Electron: npm run capture-harness --workspace=@getpaseo/desktop ``` +On macOS the harness process must set `app.setActivationPolicy("accessory")` and +hide the Dock icon before creating any window. `showInactive()` only prevents window +focus; a normal Electron app launch can still activate the app and steal focus. +Harness windows are then created hidden, positioned in a screen corner, skipped from +the taskbar where Electron supports it, and revealed with `showInactive()` from +`ready-to-show`. Do not replace this with `show()`, `focus()`, or `app.focus()`: +the compositor only needs visible inactive windows, and harness runs must not steal +focus from the person using the machine. + The harness writes PNG evidence and `results.json` to: ```text diff --git a/packages/desktop/capture-harness/bright.html b/packages/desktop/capture-harness/bright.html index 9e37a4f5a..54a59da52 100644 --- a/packages/desktop/capture-harness/bright.html +++ b/packages/desktop/capture-harness/bright.html @@ -70,5 +70,20 @@
LOCAL MAGENTA TARGET 1280x800 VIEWPORT
FULL PAGE MARKER AT Y=1370
+ diff --git a/packages/desktop/capture-harness/index.html b/packages/desktop/capture-harness/index.html index 83ca52968..2541787fc 100644 --- a/packages/desktop/capture-harness/index.html +++ b/packages/desktop/capture-harness/index.html @@ -43,10 +43,16 @@ const RESIDENT_VIEWPORT_HEIGHT = 800; const host = document.getElementById("paseo-browser-resident-webviews"); const params = new URLSearchParams(window.location.search); - const webviewCount = Math.max(2, Number(params.get("webviewCount")) || 2); + const requestedWebviewCount = params.has("webviewCount") + ? Number(params.get("webviewCount")) + : 2; + const webviewCount = Number.isFinite(requestedWebviewCount) + ? Math.max(0, requestedWebviewCount) + : 2; const webviews = []; const activeTokens = new Set(); let nextTokenId = 0; + let permanentParkingState = params.get("permanentParkingState") || ""; function appendHarnessWebview(sourceUrl) { const index = webviews.length; @@ -112,6 +118,7 @@ } function applyParking() { + permanentParkingState = ""; applyHostParking(); webviews.forEach((webview) => { applyStackedWebviewStyle(webview); @@ -126,6 +133,7 @@ } function applyCapturePrep() { + permanentParkingState = ""; host.style.position = "fixed"; host.style.left = "0"; host.style.top = "0"; @@ -140,6 +148,99 @@ host.style.transform = ""; } + function applyHostPermanentBase() { + host.style.position = "fixed"; + host.style.left = "0"; + host.style.top = "0"; + host.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`; + host.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`; + host.style.overflow = "hidden"; + host.style.opacity = "1"; + host.style.pointerEvents = "none"; + host.style.zIndex = "1"; + host.style.clipPath = ""; + host.style.visibility = ""; + host.style.transform = ""; + host.style.transformOrigin = "0 0"; + } + + function applyPermanentWebviewBase(webview, index) { + applyStackedWebviewStyle(webview); + webview.style.opacity = "1"; + webview.style.transform = ""; + webview.style.pointerEvents = "none"; + webview.style.zIndex = "0"; + webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`; + webview.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`; + webview.style.left = "0"; + webview.style.top = "0"; + } + + function applyPermanentParkingState(stateName) { + permanentParkingState = stateName; + activeTokens.clear(); + applyHostPermanentBase(); + webviews.forEach(applyPermanentWebviewBase); + + if (stateName === "p1-overflow-1x1") { + host.style.width = "1px"; + host.style.height = "1px"; + host.style.overflow = "hidden"; + return state(); + } + + if (stateName === "p2-clip-path-1x1") { + host.style.width = "1px"; + host.style.height = "1px"; + host.style.overflow = "visible"; + host.style.clipPath = "inset(1px)"; + return state(); + } + + if (stateName === "p3-opacity-0") { + host.style.opacity = "0"; + return state(); + } + + if (stateName === "p4-transform-scale-0") { + host.style.transform = "scale(0)"; + return state(); + } + + if (stateName === "p4-transform-scale-0001") { + host.style.transform = "scale(0.001)"; + return state(); + } + + if (stateName === "p5-webview-0x0") { + webviews.forEach((webview) => { + webview.style.width = "0"; + webview.style.height = "0"; + }); + return state(); + } + + if (stateName === "p5-webview-1x1") { + webviews.forEach((webview) => { + webview.style.width = "1px"; + webview.style.height = "1px"; + }); + return state(); + } + + if (stateName === "p6-z-index-negative") { + host.style.zIndex = "-1"; + return state(); + } + + if (stateName === "p7-opacity-001") { + host.style.opacity = "0.01"; + return state(); + } + + throw new Error(`Unknown permanent parking state: ${stateName}`); + } + function applyTargetStacking(targetIndex) { webviews.forEach((webview, index) => { applyStackedWebviewStyle(webview, index === targetIndex ? "2" : "0"); @@ -162,7 +263,7 @@ function state(targetIndex = 0) { const hostRect = host.getBoundingClientRect(); const targetWebview = webviews[targetIndex] || webviews[0]; - const webviewRect = targetWebview.getBoundingClientRect(); + const webviewRect = targetWebview?.getBoundingClientRect(); return { hostStyle: { left: host.style.left, @@ -172,6 +273,9 @@ overflow: host.style.overflow, opacity: host.style.opacity, pointerEvents: host.style.pointerEvents, + zIndex: host.style.zIndex, + clipPath: host.style.clipPath, + transform: host.style.transform, }, hostRect: { x: hostRect.x, @@ -180,10 +284,10 @@ height: hostRect.height, }, webviewRect: { - x: webviewRect.x, - y: webviewRect.y, - width: webviewRect.width, - height: webviewRect.height, + x: webviewRect?.x ?? 0, + y: webviewRect?.y ?? 0, + width: webviewRect?.width ?? 0, + height: webviewRect?.height ?? 0, }, webviews: webviews.map((webview) => { const rect = webview.getBoundingClientRect(); @@ -191,6 +295,7 @@ id: webview.id, zIndex: webview.style.zIndex, position: webview.style.position, + opacity: webview.style.opacity, x: rect.x, y: rect.y, width: rect.width, @@ -213,6 +318,16 @@ addWebview(sourceUrl) { return appendHarnessWebview(sourceUrl || params.get("targetUrl") || "bright.html"); }, + addPermanentWebview(sourceUrl, stateName) { + const index = appendHarnessWebview(sourceUrl || params.get("targetUrl") || "bright.html"); + applyPermanentParkingState(stateName || permanentParkingState); + return index; + }, + async applyPermanentParkingState(stateName) { + const nextState = applyPermanentParkingState(stateName); + await waitFrames(2); + return nextState; + }, async prepareForPixelCapture(targetIndex = 0) { const token = `capture-${++nextTokenId}`; activeTokens.add(token); @@ -252,6 +367,10 @@ return state(); }, }; + + if (permanentParkingState) { + applyPermanentParkingState(permanentParkingState); + } diff --git a/packages/desktop/capture-harness/main.js b/packages/desktop/capture-harness/main.js index 115265165..bea566a09 100644 --- a/packages/desktop/capture-harness/main.js +++ b/packages/desktop/capture-harness/main.js @@ -1,7 +1,7 @@ const fs = require("node:fs"); const fsp = require("node:fs/promises"); const path = require("node:path"); -const { app, BrowserWindow, nativeImage } = require("electron"); +const { app, BrowserWindow, nativeImage, screen } = require("electron"); const ROOT = __dirname; const OUT_DIR = process.env.PASEO_CAPTURE_HARNESS_OUT_DIR || path.join(ROOT, "out"); @@ -11,15 +11,155 @@ const FULL_PAGE_HEIGHT = 1600; const CAPTURE_TIMEOUT_MS = 5000; const CAPTURE_RETRY_INTERVAL_MS = 200; const REPEAT_COUNT = 5; +const FRESH_REPEAT_COUNT = 3; +const SOAK_MS = Number(process.env.PASEO_CAPTURE_HARNESS_SOAK_MS || 75000); +const HARNESS_GROUP = process.env.PASEO_CAPTURE_HARNESS_GROUP || "all"; +const PERMANENT_STATE_FILTER = new Set( + (process.env.PASEO_CAPTURE_HARNESS_STATES || "") + .split(",") + .map((state) => state.trim()) + .filter(Boolean), +); +const PERMANENT_VARIANT_FILTER = new Set( + (process.env.PASEO_CAPTURE_HARNESS_VARIANTS || "") + .split(",") + .map((variant) => variant.trim()) + .filter(Boolean), +); +const PERMANENT_CAPTURE_MODES = ["viewport", "full-page"]; +const PERMANENT_THROTTLING_VARIANTS = [ + { + id: "capture-only-throttling", + code: "capture-only", + label: "backgroundThrottling disabled only during each capture", + disableGuestBackgroundThrottlingAtAttach: false, + }, + { + id: "attach-off-throttling", + code: "attach-off", + label: "backgroundThrottling disabled once at guest attach", + disableGuestBackgroundThrottlingAtAttach: true, + }, +]; +const ATTACH_OFF_VARIANT_STATE_CODES = new Set(["P1", "P3", "P7"]); +const PERMANENT_PARKING_STATES = [ + { + id: "p1-overflow-1x1", + code: "P1", + label: "host 1x1 overflow hidden", + }, + { + id: "p2-clip-path-1x1", + code: "P2", + label: "host 1x1 clip-path inset 1px", + }, + { + id: "p3-opacity-0", + code: "P3", + label: "host full-size opacity 0", + }, + { + id: "p4-transform-scale-0", + code: "P4a", + label: "host full-size transform scale(0)", + }, + { + id: "p4-transform-scale-0001", + code: "P4b", + label: "host full-size transform scale(0.001)", + }, + { + id: "p5-webview-0x0", + code: "P5a", + label: "webview element 0x0", + }, + { + id: "p5-webview-1x1", + code: "P5b", + label: "webview element 1x1", + }, + { + id: "p6-z-index-negative", + code: "P6", + label: "host full-size z-index -1 behind page", + }, + { + id: "p7-opacity-001", + code: "P7", + label: "host full-size opacity 0.01", + }, +]; -function fileUrl(filePath) { - return new URL(`file://${filePath}`).toString(); +function applyEarlyMacHarnessActivationPolicy() { + if (process.platform !== "darwin") { + return; + } + try { + app.setActivationPolicy("accessory"); + } catch { + // App readiness varies by Electron/macOS version; enforce again before windows. + } +} + +function applyMacHarnessActivationPolicyBeforeWindows() { + if (process.platform !== "darwin") { + return; + } + app.setActivationPolicy("accessory"); + app.dock?.hide(); +} + +applyEarlyMacHarnessActivationPolicy(); + +function fileUrl(filePath, params = {}) { + const url = new URL(`file://${filePath}`); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)); + } + } + return url.toString(); } function ensureDirSync(dir) { fs.mkdirSync(dir, { recursive: true }); } +function cornerWindowBounds(width, height) { + const { workArea } = screen.getPrimaryDisplay(); + const inset = 12; + return { + x: Math.round(workArea.x + workArea.width - width - inset), + y: Math.round(workArea.y + workArea.height - height - inset), + width, + height, + }; +} + +function createInactiveHarnessWindow(input) { + const { width, height, ...options } = input; + const win = new BrowserWindow({ + ...options, + ...cornerWindowBounds(width, height), + show: false, + skipTaskbar: true, + }); + const readyToShow = new Promise((resolve) => { + win.once("ready-to-show", () => { + if (!win.isDestroyed()) { + win.showInactive(); + } + resolve(); + }); + }); + return { win, readyToShow }; +} + +async function waitForInactiveReveal(handle, label) { + await withTimeout(handle.readyToShow, `${label} ready-to-show`); + await delay(250); +} + function withTimeout(promise, label) { let timeoutId; const timeout = new Promise((_, reject) => { @@ -48,8 +188,11 @@ function analyzeImage(image, expected, guestMetrics) { return { width: 0, height: 0, + logicalWidthAtDpr: 0, + logicalHeightAtDpr: 0, brightRatio: 0, textNonUniform: false, + matchedSize: false, pass: false, }; } @@ -131,6 +274,51 @@ function analyzeImage(image, expected, guestMetrics) { }; } +function expectedForMode(mode) { + return mode === "viewport" + ? { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT, minBrightRatio: 0.65 } + : { width: VIEWPORT_WIDTH, height: FULL_PAGE_HEIGHT, minBrightRatio: 0.55 }; +} + +function summarizeAnalysis(analysis) { + if (!analysis) { + return { + width: 0, + height: 0, + logicalWidthAtDpr: 0, + logicalHeightAtDpr: 0, + brightRatio: 0, + textNonUniform: false, + matchedSize: false, + pass: false, + }; + } + return { + width: analysis.width, + height: analysis.height, + logicalWidthAtDpr: analysis.logicalWidthAtDpr, + logicalHeightAtDpr: analysis.logicalHeightAtDpr, + brightRatio: analysis.brightRatio, + textNonUniform: analysis.textNonUniform, + matchedSize: analysis.matchedSize, + pass: analysis.pass, + }; +} + +function analysisSize(analysis) { + if (!analysis) { + return "0x0"; + } + return `${analysis.width}x${analysis.height}`; +} + +function analysisLogicalSize(analysis) { + if (!analysis) { + return "0x0"; + } + return `${analysis.logicalWidthAtDpr}x${analysis.logicalHeightAtDpr}`; +} + function pass(message) { console.log(`PASS ${message}`); } @@ -141,6 +329,7 @@ function fail(message) { } async function saveImage(image, outputPath) { + ensureDirSync(path.dirname(outputPath)); await fsp.writeFile(outputPath, image.toPNG()); } @@ -236,6 +425,267 @@ async function captureFullPage(contents) { } } +async function captureFullPageSequence(contents) { + const previousBackgroundThrottling = contents.getBackgroundThrottling(); + contents.setBackgroundThrottling(false); + try { + contents.invalidate(); + return await captureFullPage(contents); + } finally { + contents.setBackgroundThrottling(previousBackgroundThrottling); + } +} + +function installHarnessWebviewGuards(win) { + win.webContents.on("will-attach-webview", (_event, webPreferences) => { + webPreferences.nodeIntegration = false; + webPreferences.contextIsolation = true; + }); +} + +function trackAttachedGuests(win, input = {}) { + const attachedGuests = []; + const waiters = []; + win.webContents.on("did-attach-webview", (_event, contents) => { + if (input.disableGuestBackgroundThrottlingAtAttach) { + contents.setBackgroundThrottling(false); + } + attachedGuests.push(contents); + const waiter = waiters.shift(); + if (waiter) { + waiter(contents); + } + }); + return { + attachedGuests, + waitForNextAttachedGuest() { + return new Promise((resolve) => { + waiters.push(resolve); + }); + }, + }; +} + +async function createPermanentHarnessWindow(state, variant) { + const handle = createInactiveHarnessWindow({ + width: 1400, + height: 900, + backgroundColor: "#202020", + webPreferences: { + webviewTag: true, + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }); + const { win } = handle; + installHarnessWebviewGuards(win); + const tracker = trackAttachedGuests(win, { + disableGuestBackgroundThrottlingAtAttach: variant.disableGuestBackgroundThrottlingAtAttach, + }); + await withTimeout( + win.loadFile(path.join(ROOT, "index.html"), { + query: { + webviewCount: "0", + permanentParkingState: state.id, + targetUrl: fileUrl(path.join(ROOT, "bright.html")), + }, + }), + "permanent harness window loadFile", + ); + await waitForInactiveReveal(handle, "permanent harness window"); + return { win, tracker }; +} + +async function createPermanentKeeperWindow() { + const handle = createInactiveHarnessWindow({ + width: 1, + height: 1, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }); + const { win } = handle; + await withTimeout(win.loadURL("about:blank"), "permanent keeper loadURL"); + await waitForInactiveReveal(handle, "permanent keeper"); + return win; +} + +async function closeHarnessWindow(win) { + if (!win.isDestroyed()) { + win.close(); + } +} + +function targetUrlForVariant(state, variant, phase, mode, index) { + const webviewNumber = index + 1; + return fileUrl(path.join(ROOT, "bright.html"), { + label: `${state.code} ${variant.code} ${phase.toUpperCase()} W${webviewNumber}`, + sub: `${mode.toUpperCase()} ${state.id} ${variant.code}`, + bottom: `${state.code} ${variant.code} FULL PAGE MARKER`, + }); +} + +function variantsForPermanentState(state) { + const defaultVariants = ATTACH_OFF_VARIANT_STATE_CODES.has(state.code) + ? PERMANENT_THROTTLING_VARIANTS + : [PERMANENT_THROTTLING_VARIANTS[0]]; + if (PERMANENT_VARIANT_FILTER.size === 0) { + return defaultVariants; + } + return defaultVariants.filter( + (variant) => + PERMANENT_VARIANT_FILTER.has(variant.id) || PERMANENT_VARIANT_FILTER.has(variant.code), + ); +} + +async function appendPermanentWebview({ win, tracker, state, sourceUrl }) { + const guestPromise = tracker.waitForNextAttachedGuest(); + const targetIndex = await withTimeout( + renderer( + win, + `window.captureHarness.addPermanentWebview(${JSON.stringify(sourceUrl)}, ${JSON.stringify(state.id)})`, + ), + "permanent add webview", + ); + const guest = await withTimeout(guestPromise, "permanent did-attach-webview"); + return { guest, targetIndex }; +} + +async function measurePermanentCapture({ + contents, + mode, + state, + variant, + phase, + repeatIndex, + repeatTotal, + targetIndex, + guestMetrics, + retryUntilPass = false, +}) { + const outputPath = path.join( + OUT_DIR, + "permanent-parking", + variant.id, + state.id, + `${phase}-${mode}-webview-${targetIndex + 1}-${repeatIndex}.png`, + ); + await fsp.rm(outputPath, { force: true }); + const expected = expectedForMode(mode); + const start = Date.now(); + const deadline = start + CAPTURE_TIMEOUT_MS; + let attempt = 0; + let lastAnalysis = null; + let lastError = ""; + let lastImage = null; + + while (Date.now() < deadline || attempt === 0) { + attempt += 1; + try { + const image = + mode === "viewport" + ? await capturePageSequence(contents) + : await captureFullPageSequence(contents); + lastImage = image; + lastAnalysis = analyzeImage(image, expected, guestMetrics); + if (lastAnalysis.pass) { + await saveImage(image, outputPath); + const latencyMs = Date.now() - start; + const result = { + group: "permanent-parking", + stateId: state.id, + stateCode: state.code, + stateLabel: state.label, + variantId: variant.id, + variantCode: variant.code, + variantLabel: variant.label, + attachTimeBackgroundThrottlingDisabled: variant.disableGuestBackgroundThrottlingAtAttach, + phase, + mode, + repeatIndex, + repeatTotal, + targetIndex, + attempts: attempt, + latencyMs, + outputPath, + error: null, + analysis: summarizeAnalysis(lastAnalysis), + pass: true, + }; + pass( + `permanent ${state.code} ${variant.code} ${phase} ${mode} webview ${targetIndex + 1} ${repeatIndex}/${repeatTotal} attempts=${attempt} size=${analysisSize(lastAnalysis)} logical=${analysisLogicalSize(lastAnalysis)} bright=${lastAnalysis.brightRatio.toFixed(4)} text=${lastAnalysis.textNonUniform} file=${outputPath}`, + ); + return result; + } + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + + if (!retryUntilPass) { + break; + } + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + break; + } + await delay(Math.min(CAPTURE_RETRY_INTERVAL_MS, remainingMs)); + } + + if (lastImage && !lastImage.isEmpty()) { + await saveImage(lastImage, outputPath); + } + const latencyMs = Date.now() - start; + const bright = lastAnalysis ? lastAnalysis.brightRatio.toFixed(4) : "0.0000"; + const textNonUniform = lastAnalysis ? lastAnalysis.textNonUniform : false; + const size = analysisSize(lastAnalysis); + const logicalSize = analysisLogicalSize(lastAnalysis); + const result = { + group: "permanent-parking", + stateId: state.id, + stateCode: state.code, + stateLabel: state.label, + variantId: variant.id, + variantCode: variant.code, + variantLabel: variant.label, + attachTimeBackgroundThrottlingDisabled: variant.disableGuestBackgroundThrottlingAtAttach, + phase, + mode, + repeatIndex, + repeatTotal, + targetIndex, + attempts: attempt, + latencyMs, + outputPath: lastImage && !lastImage.isEmpty() ? outputPath : null, + error: lastError || null, + analysis: summarizeAnalysis(lastAnalysis), + pass: false, + }; + console.log( + `FAIL permanent ${state.code} ${variant.code} ${phase} ${mode} webview ${targetIndex + 1} ${repeatIndex}/${repeatTotal} attempts=${attempt} size=${size} logical=${logicalSize} bright=${bright} text=${textNonUniform} error=${lastError || "pixel verdict failed"} file=${result.outputPath || "none"}`, + ); + return result; +} + +async function writePermanentParkingResults(results) { + await fsp.writeFile( + path.join(OUT_DIR, "permanent-parking-results.json"), + `${JSON.stringify( + { + generatedAt: new Date().toISOString(), + soakMs: SOAK_MS, + states: PERMANENT_PARKING_STATES, + variants: PERMANENT_THROTTLING_VARIANTS, + results, + }, + null, + 2, + )}\n`, + ); +} + async function captureWithPrep({ win, contents, @@ -269,7 +719,7 @@ async function captureWithPrep({ const image = mode === "viewport" ? await capturePageSequence(contents) - : await captureFullPage(contents); + : await captureFullPageSequence(contents); const analysis = analyzeImage(image, expected, guestMetrics); const size = `${analysis.width}x${analysis.height}`; const logicalSize = `${analysis.logicalWidthAtDpr}x${analysis.logicalHeightAtDpr}`; @@ -314,7 +764,9 @@ async function expectLegacySecondWebviewFailure({ win, contents, mode, guestMetr const outputPath = path.join(OUT_DIR, `${mode}-legacy-webview-${targetIndex + 1}.png`); try { const image = - mode === "viewport" ? await capturePageSequence(contents) : await captureFullPage(contents); + mode === "viewport" + ? await capturePageSequence(contents) + : await captureFullPageSequence(contents); await saveImage(image, outputPath); const expected = mode === "viewport" @@ -372,8 +824,307 @@ async function captureFreshDelayedWebview({ win, waitForNextAttachedGuest, mode }); } +async function runPermanentFreshPhase(state, variant, results) { + for (let repeatIndex = 1; repeatIndex <= FRESH_REPEAT_COUNT; repeatIndex += 1) { + for (const mode of PERMANENT_CAPTURE_MODES) { + const { win, tracker } = await createPermanentHarnessWindow(state, variant); + try { + const sourceUrl = targetUrlForVariant(state, variant, "fresh", mode, 0); + const { guest, targetIndex } = await appendPermanentWebview({ + win, + tracker, + state, + sourceUrl, + }); + const guestMetrics = await readGuestMetrics(guest); + results.push( + await measurePermanentCapture({ + contents: guest, + mode, + state, + variant, + phase: "fresh", + repeatIndex, + repeatTotal: FRESH_REPEAT_COUNT, + targetIndex, + guestMetrics, + retryUntilPass: true, + }), + ); + } finally { + await closeHarnessWindow(win); + } + } + } +} + +async function runPermanentSettledAndSoakPhases(state, variant, results) { + const { win, tracker } = await createPermanentHarnessWindow(state, variant); + try { + const targets = []; + for (const mode of PERMANENT_CAPTURE_MODES) { + const sourceUrl = targetUrlForVariant(state, variant, "settled", mode, targets.length); + const target = await appendPermanentWebview({ win, tracker, state, sourceUrl }); + targets.push({ mode, ...target }); + } + await Promise.all(targets.map((target) => waitForGuestLoad(target.guest))); + await delay(250); + const guestMetrics = new Map(); + for (const target of targets) { + guestMetrics.set(target.targetIndex, await readGuestMetrics(target.guest)); + } + + for (const target of targets) { + for (let repeatIndex = 1; repeatIndex <= REPEAT_COUNT; repeatIndex += 1) { + results.push( + await measurePermanentCapture({ + contents: target.guest, + mode: target.mode, + state, + variant, + phase: "settled", + repeatIndex, + repeatTotal: REPEAT_COUNT, + targetIndex: target.targetIndex, + guestMetrics: guestMetrics.get(target.targetIndex), + }), + ); + } + } + + console.log(`SOAK permanent ${state.code} idle ${SOAK_MS}ms`); + await delay(SOAK_MS); + + for (let repeatIndex = 1; repeatIndex <= REPEAT_COUNT; repeatIndex += 1) { + for (const target of targets) { + results.push( + await measurePermanentCapture({ + contents: target.guest, + mode: target.mode, + state, + variant, + phase: "soak", + repeatIndex, + repeatTotal: REPEAT_COUNT, + targetIndex: target.targetIndex, + guestMetrics: guestMetrics.get(target.targetIndex), + }), + ); + } + } + } finally { + await closeHarnessWindow(win); + } +} + +async function runPermanentMultiTabPhase(state, variant, results) { + const { win, tracker } = await createPermanentHarnessWindow(state, variant); + try { + const targets = []; + for (let index = 0; index < 3; index += 1) { + const sourceUrl = targetUrlForVariant(state, variant, "multi", "multi", index); + const target = await appendPermanentWebview({ win, tracker, state, sourceUrl }); + targets.push(target); + } + await Promise.all(targets.map((target) => waitForGuestLoad(target.guest))); + await delay(250); + const guestMetrics = new Map(); + for (const target of targets) { + guestMetrics.set(target.targetIndex, await readGuestMetrics(target.guest)); + } + + for (const target of targets.slice(1, 3)) { + for (const mode of PERMANENT_CAPTURE_MODES) { + results.push( + await measurePermanentCapture({ + contents: target.guest, + mode, + state, + variant, + phase: "multi-tab", + repeatIndex: 1, + repeatTotal: 1, + targetIndex: target.targetIndex, + guestMetrics: guestMetrics.get(target.targetIndex), + }), + ); + } + } + } finally { + await closeHarnessWindow(win); + } +} + +async function createOccludingWindow(targetWindow) { + const bounds = targetWindow.getBounds(); + const handle = createInactiveHarnessWindow({ + width: bounds.width, + height: bounds.height, + alwaysOnTop: true, + backgroundColor: "#101010", + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }); + const { win: blocker } = handle; + await blocker.loadURL( + "data:text/html;charset=utf-8,", + ); + await waitForInactiveReveal(handle, "occluding window"); + blocker.setAlwaysOnTop(true); + await delay(500); + return blocker; +} + +async function runPermanentWindowHostilityPhase(state, variant, results) { + const { win, tracker } = await createPermanentHarnessWindow(state, variant); + let blocker = null; + try { + const targets = []; + for (const mode of PERMANENT_CAPTURE_MODES) { + const sourceUrl = targetUrlForVariant(state, variant, "hostile", mode, targets.length); + const target = await appendPermanentWebview({ win, tracker, state, sourceUrl }); + targets.push({ mode, ...target }); + } + await Promise.all(targets.map((target) => waitForGuestLoad(target.guest))); + await delay(250); + const guestMetrics = new Map(); + for (const target of targets) { + guestMetrics.set(target.targetIndex, await readGuestMetrics(target.guest)); + } + + blocker = await createOccludingWindow(win); + for (const target of targets) { + results.push( + await measurePermanentCapture({ + contents: target.guest, + mode: target.mode, + state, + variant, + phase: "window-occluded", + repeatIndex: 1, + repeatTotal: 1, + targetIndex: target.targetIndex, + guestMetrics: guestMetrics.get(target.targetIndex), + }), + ); + } + await closeHarnessWindow(blocker); + blocker = null; + + win.minimize(); + await delay(800); + for (const target of targets) { + results.push( + await measurePermanentCapture({ + contents: target.guest, + mode: target.mode, + state, + variant, + phase: "window-minimized", + repeatIndex: 1, + repeatTotal: 1, + targetIndex: target.targetIndex, + guestMetrics: guestMetrics.get(target.targetIndex), + }), + ); + } + } finally { + if (blocker) { + await closeHarnessWindow(blocker); + } + await closeHarnessWindow(win); + } +} + +async function runPermanentParkingState(state, variant, results) { + console.log(`STATE permanent ${state.code} ${variant.code} ${state.label}`); + await runPermanentFreshPhase(state, variant, results); + await writePermanentParkingResults(results); + await runPermanentSettledAndSoakPhases(state, variant, results); + await writePermanentParkingResults(results); + await runPermanentMultiTabPhase(state, variant, results); + await writePermanentParkingResults(results); + await runPermanentWindowHostilityPhase(state, variant, results); + await writePermanentParkingResults(results); +} + +async function runPermanentParkingGroup() { + const results = []; + await fsp.rm(path.join(OUT_DIR, "permanent-parking"), { recursive: true, force: true }); + await fsp.rm(path.join(OUT_DIR, "permanent-parking-results.json"), { force: true }); + const keeper = await createPermanentKeeperWindow(); + const states = + PERMANENT_STATE_FILTER.size === 0 + ? PERMANENT_PARKING_STATES + : PERMANENT_PARKING_STATES.filter( + (state) => PERMANENT_STATE_FILTER.has(state.id) || PERMANENT_STATE_FILTER.has(state.code), + ); + console.log(`RUN permanent-parking states=${states.length} soakMs=${SOAK_MS}`); + try { + for (const state of states) { + for (const variant of variantsForPermanentState(state)) { + try { + await runPermanentParkingState(state, variant, results); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(`FAIL permanent ${state.code} ${variant.code} fatal error=${message}`); + results.push({ + group: "permanent-parking", + stateId: state.id, + stateCode: state.code, + stateLabel: state.label, + variantId: variant.id, + variantCode: variant.code, + variantLabel: variant.label, + attachTimeBackgroundThrottlingDisabled: + variant.disableGuestBackgroundThrottlingAtAttach, + phase: "fatal", + mode: "setup", + repeatIndex: 1, + repeatTotal: 1, + targetIndex: 0, + attempts: 0, + latencyMs: 0, + outputPath: null, + error: message, + analysis: summarizeAnalysis(null), + pass: false, + }); + await writePermanentParkingResults(results); + } + } + } + } finally { + await closeHarnessWindow(keeper); + } + return results; +} + async function main() { ensureDirSync(OUT_DIR); + if (!["all", "existing", "permanent-parking"].includes(HARNESS_GROUP)) { + fail(`unknown harness group ${HARNESS_GROUP}`); + } + + if (HARNESS_GROUP === "permanent-parking") { + const permanentParkingResults = await runPermanentParkingGroup(); + await fsp.writeFile( + path.join(OUT_DIR, "results.json"), + `${JSON.stringify( + { + generatedAt: new Date().toISOString(), + permanentParkingResults, + }, + null, + 2, + )}\n`, + ); + pass(`capture harness permanent-parking complete output=${OUT_DIR}`); + return; + } const attachedGuests = []; const freshGuestWaiters = []; @@ -385,10 +1136,9 @@ async function main() { new Promise((resolve) => { freshGuestWaiters.push(resolve); }); - const win = new BrowserWindow({ + const handle = createInactiveHarnessWindow({ width: 1000, height: 700, - show: true, backgroundColor: "#202020", webPreferences: { webviewTag: true, @@ -397,6 +1147,7 @@ async function main() { sandbox: false, }, }); + const { win } = handle; win.webContents.on("will-attach-webview", (_event, webPreferences) => { webPreferences.nodeIntegration = false; @@ -416,6 +1167,7 @@ async function main() { await win.loadFile(path.join(ROOT, "index.html"), { query: { targetUrl: fileUrl(path.join(ROOT, "bright.html")), webviewCount: "2" }, }); + await waitForInactiveReveal(handle, "capture harness window"); await withTimeout(guestsPromise, "did-attach-webview"); await Promise.all(attachedGuests.map((guest) => waitForGuestLoad(guest))); await renderer(win, "window.captureHarness.waitForFrames(2)"); @@ -504,9 +1256,20 @@ async function main() { } } + const permanentParkingResults = HARNESS_GROUP === "all" ? await runPermanentParkingGroup() : []; + await fsp.writeFile( path.join(OUT_DIR, "results.json"), - `${JSON.stringify({ generatedAt: new Date().toISOString(), guestMetrics, results }, null, 2)}\n`, + `${JSON.stringify( + { + generatedAt: new Date().toISOString(), + guestMetrics, + results, + permanentParkingResults, + }, + null, + 2, + )}\n`, ); pass(`capture harness complete output=${OUT_DIR}`); @@ -516,8 +1279,14 @@ async function main() { } app + .on("window-all-closed", () => { + // The permanent parking sweep intentionally opens and closes many phase windows. + }) .whenReady() - .then(main) + .then(() => { + applyMacHarnessActivationPolicyBeforeWindows(); + return main(); + }) .then(() => app.quit()) .catch(async (error) => { console.error(error);