import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import {
DESKTOP_READABILITY_VIEWPORT,
MIN_PROJECTED_NODE_TEXT_PX,
} from '../renderers/shared/desktop-readability.mjs';
export const VISUAL_CHECK_VIEWPORTS = Object.freeze([
DESKTOP_READABILITY_VIEWPORT,
Object.freeze({ width: 1600, height: 1000 }),
Object.freeze({ width: 1920, height: 1080 }),
Object.freeze({ width: 2048, height: 1320 }),
]);
const CAPTURE_VIEWPORTS = Object.freeze([
VISUAL_CHECK_VIEWPORTS[0],
VISUAL_CHECK_VIEWPORTS[VISUAL_CHECK_VIEWPORTS.length - 1],
]);
const THEMES = Object.freeze(['light', 'dark']);
const EXIT = Object.freeze({ pass: 0, fail: 1, skipped: 2 });
export const CHROME_NO_SANDBOX_ENV = 'ARCHIFY_CHROME_NO_SANDBOX';
function sha256(buffer) {
return createHash('sha256').update(buffer).digest('hex');
}
function htmlEscape(value) {
return String(value).replace(/[&<>"']/g, (char) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
})[char]);
}
function safeUnlink(file) {
try {
fs.rmSync(file, { force: true });
} catch {
// A stale optional sidecar must never make the delivered HTML mutable.
}
}
function writeAtomic(file, contents) {
const temporary = `${file}.tmp-${process.pid}`;
try {
fs.writeFileSync(temporary, contents, { flag: 'w' });
fs.renameSync(temporary, file);
} finally {
safeUnlink(temporary);
}
}
function screenshotKey(width, height, theme) {
return `${width}x${height}:${theme}`;
}
export function sidecarPaths(artifactPath) {
const artifact = path.resolve(artifactPath);
const stem = artifact.replace(/\.html?$/i, '');
const base = `${stem}.visual-check`;
const screenshots = CAPTURE_VIEWPORTS.flatMap(({ width, height }) => THEMES.map((theme) => ({
width,
height,
theme,
path: `${base}.${width}x${height}.${theme}.png`,
})));
return {
base,
receipt: `${base}.json`,
contactSheet: `${base}.html`,
screenshots,
};
}
function cleanupCaptureSidecars(paths) {
safeUnlink(paths.contactSheet);
for (const screenshot of paths.screenshots) safeUnlink(screenshot.path);
}
function executable(file, platform = process.platform) {
if (!file) return null;
try {
fs.accessSync(file, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
return path.resolve(file);
} catch {
return null;
}
}
function findOnPath(command, env, platform) {
const directories = String(env.PATH || '').split(path.delimiter).filter(Boolean);
const extensions = platform === 'win32'
? String(env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean)
: [''];
for (const directory of directories) {
for (const extension of extensions) {
const candidate = path.join(directory, `${command}${extension}`);
const resolved = executable(candidate, platform);
if (resolved) return resolved;
}
}
return null;
}
export function findChrome({ env = process.env, platform = process.platform } = {}) {
if (Object.prototype.hasOwnProperty.call(env, 'ARCHIFY_CHROME')) {
return executable(env.ARCHIFY_CHROME, platform);
}
const fixed = [];
const commands = [];
if (platform === 'darwin') {
fixed.push(
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
);
} else if (platform === 'win32') {
for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].filter(Boolean)) {
fixed.push(
path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),
path.join(root, 'Chromium', 'Application', 'chrome.exe'),
);
}
} else {
commands.push('google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser');
}
for (const candidate of fixed) {
const resolved = executable(candidate, platform);
if (resolved) return resolved;
}
for (const command of commands) {
const resolved = findOnPath(command, env, platform);
if (resolved) return resolved;
}
return null;
}
class PipeCdp {
constructor(child, { failureDetails = () => '' } = {}) {
this.child = child;
this.failureDetails = failureDetails;
this.nextId = 1;
this.buffer = '';
this.pending = new Map();
this.waiters = [];
this.writePipe = child.stdio[3];
this.readPipe = child.stdio[4];
this.readPipe.setEncoding('utf8');
this.readPipe.on('data', (chunk) => this.consume(chunk));
this.writePipe.on('error', (error) => this.failAll(this.failure('write pipe', error)));
this.readPipe.on('error', (error) => this.failAll(this.failure('read pipe', error)));
child.once('error', (error) => this.failAll(this.failure('process launch', error)));
child.once('close', (code, signal) => {
const ending = signal ? `signal ${signal}` : `exit code ${code}`;
this.failAll(this.failure('process exit', new Error(`Chrome closed with ${ending}`)));
});
}
failure(stage, error) {
const code = error?.code ? ` [${error.code}]` : '';
const details = this.failureDetails();
return new Error([
`Chrome DevTools ${stage} failed: ${error?.message || String(error)}${code}`,
details,
].filter(Boolean).join('\n'));
}
consume(chunk) {
this.buffer += chunk;
let boundary;
while ((boundary = this.buffer.indexOf('\0')) >= 0) {
const raw = this.buffer.slice(0, boundary);
this.buffer = this.buffer.slice(boundary + 1);
if (!raw) continue;
let message;
try {
message = JSON.parse(raw);
} catch (error) {
this.failAll(new Error(`Chrome DevTools returned invalid JSON: ${error.message}`));
continue;
}
if (message.id) {
const pending = this.pending.get(message.id);
if (!pending) continue;
clearTimeout(pending.timer);
this.pending.delete(message.id);
if (message.error) pending.reject(new Error(`${pending.method}: ${message.error.message}`));
else pending.resolve(message.result || {});
continue;
}
for (const waiter of [...this.waiters]) {
if (waiter.method !== message.method) continue;
if (waiter.sessionId && waiter.sessionId !== message.sessionId) continue;
clearTimeout(waiter.timer);
this.waiters.splice(this.waiters.indexOf(waiter), 1);
waiter.resolve(message.params || {});
}
}
}
send(method, params = {}, sessionId = undefined, timeoutMs = 15000) {
const id = this.nextId++;
const message = { id, method, params };
if (sessionId) message.sessionId = sessionId;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`${method}: timed out after ${timeoutMs}ms`));
}, timeoutMs);
this.pending.set(id, { method, resolve, reject, timer });
try {
this.writePipe.write(`${JSON.stringify(message)}\0`, (error) => {
if (error) this.failAll(this.failure('write pipe', error));
});
} catch (error) {
this.failAll(this.failure('write pipe', error));
}
});
}
waitFor(method, sessionId, timeoutMs = 15000) {
return new Promise((resolve, reject) => {
const waiter = { method, sessionId, resolve, reject, timer: null };
waiter.timer = setTimeout(() => {
this.waiters.splice(this.waiters.indexOf(waiter), 1);
reject(new Error(`${method}: event timed out after ${timeoutMs}ms`));
}, timeoutMs);
this.waiters.push(waiter);
});
}
failAll(error) {
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
for (const waiter of this.waiters) {
clearTimeout(waiter.timer);
waiter.reject(error);
}
this.pending.clear();
this.waiters = [];
}
}
export function chromeVisualBrowserArgs(profileRoot, {
env = process.env,
getuid = typeof process.getuid === 'function' ? () => process.getuid() : null,
} = {}) {
const args = [
'--headless=new',
'--remote-debugging-pipe',
'--disable-gpu',
'--hide-scrollbars',
'--disable-background-networking',
'--disable-component-update',
'--disable-default-apps',
'--disable-sync',
'--metrics-recording-only',
'--no-first-run',
'--no-default-browser-check',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--force-device-scale-factor=1',
`--user-data-dir=${profileRoot}`,
'about:blank',
];
const rootUser = typeof getuid === 'function' && getuid() === 0;
const sandboxOptOut = env?.[CHROME_NO_SANDBOX_ENV] === '1';
if (rootUser || sandboxOptOut) args.unshift('--no-sandbox');
return args;
}
async function evaluate(cdp, sessionId, expression, awaitPromise = false) {
const response = await cdp.send('Runtime.evaluate', {
expression,
awaitPromise,
returnByValue: true,
}, sessionId);
if (response.exceptionDetails) {
throw new Error(response.exceptionDetails.exception?.description
|| response.exceptionDetails.text
|| 'Runtime.evaluate failed');
}
return response.result?.value;
}
export class ChromeVisualBrowser {
constructor(chromePath, {
env = process.env,
getuid = typeof process.getuid === 'function' ? () => process.getuid() : null,
spawnImpl = spawn,
} = {}) {
this.profileRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-visual-check-profile-'));
this.stderr = '';
const args = chromeVisualBrowserArgs(this.profileRoot, { env, getuid });
this.child = spawnImpl(chromePath, args, { stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'] });
this.child.stderr.setEncoding('utf8');
this.child.stderr.on('data', (chunk) => {
this.stderr = `${this.stderr}${chunk}`.slice(-8000);
});
this.child.stderr.on('error', (error) => {
this.stderr = `${this.stderr}\nChrome stderr stream failed: ${error.message}`.trim().slice(-8000);
});
this.cdp = new PipeCdp(this.child, {
failureDetails: () => {
const exit = this.child.signalCode
? `signal ${this.child.signalCode}`
: this.child.exitCode == null ? 'still running' : `exit code ${this.child.exitCode}`;
const stderr = this.stderr.trim();
return [
`Chrome process: ${exit}.`,
stderr ? `Chrome stderr:\n${stderr}` : '',
].filter(Boolean).join('\n');
},
});
this.sessionPromise = this.attach();
}
async attach() {
const targets = await this.cdp.send('Target.getTargets');
let target = targets.targetInfos?.find((item) => item.type === 'page');
if (!target) {
const created = await this.cdp.send('Target.createTarget', { url: 'about:blank' });
target = { targetId: created.targetId };
}
const attached = await this.cdp.send('Target.attachToTarget', {
targetId: target.targetId,
flatten: true,
});
await this.cdp.send('Page.enable', {}, attached.sessionId);
await this.cdp.send('Runtime.enable', {}, attached.sessionId);
return attached.sessionId;
}
async inspect({ artifactPath, width, height, theme, screenshotPath }) {
const sessionId = await this.sessionPromise;
await this.cdp.send('Emulation.setDeviceMetricsOverride', {
width,
height,
deviceScaleFactor: 1,
mobile: false,
}, sessionId);
const url = new URL(pathToFileURL(artifactPath).href);
url.searchParams.set('theme', theme);
const loaded = this.cdp.waitFor('Page.loadEventFired', sessionId);
const navigation = await this.cdp.send('Page.navigate', { url: url.href }, sessionId);
if (navigation.errorText) throw new Error(`Chrome navigation failed: ${navigation.errorText}`);
await loaded;
await evaluate(this.cdp, sessionId, `(function () {
document.documentElement.setAttribute('data-motion', 'still');
var panel = document.querySelector('.diagram-container');
if (panel) panel.setAttribute('data-detail-level', 'read');
var fontsReady = document.fonts && document.fonts.ready
? document.fonts.ready.catch(function () {})
: Promise.resolve();
return fontsReady.then(function () {
if (window.Archify && Archify.readerLayout && typeof Archify.readerLayout.whenStable === 'function') {
return Archify.readerLayout.whenStable();
}
}).then(function () {
if (window.Archify && Archify.viewerChromeLayout && typeof Archify.viewerChromeLayout.whenStable === 'function') {
return Archify.viewerChromeLayout.whenStable();
}
}).then(function () {
if (window.Archify && Archify.readerLayout && typeof Archify.readerLayout.whenStable === 'function') {
return Archify.readerLayout.whenStable();
}
}).then(function () {
if (window.Archify && Archify.viewerChromeLayout && typeof Archify.viewerChromeLayout.whenStable === 'function') {
return Archify.viewerChromeLayout.whenStable();
}
return new Promise(function (resolve) {
requestAnimationFrame(function () { requestAnimationFrame(resolve); });
});
});
})()`, true);
const metrics = await evaluate(this.cdp, sessionId, `(function () {
var reader = document.querySelector('.container');
var diagram = document.querySelector('.diagram-container');
var svg = diagram && (
diagram.querySelector(':scope > svg') ||
diagram.querySelector(':scope > .diagram-stage > svg')
);
var stage = diagram && (diagram.querySelector(':scope > .diagram-stage') || svg);
var legend = svg && svg.querySelector('[data-legend]');
var navigationDock = diagram && diagram.querySelector('.diagram-nav');
var viewBox = svg && svg.viewBox && svg.viewBox.baseVal;
var diagramWidth = svg ? svg.getBoundingClientRect().width : 0;
var viewBoxWidth = viewBox ? viewBox.width : 0;
var scale = viewBoxWidth > 0 ? Math.min(1, diagramWidth / viewBoxWidth) : 0;
var minimum = null;
if (svg && scale > 0) {
Array.from(svg.querySelectorAll('text[data-node-label], text[data-boundary-label], text[data-detail="context"]')).forEach(function (text) {
var detail = text.hasAttribute('data-node-label')
? 'primary'
: text.hasAttribute('data-boundary-label') ? 'boundary' : 'context';
if (detail === 'context' && !text.closest('[data-node-id]')) return;
var sourceFontPx = parseFloat(text.getAttribute('font-size') || '');
if (!Number.isFinite(sourceFontPx)) return;
var projectedFontPx = sourceFontPx * scale;
if (!minimum || projectedFontPx < minimum.projectedFontPx) {
minimum = {
text: (text.textContent || '').trim(),
detail: detail,
sourceFontPx: sourceFontPx,
projectedFontPx: projectedFontPx
};
}
});
}
function intersectionArea(a, b) {
if (!a || !b || !a.width || !a.height || !b.width || !b.height) return 0;
var width = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left));
var height = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
return width * height;
}
var legendRect = legend ? legend.getBoundingClientRect() : null;
var stageRect = window.Archify && Archify.viewerChromeLayout
&& typeof Archify.viewerChromeLayout.stageRect === 'function'
? Archify.viewerChromeLayout.stageRect()
: (stage ? stage.getBoundingClientRect() : null);
var navigationDockRect = navigationDock ? navigationDock.getBoundingClientRect() : null;
var stageDockIntersectionArea = intersectionArea(stageRect, navigationDockRect);
var viewerChromeReceipt = window.Archify && Archify.viewerChromeLayout
&& typeof Archify.viewerChromeLayout.receipt === 'function'
? Archify.viewerChromeLayout.receipt()
: null;
return {
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
scrollWidth: Math.ceil(document.documentElement.scrollWidth),
scrollHeight: Math.ceil(document.documentElement.scrollHeight),
resolvedTheme: document.documentElement.getAttribute('data-theme') || '',
readerWidth: reader ? reader.getBoundingClientRect().width : 0,
diagramWidth: diagramWidth,
viewBoxWidth: viewBoxWidth,
minimumProjectedNodeTextPx: minimum ? minimum.projectedFontPx : null,
minimumProjectedNodeText: minimum ? minimum.text : null,
minimumProjectedNodeTextDetail: minimum ? minimum.detail : null,
hasLegend: Boolean(legendRect && legendRect.width && legendRect.height),
hasNavigationDock: Boolean(navigationDockRect && navigationDockRect.width && navigationDockRect.height),
legendDockIntersectionArea: stageDockIntersectionArea > 0
? intersectionArea(legendRect, navigationDockRect)
: 0,
dockStageIntersectionArea: stageDockIntersectionArea,
dockStageGap: stageRect && navigationDockRect ? navigationDockRect.top - stageRect.bottom : null,
viewerChromeRequiredGap: viewerChromeReceipt ? viewerChromeReceipt.gap : null,
viewerChromeReserve: viewerChromeReceipt ? viewerChromeReceipt.reserve : 0,
viewerChromeActive: viewerChromeReceipt ? viewerChromeReceipt.active : false
};
})()`);
if (!metrics || !Number.isFinite(metrics.scrollWidth) || !Number.isFinite(metrics.scrollHeight)) {
throw new Error('Chrome returned incomplete containment metrics.');
}
if (screenshotPath) {
const capture = await this.cdp.send('Page.captureScreenshot', {
format: 'png',
fromSurface: true,
captureBeyondViewport: false,
}, sessionId, 20000);
if (!capture.data) throw new Error('Chrome returned an empty screenshot.');
fs.writeFileSync(screenshotPath, Buffer.from(capture.data, 'base64'));
}
return metrics;
}
async close() {
this.cdp.failAll(new Error('visual-check finished'));
if (this.child.exitCode === null && this.child.signalCode === null) {
this.child.kill('SIGTERM');
await new Promise((resolve) => {
const timer = setTimeout(() => {
if (this.child.exitCode === null && this.child.signalCode === null) this.child.kill('SIGKILL');
resolve();
}, 1500);
this.child.once('exit', () => {
clearTimeout(timer);
resolve();
});
});
}
try {
fs.rmSync(this.profileRoot, { recursive: true, force: true });
} catch {
// Chrome may briefly retain profile files on Windows; evidence is done.
}
}
}
function observation({ width, height, theme, metrics }) {
const innerWidth = Number(metrics.innerWidth);
const innerHeight = Number(metrics.innerHeight);
const scrollWidth = Number(metrics.scrollWidth);
const scrollHeight = Number(metrics.scrollHeight);
const overflowX = scrollWidth > innerWidth;
const overflowY = scrollHeight > innerHeight;
const minimumProjectedNodeTextPx = metrics.minimumProjectedNodeTextPx == null
? null
: Number(metrics.minimumProjectedNodeTextPx);
const readabilityOk = minimumProjectedNodeTextPx == null
|| minimumProjectedNodeTextPx >= MIN_PROJECTED_NODE_TEXT_PX;
const legendDockIntersectionArea = Number(metrics.legendDockIntersectionArea) || 0;
const dockStageIntersectionArea = Number(metrics.dockStageIntersectionArea) || 0;
const dockStageGap = metrics.dockStageGap == null ? null : Number(metrics.dockStageGap);
const receiptDockStageGap = metrics.viewerChromeRequiredGap == null
? null
: Number(metrics.viewerChromeRequiredGap);
const requiredDockStageGap = Number.isFinite(receiptDockStageGap) ? receiptDockStageGap : 0;
const viewerChromeStageOk = !metrics.hasNavigationDock || (
Number.isFinite(dockStageGap)
&& dockStageIntersectionArea <= 0.5
&& dockStageGap >= requiredDockStageGap - 1
);
const viewerChromeOk = legendDockIntersectionArea <= 0.5 && viewerChromeStageOk;
return {
width,
height,
theme,
innerWidth,
innerHeight,
scrollWidth,
scrollHeight,
overflowX,
overflowY,
ok: !overflowX && !overflowY,
readerWidth: Number(metrics.readerWidth) || null,
diagramWidth: Number(metrics.diagramWidth) || null,
viewBoxWidth: Number(metrics.viewBoxWidth) || null,
minimumProjectedNodeTextPx,
minimumProjectedNodeText: metrics.minimumProjectedNodeText || null,
minimumProjectedNodeTextDetail: metrics.minimumProjectedNodeTextDetail || null,
minimumRequiredNodeTextPx: MIN_PROJECTED_NODE_TEXT_PX,
readabilityOk,
hasLegend: Boolean(metrics.hasLegend),
hasNavigationDock: Boolean(metrics.hasNavigationDock),
legendDockIntersectionArea,
dockStageIntersectionArea,
dockStageGap,
requiredDockStageGap,
viewerChromeStageOk,
viewerChromeReserve: Number(metrics.viewerChromeReserve) || 0,
viewerChromeActive: Boolean(metrics.viewerChromeActive),
viewerChromeOk,
resolvedTheme: metrics.resolvedTheme || theme,
};
}
function contactSheetHtml({ artifactPath, receipt, screenshots }) {
const cards = screenshots.map((entry) => `
${htmlEscape(path.basename(artifactPath))} · automated containment ${htmlEscape(receipt.containment.status)} · visual review pending