blob: 0c8c637056b84e640698ef26c29f5fe71de50c4c [file]
// Boot an e2e-fixture window and evaluate in its renderer.
//
// This is the same launch the Playwright E2E suite performs, driven from a
// plain script so the migration contract harness can run outside the test
// runner. The environment builder and the bounded teardown are shared with
// that suite rather than restated; the launch, readiness wait, and teardown
// go through Playwright's Electron support rather than a second hand-rolled
// CDP client.
//
// An earlier version of this file did roll its own: fixed debug ports, a
// `/json/list` poll, a raw WebSocket, and a process-group kill. It attached to
// whatever was listening on the port, which meant a leftover Electron from a
// previous run was captured instead of the window this call launched — a
// silent wrong-window pass on a contract whose entire value is "no diff means
// the migration did not move this element".
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { _electron as electron } from '@playwright/test';
import { closeElectronApplication } from './electron-lifecycle.mjs';
import { buildFixtureEnv, isCiLinuxDisplay } from './fixture-env.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const DESKTOP_DIR = join(ROOT, 'apps', 'desktop');
export const DEFAULT_READY_TIMEOUT_MS = Number(process.env.FIXTURE_READY_TIMEOUT_MS ?? 30_000);
export const DEFAULT_SETTLE_MS = Number(process.env.FIXTURE_SETTLE_MS ?? 1_000);
// Playwright's evaluate has no deadline of its own: a renderer that wedges or
// an expression that returns a never-settling promise would hang the run
// forever. Generous, because a capture expression walks the whole tree.
const EVALUATE_TIMEOUT_MS = Number(process.env.FIXTURE_EVALUATE_TIMEOUT_MS ?? 60_000);
// Grace for ElectronApplication.close() before the process tree is SIGKILLed.
const CLOSE_GRACE_MS = 5_000;
/**
* Pinned so a capture taken here matches one taken anywhere else. Any IANA
* name works; UTC is the one nobody has to look up.
*/
export const CAPTURE_TIMEZONE = process.env.FIXTURE_TIMEZONE ?? 'UTC';
/**
* Electron 43 defaults to native Wayland when XDG_SESSION_TYPE=wayland, where
* BrowserWindow.showInactive() is unsupported. Keep inactive fixtures on
* XWayland; other launches retain Electron's platform default.
*
* @param {NodeJS.ProcessEnv} [env]
* @param {NodeJS.Platform} [platform]
*/
export function inactiveWindowElectronArgs(env = process.env, platform = process.platform) {
return platform === 'linux' && env.XDG_SESSION_TYPE?.toLowerCase() === 'wayland'
? ['.', '--ozone-platform=x11']
: ['.'];
}
/**
* Map the BrowserWindow owned by this launch without focusing it.
*
* @param {import('@playwright/test').ElectronApplication} app
* @param {import('@playwright/test').Page} page
*/
async function mapFixtureWindowInactive(app, page) {
const windowHandle = await app.browserWindow(page);
try {
const state = await windowHandle.evaluate((window) => {
window.showInactive();
return {
visible: window.isVisible(),
focused: window.isFocused(),
};
});
if (!state.visible || state.focused) {
throw new Error(
`inactive fixture mapping failed: visible=${state.visible} focused=${state.focused}`,
);
}
} finally {
await windowHandle.dispose();
}
}
// Freeze everything that would otherwise make two captures of the same build
// differ: in-flight transitions and animations, caret blink, and text that
// reflows when a webfont swaps in. Returns once the renderer has painted twice
// with all of that settled.
// Durations go to zero rather than the animation being removed. `animation:
// none` cancels the animation and leaves the element at its static value —
// for an entry animation that starts at `opacity: 0` and fills forwards, that
// static value is invisible, so freezing that way would hide the very surface
// a capture is meant to look at. Zero duration runs the animation to its final
// frame instantly instead.
const SETTLE_EXPR = `(async () => {
if (!document.getElementById('maka-contract-freeze')) {
const style = document.createElement('style');
style.id = 'maka-contract-freeze';
style.textContent = '*,*::before,*::after{transition-duration:0s!important;transition-delay:0s!important;animation-duration:0s!important;animation-delay:0s!important;caret-color:transparent!important;scroll-behavior:auto!important}';
document.head.appendChild(style);
}
try { await document.fonts.ready; } catch {}
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
return true;
})()`;
/**
* Boot one fixture window, hand a renderer evaluator to `fn`, then tear the
* window down.
*
* `fn` receives `{ evaluate, page }`. `evaluate(expression)` returns the
* expression's value, awaited if it is a promise. `page` is the Playwright
* page, for the cases that want a selector wait or an input event rather than
* an expression.
*
* @param {string} scenario MAKA_E2E_FIXTURE scenario name.
* @param {{
* theme?: 'light' | 'dark',
* platform?: 'darwin' | 'win32' | 'linux',
* mapWindowInactive?: boolean,
* readySelector?: string,
* readyTimeoutMs?: number,
* settleMs?: number,
* desktopDir?: string,
* }} [options]
* @param {(ctx: { evaluate: (expression: string) => Promise<any>, page: import('@playwright/test').Page }) => Promise<any>} fn
*/
export async function withFixtureWindow(scenario, options, fn) {
const {
theme = 'light',
platform,
// Hit testing needs a mapped window, not foreground focus. Keep the app
// accessory/Dock-hidden and map only this launch's BrowserWindow.
mapWindowInactive = false,
// Every route names the element that means "this surface has rendered".
// Without one the only alternative is a fixed sleep, which reports a
// half-mounted route as a clean one on a slow machine.
readySelector = '#root',
readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS,
settleMs = DEFAULT_SETTLE_MS,
// Which build to boot. The two-sided visual compare launches the base
// ref's build from a temporary worktree through the same launcher.
desktopDir = DESKTOP_DIR,
} = options ?? {};
// xvfb throttles a hidden window's compositor to ~1fps; only that isolated
// display gets a visible window. Local hit tests stay accessory/Dock-hidden.
const ciVisible = isCiLinuxDisplay();
const launchArgs = mapWindowInactive && !ciVisible ? inactiveWindowElectronArgs() : ['.'];
const userDataDir = await mkdtemp(join(tmpdir(), 'maka-fixture-'));
// Inside the throwaway userData dir so the same teardown removes it; there
// is no second path to leak.
const homeDir = join(userDataDir, 'home');
await mkdir(homeDir, { recursive: true });
/** @type {import('@playwright/test').ElectronApplication | undefined} */
let app;
const rendererLogs = [];
try {
app = await electron.launch({
args: launchArgs,
cwd: desktopDir,
env: buildFixtureEnv(userDataDir, homeDir, {
scenario,
theme,
platform,
showWindow: ciVisible,
timezone: CAPTURE_TIMEZONE,
}),
});
const page = await app.firstWindow();
if (mapWindowInactive && !ciVisible) await mapFixtureWindowInactive(app, page);
page.on('console', (message) => {
rendererLogs.push(`[console:${message.type()}] ${message.text()}`);
if (rendererLogs.length > 30) rendererLogs.shift();
});
page.on('pageerror', (error) => {
rendererLogs.push(`[pageerror] ${error.stack ?? error.message}`);
if (rendererLogs.length > 30) rendererLogs.shift();
});
try {
await page.waitForSelector(readySelector, { timeout: readyTimeoutMs });
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
const logs = rendererLogs.length > 0 ? `\nRenderer console:\n${rendererLogs.join('\n')}` : '';
throw new Error(
`${scenario}: readiness selector ${readySelector} never appeared${logs}\n${detail}`,
);
}
// Readiness says the surface mounted; this covers the paint after it.
if (settleMs > 0) await page.waitForTimeout(settleMs);
const evaluate = (expression) =>
withDeadline(page.evaluate(expression), EVALUATE_TIMEOUT_MS, `${scenario}: evaluate`);
await evaluate(SETTLE_EXPR);
return await fn({ evaluate, page });
} finally {
try {
// Bounded: a wedged Electron must cost seconds, not a hung run. The
// same close the E2E suite uses — grace, then SIGKILL the tree.
if (app) await closeElectronApplication(app, CLOSE_GRACE_MS);
} finally {
await rm(userDataDir, { recursive: true, force: true });
}
}
}
/**
* @param {Promise<any>} promise
* @param {number} timeoutMs
* @param {string} label
*/
async function withDeadline(promise, timeoutMs, label) {
/** @type {ReturnType<typeof setTimeout> | undefined} */
let timer;
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(
() => reject(new Error(`${label} did not settle within ${timeoutMs}ms`)),
timeoutMs,
);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}