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';
import {
backupPublicRegularFileBinding,
captureAtomicOutput,
captureRegularFileBinding,
quarantineRemoveLinkedRegularFileAlias,
quarantineRemoveRegularFileBinding,
releaseRegularFileBinding,
removeEmptyDirectoryWithRetry,
verifyAtomicOutput,
verifyRegularFileBinding,
} from '../renderers/shared/atomic-output.mjs';
import {
sameEntry,
sameLocation,
sameParent,
sidecarNamespaceComponentKey,
} from '../renderers/shared/path-semantics.mjs';
import {
boundedSidecarStem,
sidecarStemFromComponent,
} from '../renderers/shared/sidecar-path.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 }),
]);
export const CAPTURE_VIEWPORTS = Object.freeze([
VISUAL_CHECK_VIEWPORTS[0],
VISUAL_CHECK_VIEWPORTS[VISUAL_CHECK_VIEWPORTS.length - 1],
]);
export 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';
const VISUAL_SIDECAR_SUFFIXES = Object.freeze([
'.visual-check.json',
'.visual-check.html',
...CAPTURE_VIEWPORTS.flatMap(({ width, height }) => THEMES.map(
(theme) => `.visual-check.${width}x${height}.${theme}.png`,
)),
]);
const visualEvidenceOwnerships = new WeakSet();
export const CHROME_STARTUP_TIMEOUT_MS = 90000;
function sha256(buffer) {
return createHash('sha256').update(buffer).digest('hex');
}
function htmlEscape(value) {
return String(value).replace(/[&<>"']/g, (char) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
})[char]);
}
function pathEntry(file) {
try {
return { exists: true, stat: fs.lstatSync(file) };
} catch (error) {
if (error?.code === 'ENOENT') return { exists: false };
return { exists: null, error };
}
}
function visualEvidencePaths(outputs) {
return outputs.capture === false ? [] : [outputs.contactSheet, ...outputs.screenshots.map((entry) => entry.path)];
}
function regularFileEvidence(file, { expectedLinks = 1 } = {}) {
const captured = captureRegularFileBinding(file, {
subject: 'evidence-file',
expectedLinks,
});
if (captured.status !== 'captured') {
const systemCode = captured.reason?.systemCode;
return {
ok: false,
reason: systemCode === 'ENOENT'
? { code: 'missing-evidence-file' }
: captured.reason?.code === 'evidence-file-not-regular-file'
? { code: 'evidence-path-not-regular-file' }
: {
code: 'evidence-file-unreadable',
...(systemCode ? { systemCode } : {}),
...(captured.reason?.code ? { bindingCode: captured.reason.code } : {}),
},
};
}
const released = releaseRegularFileBinding(captured.binding);
if (released.status !== 'released') {
return {
ok: false,
reason: {
code: 'evidence-file-unreadable',
...(released.reason?.systemCode ? { systemCode: released.reason.systemCode } : {}),
...(released.reason?.code ? { bindingCode: released.reason.code } : {}),
},
};
}
return {
ok: true,
evidence: {
file: path.basename(file),
sha256: captured.content.sha256,
bytes: captured.content.bytes,
},
identity: captured.identity,
};
}
function readRegularFileContents(file, { subject = 'file' } = {}) {
const captured = captureRegularFileBinding(file, {
subject,
expectedLinks: 1,
includeContent: true,
});
if (captured.status !== 'captured') return { ok: false, reason: captured.reason };
const released = releaseRegularFileBinding(captured.binding);
if (released.status !== 'released') return { ok: false, reason: released.reason };
return {
ok: true,
bytes: captured.content.buffer,
evidence: {
sha256: captured.content.sha256,
bytes: captured.content.bytes,
},
identity: captured.identity,
};
}
function regularFileCaptureError(label, captured) {
const reason = captured?.reason || { code: 'regular-file-capture-failed' };
const error = new Error(`${label} could not be read safely (${reason.code}).`);
if (reason.systemCode) error.code = reason.systemCode;
error.evidenceReason = reason;
return error;
}
function screenshotKey(width, height, theme) {
return `${width}x${height}:${theme}`;
}
function resolvedSidecarNamespace(artifact, component) {
const namespace = sidecarNamespaceComponentKey(path.dirname(artifact), component);
if (namespace.status === 'resolved') return namespace;
const error = new Error(
`Could not establish a stable sidecar namespace (${namespace.reason?.code || 'unknown'}).`,
);
error.code = 'ARCHIFY_SIDECAR_NAMESPACE_INDETERMINATE';
error.sidecarNamespaceReason = namespace.reason;
throw error;
}
function portableSidecarComponentKey(component) {
return component
.normalize('NFC')
.toLocaleUpperCase('en-US')
.toLocaleLowerCase('en-US')
.normalize('NFC');
}
function targetSidecarNamespace(directory, component) {
const namespace = sidecarNamespaceComponentKey(directory, component);
if (namespace.status === 'resolved') return namespace.componentKey;
if (namespace.reason?.code === 'sidecar-directory-missing') {
// A pure path calculation must not create --out-dir. Use the portable
// collision envelope until the directory exists and can be probed.
return portableSidecarComponentKey(component);
}
const error = new Error(
`Could not establish target sidecar semantics (${namespace.reason?.code || 'unknown'}).`,
);
error.code = 'ARCHIFY_SIDECAR_NAMESPACE_INDETERMINATE';
error.sidecarNamespaceReason = namespace.reason;
throw error;
}
function evidenceSidecarPaths(artifactPath, { outDir, command = 'visual-check', capture = true } = {}) {
let artifact = path.resolve(artifactPath);
try {
// Existing aliases, including Windows 8.3 spellings, must share the
// physical artifact's evidence namespace even when the stem is short.
artifact = fs.realpathSync.native(artifact);
} catch (error) {
if (!['ENOENT', 'ENOTDIR', 'ENAMETOOLONG'].includes(error?.code)) throw error;
// Missing artifacts still need deterministic failure sidecar paths.
}
const namespace = resolvedSidecarNamespace(artifact, path.basename(artifact));
artifact = path.join(namespace.directoryPath, path.basename(artifact));
const namespaceStem = sidecarStemFromComponent(namespace.componentKey);
const directory = outDir ? path.resolve(outDir) : path.dirname(artifact);
const targetComponentKey = outDir
? targetSidecarNamespace(directory, namespace.componentKey)
: namespace.componentKey;
const portableTargetKey = outDir
? portableSidecarComponentKey(namespace.componentKey)
: namespace.componentKey;
const targetAliasesSourceSpelling = targetComponentKey !== namespace.componentKey
|| portableTargetKey !== namespace.componentKey;
const stem = boundedSidecarStem(
namespaceStem.stem,
capture ? VISUAL_SIDECAR_SUFFIXES : ['.browser-check.json'],
targetAliasesSourceSpelling ? {
force: true,
hashDomain: `${command}-artifact\0${artifact}\0target-component\0${portableTargetKey}`,
} : namespaceStem.options,
);
const base = path.join(directory, `${stem}.${command}`);
const screenshots = (capture ? CAPTURE_VIEWPORTS : []).flatMap(({ width, height }) => THEMES.map((theme) => ({
width,
height,
theme,
path: `${base}.${width}x${height}.${theme}.png`,
})));
return {
base, command, capture,
receipt: `${base}.json`,
contactSheet: `${base}.html`,
screenshots,
};
}
export function sidecarPaths(artifactPath, options = {}) {
return evidenceSidecarPaths(artifactPath, options);
}
export function browserCheckSidecarPaths(artifactPath, options = {}) {
return evidenceSidecarPaths(artifactPath, { ...options, command: 'browser-check', capture: false });
}
function evidencePathConflict(artifactPath, outputs, file, reason) {
const target = file || outputs.receipt;
const error = `${outputs.command} will not replace the existing unowned evidence path "${target}".`;
return {
ok: false,
error,
diagnostic: failureDiagnostic({
code: 'viewer/evidence-path-conflict',
message: error,
subject: { artifact: path.resolve(artifactPath), evidencePath: target },
evidence: { reason },
supportedFixes: [
`move the conflicting file aside, or choose a separate --out-dir, then rerun ${outputs.command}`,
],
}),
};
}
function visualEvidenceOwnershipMatches(ownership, artifactPath, outputs) {
return visualEvidenceOwnerships.has(ownership)
&& ownership.active
&& sameEntry(ownership.artifact, artifactPath).status === 'match'
&& sameLocation(ownership.receipt, outputs.receipt).status === 'match';
}
function visualEvidenceTargets(outputs) {
return [
{ kind: 'receipt', path: outputs.receipt },
...(outputs.capture === false ? [] : [{ kind: 'contact-sheet', path: outputs.contactSheet }]),
...outputs.screenshots.map((entry, index) => ({
kind: 'screenshot',
index,
path: entry.path,
})),
];
}
function evidenceWriteFailure(artifactPath, message, errors) {
return {
ok: false,
error: message,
diagnostic: failureDiagnostic({
code: 'viewer/evidence-write',
message,
subject: { artifact: path.resolve(artifactPath) },
evidence: { errors },
supportedFixes: ['restore write access to the evidence directory and rerun visual-check'],
}),
};
}
function fileIdentity(stat) {
return {
device: stat.dev,
inode: stat.ino,
mode: Number(stat.mode & 0o777n),
links: stat.nlink,
};
}
function identityMatches(stat, identity, { directory = false } = {}) {
return stat.dev === identity.device
&& stat.ino === identity.inode
&& Number(stat.mode & 0o777n) === identity.mode
&& (directory ? stat.isDirectory() : stat.isFile())
&& !stat.isSymbolicLink();
}
function evidenceIdentityMatches(actual, expected, expectedLinks = expected?.links ?? 1) {
return actual?.device === expected?.device
&& actual?.inode === expected?.inode
&& actual?.mode === expected?.mode
&& BigInt(actual?.links ?? 0) === BigInt(expectedLinks);
}
function stagedOutputs(outputs, stagingDirectory) {
return {
receipt: path.join(stagingDirectory, 'receipt.json'),
contactSheet: path.join(stagingDirectory, 'contact-sheet.html'),
screenshots: outputs.screenshots.map((entry, index) => ({
...entry,
path: path.join(stagingDirectory, `capture-${index}.png`),
})),
};
}
function stagedPathFor(ownership, finalPath) {
return ownership.stagingPaths.get(finalPath);
}
function registerStagedEvidence(ownership, finalPath) {
const stagedPath = stagedPathFor(ownership, finalPath);
let stat;
try {
stat = fs.lstatSync(stagedPath, { bigint: true });
} catch (error) {
throw new Error(`visual-check could not inspect staged evidence "${stagedPath}": ${error.message}`);
}
if (!stat.isFile() || stat.isSymbolicLink() || stat.ino === 0n) {
throw new Error(`visual-check staged evidence is not a uniquely owned regular file: "${stagedPath}".`);
}
if (stat.nlink !== 1n) {
ownership.stagedEntries.set(finalPath, {
path: stagedPath,
identity: fileIdentity(stat),
});
throw new Error(`visual-check staged evidence has an external hard link: "${stagedPath}".`);
}
const targetMode = ownership.targets.get(finalPath)?.mode;
if (targetMode !== null && targetMode !== undefined
&& Number(stat.mode & 0o777n) !== targetMode) {
fs.chmodSync(stagedPath, targetMode);
const adjusted = fs.lstatSync(stagedPath, { bigint: true });
if (adjusted.dev !== stat.dev || adjusted.ino !== stat.ino || !adjusted.isFile()) {
throw new Error(`visual-check staged evidence changed while preserving its mode: "${stagedPath}".`);
}
stat = adjusted;
}
const entry = {
path: stagedPath,
identity: fileIdentity(stat),
};
ownership.stagedEntries.set(finalPath, entry);
const evidence = regularFileEvidence(stagedPath);
if (!evidence.ok || !evidenceIdentityMatches(evidence.identity, entry.identity)) {
throw new Error(`visual-check could not bind staged evidence "${stagedPath}" to its contents.`);
}
entry.evidence = {
sha256: evidence.evidence.sha256,
bytes: evidence.evidence.bytes,
};
}
function writeStagedEvidence(ownership, finalPath, contents) {
const stagedPath = stagedPathFor(ownership, finalPath);
const target = ownership.targets.get(finalPath);
const mode = target?.mode ?? 0o666;
let descriptor;
try {
descriptor = fs.openSync(stagedPath, 'wx', mode);
let stat = fs.fstatSync(descriptor, { bigint: true });
if (!stat.isFile() || stat.ino === 0n) {
throw new Error('the newly created staging entry has no unique regular-file identity');
}
if (target?.mode !== null && target?.mode !== undefined
&& Number(stat.mode & 0o777n) !== target.mode) {
fs.fchmodSync(descriptor, target.mode);
const adjusted = fs.fstatSync(descriptor, { bigint: true });
if (adjusted.dev !== stat.dev || adjusted.ino !== stat.ino || !adjusted.isFile()
|| Number(adjusted.mode & 0o777n) !== target.mode) {
throw new Error('the staged evidence mode could not be preserved');
}
stat = adjusted;
}
const entry = {
path: stagedPath,
identity: fileIdentity(stat),
};
ownership.stagedEntries.set(finalPath, entry);
if (stat.nlink !== 1n) {
throw new Error('the newly created staging entry has an external hard link');
}
fs.writeFileSync(descriptor, contents);
const bytes = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
entry.evidence = { sha256: sha256(bytes), bytes: bytes.byteLength };
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
function unlinkOwnedEntry(file, identity, {
evidence,
expectedLinks,
} = {}) {
const verifiedLinks = Number(expectedLinks ?? identity.links ?? 1);
let captured = captureRegularFileBinding(file, {
subject: 'visual-cleanup',
expectedIdentity: identity,
expectedMode: identity.mode,
expectedLinks: verifiedLinks,
...(evidence ? {
expectedSha256: evidence.sha256,
expectedBytes: evidence.bytes,
} : {}),
});
const reportedLinks = Number(captured.reason?.links);
if (captured.status === 'unsupported'
&& captured.reason?.code === 'visual-cleanup-hardlinked'
&& Number.isSafeInteger(reportedLinks)
&& reportedLinks > verifiedLinks) {
captured = captureRegularFileBinding(file, {
subject: 'visual-cleanup',
expectedIdentity: identity,
expectedMode: identity.mode,
expectedLinks: reportedLinks,
...(evidence ? {
expectedSha256: evidence.sha256,
expectedBytes: evidence.bytes,
} : {}),
});
}
if (captured.status !== 'captured') {
if (captured.reason?.systemCode === 'ENOENT') return { status: 'absent' };
return {
status: captured.status === 'different' || captured.status === 'unsupported'
? 'different'
: 'unknown',
reason: captured.reason?.code?.includes('content-changed') ? 'content' : 'identity',
error: new Error(captured.reason?.code || 'visual cleanup binding failed'),
bindingState: captured.reason,
};
}
let removal;
let released;
try {
removal = quarantineRemoveRegularFileBinding(captured.binding, file, {
subject: 'visual-cleanup',
expectedLinks: captured.identity?.links ? Number(captured.identity.links) : verifiedLinks,
});
} finally {
released = releaseRegularFileBinding(captured.binding);
}
if (released.status !== 'released') {
return {
status: 'unknown',
reason: 'identity',
error: new Error(released.reason?.code || 'visual cleanup binding release failed'),
bindingState: released.reason,
};
}
if (removal.status === 'removed' || removal.status === 'absent') return removal;
return {
status: removal.status === 'preserved' || removal.status === 'different'
|| removal.status === 'unsupported' ? 'different' : 'unknown',
reason: 'identity',
error: new Error(removal.reason?.code || 'visual cleanup quarantine failed'),
bindingState: removal.reason,
...(removal.recoveryDirectory ? {
recoveryDirectory: removal.recoveryDirectory,
recoveryFile: removal.recoveryFile,
} : {}),
};
}
function backupOwnedPublishedEntry(file, backupPath, identity, evidence) {
const captured = captureRegularFileBinding(file, {
subject: 'previous-evidence',
expectedIdentity: identity,
expectedMode: identity.mode,
expectedSha256: evidence.sha256,
expectedBytes: evidence.bytes,
expectedLinks: 1,
});
if (captured.status !== 'captured') {
return { ...captured, backupCreated: false, backupVerified: false };
}
let backup;
let released;
try {
backup = backupPublicRegularFileBinding(captured.binding, file, backupPath, {
subject: 'previous-evidence',
});
} finally {
released = releaseRegularFileBinding(captured.binding);
}
if (released.status !== 'released') {
return {
status: 'recovery-required',
reason: {
code: 'previous-evidence-binding-release-failed',
bindingState: released.reason,
},
recoveryDirectory: backup?.recoveryDirectory || path.dirname(backupPath),
recoveryFile: backup?.recoveryFile || (backup?.backupCreated ? backupPath : undefined),
target: backup?.target || file,
backupCreated: backup?.backupCreated === true,
backupVerified: backup?.backupVerified === true,
};
}
return backup;
}
function publishedRemovalError(file, result) {
const code = result.reason?.code;
let reason;
if (result.status === 'preserved') {
reason = 'published entry changed during retirement; its successor was restored and preserved';
} else if (result.status === 'recovery-required') {
reason = 'published entry changed during retirement; recovery material was preserved';
} else if (code?.includes('content-changed')) {
reason = 'published entry content changed; it was preserved';
} else if (code?.includes('mode-changed')) {
reason = 'published entry mode changed; it was preserved';
} else if (result.status === 'different' || result.status === 'unsupported') {
reason = 'published entry identity changed; it was preserved';
} else {
reason = code || 'published entry could not be safely retired; it was preserved';
}
return {
file,
reason,
...(code ? { code } : {}),
...(result.recoveryDirectory ? { recoveryDirectory: result.recoveryDirectory } : {}),
...(result.recoveryFile ? { recoveryFile: result.recoveryFile } : {}),
...(result.target ? { target: result.target } : {}),
};
}
function cleanupStagedEvidence(ownership, { removeDirectory = false } = {}) {
const errors = [];
for (const [finalPath, entry] of [...ownership.stagedEntries]) {
const result = unlinkOwnedEntry(entry.path, entry.identity, { evidence: entry.evidence });
if (result.status === 'removed' || result.status === 'absent') {
ownership.stagedEntries.delete(finalPath);
} else {
errors.push({
file: entry.path,
reason: result.status === 'different' && result.reason === 'content'
? 'staging entry content changed; it was preserved'
: result.status === 'different'
? 'staging entry identity changed; it was preserved'
: result.error?.message,
});
}
}
const directories = [
ownership.inspection,
...(removeDirectory ? [{
directory: ownership.stagingDirectory,
identity: ownership.stagingIdentity,
}] : []),
].filter(Boolean);
for (const { directory, identity } of directories) {
try {
const stat = fs.lstatSync(directory, { bigint: true });
if (!identity || !identityMatches(stat, identity, { directory: true })) {
errors.push({
file: directory,
recoveryDirectory: directory,
reason: 'staging directory identity changed; it was preserved',
});
} else {
removeEmptyDirectoryWithRetry(directory);
}
} catch (error) {
if (error?.code !== 'ENOENT') {
errors.push({ file: directory, recoveryDirectory: directory, reason: error.message });
}
}
}
return errors;
}
function verifyVisualEvidenceTargets(artifactPath, outputs, ownership) {
for (const target of visualEvidenceTargets(outputs)) {
const captured = ownership.targets.get(target.path);
const verification = captured ? verifyAtomicOutput(captured.snapshot) : {
status: 'unknown',
reason: { code: 'evidence-snapshot-missing' },
};
if (verification.status !== 'match') {
return evidencePathConflict(artifactPath, outputs, target.path, verification.reason);
}
}
return { ok: true };
}
function beginVisualEvidenceWrite(artifactPath, artifactBytes, outputs) {
const targets = visualEvidenceTargets(outputs);
const candidates = targets.map((target) => target.path);
const previousEvidence = new Map();
try {
fs.mkdirSync(path.dirname(outputs.receipt), { recursive: true });
} catch (error) {
return evidenceWriteFailure(
artifactPath,
'visual-check could not create its evidence directory.',
[{ file: path.dirname(outputs.receipt), reason: error.message }],
);
}
const capturedTargets = new Map();
for (const target of targets) {
const captured = captureAtomicOutput(target.path, {
requestedEntryPolicy: 'regular-or-absent',
});
if (captured.status !== 'captured') {
return evidencePathConflict(artifactPath, outputs, target.path, captured.reason);
}
capturedTargets.set(target.path, captured);
}
const parent = capturedTargets.get(outputs.receipt).snapshot.slot;
for (const target of targets.slice(1)) {
const slot = capturedTargets.get(target.path).snapshot.slot;
if (slot.parentDevice !== parent.parentDevice || slot.parentInode !== parent.parentInode) {
return evidencePathConflict(artifactPath, outputs, target.path, {
code: 'evidence-parent-mismatch',
});
}
}
const existing = candidates.filter((file) => (
capturedTargets.get(file).snapshot.requestedEntry.kind === 'existing'
));
if (existing.length) {
const receiptEntry = capturedTargets.get(outputs.receipt).snapshot.requestedEntry;
if (receiptEntry.kind !== 'existing') {
return evidencePathConflict(artifactPath, outputs, existing[0], {
code: 'ownership-receipt-missing',
});
}
const previousReceipt = readRegularFileContents(outputs.receipt, {
subject: 'ownership-receipt',
});
const capturedReceipt = capturedTargets.get(outputs.receipt).snapshot.target;
if (!previousReceipt.ok || !evidenceIdentityMatches(previousReceipt.identity, {
device: capturedReceipt.device,
inode: capturedReceipt.inode,
mode: capturedReceipt.mode,
links: 1,
})) {
return evidencePathConflict(artifactPath, outputs, outputs.receipt, {
code: 'ownership-receipt-invalid',
...(previousReceipt.reason?.code ? { bindingCode: previousReceipt.reason.code } : {}),
...(previousReceipt.reason?.systemCode
? { systemCode: previousReceipt.reason.systemCode } : {}),
});
}
const previousBytes = previousReceipt.bytes;
let previous;
try {
previous = JSON.parse(previousBytes.toString('utf8'));
} catch (error) {
return evidencePathConflict(artifactPath, outputs, outputs.receipt, {
code: 'ownership-receipt-invalid',
...(error?.code ? { systemCode: error.code } : {}),
});
}
previousEvidence.set(outputs.receipt, {
...previousReceipt.evidence,
});
if (previous?.schemaVersion !== 1 || previous?.command !== outputs.command) {
return evidencePathConflict(artifactPath, outputs, outputs.receipt, {
code: 'ownership-receipt-schema-mismatch',
});
}
const recordedReceiptName = previous?.sidecars?.receipt;
const recordedContactName = previous?.sidecars?.contactSheet;
const recordedDirectory = previous?.sidecars?.directory || path.dirname(previous?.artifact?.path || '');
const receiptNameSafe = typeof recordedReceiptName === 'string'
// path-contract-allow: portable-logical-path -- receipt filenames are serialized logical basenames.
&& path.basename(recordedReceiptName) === recordedReceiptName;
const contactNameSafe = outputs.capture === false || typeof recordedContactName === 'string'
// path-contract-allow: portable-logical-path -- contact-sheet filenames are serialized logical basenames.
&& path.basename(recordedContactName) === recordedContactName;
if (!path.isAbsolute(recordedDirectory) || !receiptNameSafe || !contactNameSafe
|| sameLocation(path.join(recordedDirectory, recordedReceiptName), outputs.receipt).status !== 'match'
|| (outputs.capture !== false && sameLocation(path.join(recordedDirectory, recordedContactName), outputs.contactSheet).status !== 'match')) {
return evidencePathConflict(artifactPath, outputs, outputs.receipt, {
code: 'ownership-sidecar-path-mismatch',
});
}
if (typeof previous?.artifact?.path !== 'string'
|| !path.isAbsolute(previous.artifact.path)
|| sameEntry(previous.artifact.path, artifactPath).status !== 'match'
|| previous.artifact.sha256 !== sha256(artifactBytes)
|| previous.artifact.bytes !== artifactBytes.byteLength) {
return evidencePathConflict(artifactPath, outputs, outputs.receipt, {
code: 'ownership-artifact-mismatch',
});
}
const existingEvidence = visualEvidencePaths(outputs).filter((file) => (
capturedTargets.get(file).snapshot.requestedEntry.kind === 'existing'
));
const recordedEvidence = previous?.sidecars?.files;
if (existingEvidence.length && !Array.isArray(recordedEvidence)) {
return evidencePathConflict(artifactPath, outputs, existingEvidence[0], {
code: 'ownership-evidence-digests-missing',
});
}
if (Array.isArray(recordedEvidence)) {
const allowed = new Map(visualEvidencePaths(outputs).map((file) => [path.basename(file), file]));
const recorded = new Map();
for (const item of recordedEvidence) {
if (!item || typeof item.file !== 'string' || recorded.has(item.file) || !allowed.has(item.file)) {
return evidencePathConflict(artifactPath, outputs, outputs.receipt, {
code: 'ownership-evidence-list-invalid',
});
}
recorded.set(item.file, item);
}
if (recorded.size !== existingEvidence.length
|| existingEvidence.some((file) => !recorded.has(path.basename(file)))) {
return evidencePathConflict(artifactPath, outputs, outputs.receipt, {
code: 'ownership-evidence-list-mismatch',
});
}
for (const file of existingEvidence) {
const actual = regularFileEvidence(file);
const expected = recorded.get(path.basename(file));
if (!actual.ok
|| actual.evidence.sha256 !== expected.sha256
|| actual.evidence.bytes !== expected.bytes) {
return evidencePathConflict(artifactPath, outputs, file, {
code: actual.ok ? 'ownership-evidence-digest-mismatch' : actual.reason.code,
...(!actual.ok && actual.reason.systemCode
? { systemCode: actual.reason.systemCode } : {}),
});
}
previousEvidence.set(file, {
sha256: actual.evidence.sha256,
bytes: actual.evidence.bytes,
});
}
}
}
for (const target of targets) {
const verification = verifyAtomicOutput(capturedTargets.get(target.path).snapshot);
if (verification.status !== 'match') {
return evidencePathConflict(artifactPath, outputs, target.path, verification.reason);
}
}
let stagingDirectory;
let stagingStat;
try {
stagingDirectory = fs.mkdtempSync(path.toNamespacedPath(
path.join(parent.parentPath, '.archify-visual-check-'),
));
stagingStat = fs.lstatSync(stagingDirectory, { bigint: true });
if (!stagingStat.isDirectory() || stagingStat.isSymbolicLink() || stagingStat.ino === 0n) {
throw new Error('the newly created staging directory has no stable directory identity');
}
} catch (error) {
return evidenceWriteFailure(
artifactPath,
'visual-check could not create a private evidence staging directory.',
[{ file: parent.parentPath, reason: error.message }],
);
}
const staged = stagedOutputs(outputs, stagingDirectory);
const ownership = {
active: true,
artifact: path.resolve(artifactPath),
receipt: outputs.receipt,
targets: capturedTargets,
stagingDirectory,
stagingIdentity: fileIdentity(stagingStat),
stagedOutputs: staged,
stagingPaths: new Map([
[outputs.receipt, staged.receipt],
[outputs.contactSheet, staged.contactSheet],
...outputs.screenshots.map((entry, index) => [entry.path, staged.screenshots[index].path]),
]),
stagedEntries: new Map(),
previousEvidence,
};
visualEvidenceOwnerships.add(ownership);
return { ok: true, ownership };
}
function recordVisualEvidenceFiles(receipt, outputs, ownership) {
const files = [];
for (const file of visualEvidencePaths(outputs)) {
const staged = ownership.stagedEntries.get(file);
if (!staged || !currentEvidenceMatches(staged.path, staged.identity, staged.evidence)) {
const error = new Error(`visual-check could not bind evidence file "${file}" to its receipt.`);
error.evidenceReason = { code: 'staged-evidence-content-mismatch' };
throw error;
}
files.push({ ...staged.evidence, file: path.basename(file) });
}
receipt.sidecars.files = files;
}
function currentEvidenceMatches(file, identity, evidence, {
expectedLinks = identity.links ?? 1,
} = {}) {
if (!evidence) return false;
const current = regularFileEvidence(file, { expectedLinks: Number(expectedLinks) });
return current.ok
&& evidenceIdentityMatches(current.identity, identity, expectedLinks)
&& current.evidence.sha256 === evidence.sha256
&& current.evidence.bytes === evidence.bytes;
}
function restoreOwnedBackup(backup, errors) {
const expectedLinks = backup.identity.links ?? 1n;
const expectedLinkCount = Number(expectedLinks);
let backupStat;
try {
backupStat = fs.lstatSync(backup.path, { bigint: true });
} catch {
errors.push({ file: backup.path, reason: 'backup identity changed; it was preserved' });
return;
}
if (!identityMatches(backupStat, backup.identity) || backupStat.nlink !== expectedLinks) {
errors.push({ file: backup.path, reason: 'backup identity changed; it was preserved' });
return;
}
let captured = backup.evidence && Number.isSafeInteger(expectedLinkCount)
? captureRegularFileBinding(backup.path, {
subject: 'restore-backup',
expectedIdentity: backup.identity,
expectedMode: backup.identity.mode,
expectedSha256: backup.evidence.sha256,
expectedBytes: backup.evidence.bytes,
expectedLinks: expectedLinkCount,
})
: { status: 'unknown', reason: { code: 'restore-backup-evidence-unavailable' } };
let contentChanged = false;
if (captured.status !== 'captured'
&& captured.reason?.code === 'restore-backup-content-changed') {
captured = captureRegularFileBinding(backup.path, {
subject: 'restore-backup-claimant',
expectedIdentity: backup.identity,
expectedMode: backup.identity.mode,
expectedLinks: expectedLinkCount,
});
contentChanged = captured.status === 'captured';
}
if (captured.status !== 'captured') {
errors.push({
file: backup.path,
reason: 'backup bytes, mode, or identity changed; it was preserved',
...(captured.reason?.code ? { code: captured.reason.code } : {}),
});
return;
}
let released;
try {
const finalEntry = pathEntry(backup.finalPath);
if (finalEntry.exists === true) {
errors.push({
file: backup.path,
reason: 'final path is occupied; the owned backup was preserved',
});
return;
}
if (finalEntry.exists === null) {
errors.push({ file: backup.finalPath, reason: finalEntry.error?.message });
return;
}
try {
fs.linkSync(backup.path, backup.finalPath);
const linkedCount = expectedLinkCount + 1;
const staged = verifyRegularFileBinding(captured.binding, {
filePath: backup.path,
expectedLinks: linkedCount,
});
const published = verifyRegularFileBinding(captured.binding, {
filePath: backup.finalPath,
expectedLinks: linkedCount,
});
if (staged.status !== 'match' || published.status !== 'match') {
const removed = quarantineRemoveRegularFileBinding(
captured.binding,
backup.finalPath,
{ subject: 'restored-evidence', expectedLinks: linkedCount },
);
errors.push({
file: backup.path,
reason: removed.status === 'removed'
? 'restored entry identity could not be verified; the owned backup was preserved'
: removed.status === 'preserved'
? 'restored entry changed during cleanup; its successor and the owned backup were preserved'
: 'restored entry changed before rollback; both entries were preserved',
...(removed.reason?.code ? { code: removed.reason.code } : {}),
...(removed.recoveryDirectory ? { recoveryDirectory: removed.recoveryDirectory } : {}),
...(removed.recoveryFile ? { recoveryFile: removed.recoveryFile } : {}),
});
return;
}
const removed = unlinkOwnedEntry(backup.path, backup.identity, {
evidence: captured.content,
expectedLinks: linkedCount,
});
const finalMatches = verifyRegularFileBinding(captured.binding, {
filePath: backup.finalPath,
expectedLinks: expectedLinkCount,
}).status === 'match';
if (removed.status !== 'removed' || !finalMatches) {
errors.push({ file: backup.path, reason: 'restored entry link count could not be finalized' });
} else if (contentChanged) {
errors.push({
file: backup.finalPath,
reason: 'the backup content changed externally; its current bytes were restored and preserved',
});
}
} catch (error) {
errors.push({
file: backup.path,
reason: error?.code === 'EEXIST'
? 'final path was claimed during restore; the owned backup was preserved'
: error.message,
});
}
} finally {
released = releaseRegularFileBinding(captured.binding);
if (released.status !== 'released') {
errors.push({
file: backup.path,
reason: 'restored backup binding could not be released cleanly',
...(released.reason?.code ? { code: released.reason.code } : {}),
});
}
}
}
function commitVisualEvidence(artifactPath, outputs, ownership, desiredPaths) {
const verification = verifyVisualEvidenceTargets(artifactPath, outputs, ownership);
if (!verification.ok) {
const cleanupErrors = cleanupStagedEvidence(ownership, { removeDirectory: true });
if (cleanupErrors.length) verification.diagnostic.evidence.rollbackErrors = cleanupErrors;
ownership.active = false;
return verification;
}
const desired = new Set(desiredPaths);
const stagedBindings = new Map();
const releaseStagedBindings = () => {
const failures = [];
for (const [finalPath, binding] of stagedBindings) {
const released = releaseRegularFileBinding(binding);
if (released.status !== 'released') {
failures.push({
file: finalPath,
reason: released.reason?.code,
bindingState: released.reason,
...(released.reason?.recoveryDirectory
? { recoveryDirectory: released.reason.recoveryDirectory } : {}),
});
}
}
stagedBindings.clear();
return failures;
};
for (const finalPath of desired) {
const staged = ownership.stagedEntries.get(finalPath);
const captured = staged ? captureRegularFileBinding(staged.path, {
subject: 'staged-evidence',
expectedIdentity: staged.identity,
expectedMode: staged.identity.mode,
expectedSha256: staged.evidence?.sha256,
expectedBytes: staged.evidence?.bytes,
expectedLinks: 1,
}) : null;
if (!staged || captured?.status !== 'captured') {
const failure = evidenceWriteFailure(
artifactPath,
'visual-check could not verify its staged evidence before publication.',
[{
file: staged?.path || stagedPathFor(ownership, finalPath),
reason: captured?.reason?.code || 'staged entry missing or changed',
}],
);
failure.diagnostic.evidence.errors.push(
...releaseStagedBindings(),
...cleanupStagedEvidence(ownership, { removeDirectory: true }),
);
ownership.active = false;
return failure;
}
stagedBindings.set(finalPath, captured.binding);
}
const backups = [];
const published = [];
let conflict = null;
try {
for (const [index, target] of visualEvidenceTargets(outputs).entries()) {
const captured = ownership.targets.get(target.path);
if (captured.snapshot.target.kind !== 'file') continue;
const backupPath = path.join(ownership.stagingDirectory, `previous-${index}`);
const identity = {
device: captured.snapshot.target.device,
inode: captured.snapshot.target.inode,
mode: captured.snapshot.target.mode,
links: 1n,
};
const evidence = ownership.previousEvidence.get(target.path);
if (!evidence) {
conflict = evidencePathConflict(artifactPath, outputs, target.path, {
code: 'target-evidence-missing-during-commit',
});
throw new Error(conflict.error);
}
const backup = {
targetPath: target.path,
finalPath: captured.commitPath,
path: backupPath,
identity,
evidence,
};
const backupResult = backupOwnedPublishedEntry(
captured.commitPath,
backupPath,
identity,
evidence,
);
if (backupResult.backupCreated) backups.push(backup);
if (backupResult.status !== 'backed-up'
|| !backupResult.backupCreated
|| !backupResult.backupVerified) {
conflict = evidencePathConflict(artifactPath, outputs, target.path, {
code: backupResult.reason?.code || 'target-backup-failed-during-commit',
backupCreated: backupResult.backupCreated === true,
backupVerified: backupResult.backupVerified === true,
...(backupResult.recoveryDirectory
? { recoveryDirectory: backupResult.recoveryDirectory } : {}),
...(backupResult.recoveryFile ? { recoveryFile: backupResult.recoveryFile } : {}),
...(backupResult.target ? { target: backupResult.target } : {}),
...(backupResult.reason ? { bindingState: backupResult.reason } : {}),
});
throw new Error(conflict.error);
}
}
const publicationOrder = visualEvidenceTargets(outputs)
.filter((target) => desired.has(target.path))
.sort((left, right) => Number(left.kind === 'receipt') - Number(right.kind === 'receipt'));
for (const target of publicationOrder) {
const captured = ownership.targets.get(target.path);
const staged = ownership.stagedEntries.get(target.path);
const stagedBinding = stagedBindings.get(target.path);
const beforeLink = verifyRegularFileBinding(stagedBinding, {
filePath: staged.path,
expectedLinks: 1,
});
if (beforeLink.status !== 'match') {
conflict = evidencePathConflict(artifactPath, outputs, target.path, {
code: beforeLink.reason?.code?.includes('content-changed')
? 'staged-evidence-content-mismatch'
: beforeLink.reason?.code || 'staged-evidence-content-mismatch',
});
throw new Error(conflict.error);
}
try {
fs.linkSync(staged.path, captured.commitPath);
} catch (error) {
if (error?.code === 'EEXIST') {
conflict = evidencePathConflict(artifactPath, outputs, target.path, {
code: 'target-created-during-commit',
});
}
throw error;
}
published.push({
finalPath: captured.commitPath,
stagedPath: staged.path,
identity: staged.identity,
evidence: staged.evidence,
binding: stagedBinding,
});
const stagedLinked = verifyRegularFileBinding(stagedBinding, {
filePath: staged.path,
expectedLinks: 2,
});
const publishedLinked = verifyRegularFileBinding(stagedBinding, {
filePath: captured.commitPath,
expectedLinks: 2,
});
if (stagedLinked.status !== 'match' || publishedLinked.status !== 'match') {
conflict = evidencePathConflict(artifactPath, outputs, target.path, {
code: 'published-entry-identity-mismatch',
stagedState: stagedLinked.reason,
publishedState: publishedLinked.reason,
});
throw new Error(conflict.error);
}
const removed = quarantineRemoveRegularFileBinding(stagedBinding, staged.path, {
subject: 'staged-evidence',
expectedLinks: 2,
});
if (removed.status !== 'removed' && removed.status !== 'preserved') {
conflict = evidencePathConflict(artifactPath, outputs, target.path, {
code: removed.reason?.code || 'published-entry-identity-mismatch',
});
throw new Error(`visual-check could not retire staged evidence "${staged.path}".`);
}
if (removed.status === 'removed') ownership.stagedEntries.delete(target.path);
const finalized = verifyRegularFileBinding(stagedBinding, {
filePath: captured.commitPath,
expectedLinks: 1,
});
if (finalized.status !== 'match') {
conflict = evidencePathConflict(artifactPath, outputs, target.path, {
code: 'published-entry-link-count-mismatch',
bindingState: finalized.reason,
});
throw new Error(conflict.error);
}
}
// Receipt publication is the linearization point for the evidence set.
// Recheck every earlier member afterwards so a writer that replaces a PNG
// or the contact sheet while later members publish cannot yield success.
for (const entry of published) {
const finalState = verifyRegularFileBinding(entry.binding, {
filePath: entry.finalPath,
expectedLinks: 1,
});
if (finalState.status !== 'match') {
conflict = evidencePathConflict(artifactPath, outputs, entry.finalPath, {
code: 'published-set-identity-mismatch',
bindingState: finalState.reason,
});
throw new Error(conflict.error);
}
}
for (const backup of backups) {
if (!currentEvidenceMatches(
backup.path,
backup.identity,
ownership.previousEvidence.get(backup.targetPath),
)) {
conflict = evidencePathConflict(artifactPath, outputs, backup.targetPath, {
code: 'backup-content-changed-before-cleanup',
});
throw new Error(conflict.error);
}
}
// SMB keeps removed staging names pending until every bound handle closes.
// Publication and backup verification are complete before directory cleanup.
const cleanupErrors = releaseStagedBindings();
for (const backup of backups) {
const removed = unlinkOwnedEntry(backup.path, backup.identity, {
evidence: backup.evidence,
});
if (removed.status !== 'removed' && removed.status !== 'absent') {
cleanupErrors.push({
file: backup.path,
reason: removed.status === 'different' && removed.reason === 'content'
? 'backup content changed; it was preserved'
: removed.status === 'different'
? 'backup identity changed; it was preserved'
: removed.error?.message,
});
}
}
cleanupErrors.push(...cleanupStagedEvidence(ownership, { removeDirectory: true }));
ownership.active = false;
let recoveryDirectory;
if (cleanupErrors.length) {
try {
const retained = fs.lstatSync(ownership.stagingDirectory, { bigint: true });
if (identityMatches(retained, ownership.stagingIdentity, { directory: true })) {
recoveryDirectory = ownership.stagingDirectory;
}
} catch {}
recoveryDirectory ||= cleanupErrors.find((entry) => entry.recoveryDirectory)?.recoveryDirectory;
}
return {
ok: true,
status: cleanupErrors.length ? 'committed-with-warning' : 'committed',
cleanupErrors,
...(recoveryDirectory ? { recoveryDirectory } : {}),
committedReceipt: outputs.receipt,
};
} catch (error) {
const rollbackErrors = [];
for (const entry of [...published].reverse()) {
let removed;
let ownedMismatch;
for (const expectedLinks of [2, 1]) {
const matches = verifyRegularFileBinding(entry.binding, {
filePath: entry.finalPath,
expectedLinks,
});
if (matches.status === 'match') {
removed = quarantineRemoveRegularFileBinding(
entry.binding,
entry.finalPath,
{ subject: 'published-evidence', expectedLinks },
);
break;
}
if (matches.reason?.code?.includes('content-changed')
|| matches.reason?.code?.includes('mode-changed')) {
ownedMismatch = matches;
} else {
ownedMismatch ||= matches;
}
const reportedLinks = Number(matches.reason?.links);
if (matches.status === 'unsupported'
&& matches.reason?.code === 'staged-evidence-hardlinked'
&& Number.isSafeInteger(reportedLinks)
&& reportedLinks > expectedLinks) {
const current = verifyRegularFileBinding(entry.binding, {
filePath: entry.finalPath,
expectedLinks: reportedLinks,
});
if (current.status === 'match') {
removed = quarantineRemoveRegularFileBinding(
entry.binding,
entry.finalPath,
{ subject: 'published-evidence', expectedLinks: reportedLinks },
);
break;
}
}
}
if (!removed) {
const claimantRemoval = quarantineRemoveLinkedRegularFileAlias(
entry.stagedPath,
entry.finalPath,
{ subject: 'published-evidence-claimant' },
);
removed = claimantRemoval.status === 'removed' ? claimantRemoval : ownedMismatch;
}
if (removed.status !== 'removed' && removed.status !== 'absent') {
rollbackErrors.push(publishedRemovalError(entry.finalPath, removed));
}
}
for (const backup of [...backups].reverse()) restoreOwnedBackup(backup, rollbackErrors);
rollbackErrors.push(...releaseStagedBindings());
rollbackErrors.push(...cleanupStagedEvidence(ownership, { removeDirectory: true }));
ownership.active = false;
if (conflict) {
if (rollbackErrors.length) conflict.diagnostic.evidence.rollbackErrors = rollbackErrors;
return conflict;
}
return evidenceWriteFailure(
artifactPath,
'visual-check could not atomically publish its evidence set.',
[{ reason: error.message }, ...rollbackErrors],
);
} finally {
releaseStagedBindings();
}
}
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, resolveExecutable) {
const pathApi = platform === 'win32' ? path.win32 : path;
const directories = String(env.PATH || '').split(pathApi.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 = pathApi.join(directory, `${command}${extension}`);
const resolved = resolveExecutable(candidate, platform);
if (resolved) return resolved;
}
}
return null;
}
export function findChrome({
env = process.env,
platform = process.platform,
resolveExecutable = executable,
} = {}) {
if (Object.prototype.hasOwnProperty.call(env, 'ARCHIFY_CHROME')) {
return resolveExecutable(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') {
const pathApi = path.win32;
for (const root of [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].filter(Boolean)) {
fixed.push(
pathApi.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),
pathApi.join(root, 'Chromium', 'Application', 'chrome.exe'),
);
}
commands.push('chrome', 'google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser');
} else {
commands.push('google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser');
}
for (const candidate of fixed) {
const resolved = resolveExecutable(candidate, platform);
if (resolved) return resolved;
}
for (const command of commands) {
const resolved = findOnPath(command, env, platform, resolveExecutable);
if (resolved) return resolved;
}
return null;
}
class PipeCdp {
constructor(child, { failureDetails = () => '' } = {}) {
this.child = child;
this.failureDetails = failureDetails;
this.nextId = 1;
this.buffer = '';
this.receivedBytes = 0;
this.receivedMessages = 0;
this.completedWrites = 0;
this.writtenBytes = 0;
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)));
this.readPipe.once('end', () => this.failAll(this.failure('read pipe', new Error('stream ended'))));
this.readPipe.once('close', () => this.failAll(this.failure('read pipe', new Error('stream closed'))));
this.writePipe.once('close', () => this.failAll(this.failure('write pipe', new Error('stream closed'))));
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();
const failure = new Error([
`Chrome DevTools ${stage} failed: ${error?.message || String(error)}${code}`,
details,
`Chrome transport: Node ${process.version}, libuv ${process.versions.uv}, ${process.platform}; pid=${this.child.pid ?? 'unavailable'}, exitCode=${this.child.exitCode}, signalCode=${this.child.signalCode}.`,
`Chrome read pipe: readable=${this.readPipe.readable}, ended=${this.readPipe.readableEnded}, destroyed=${this.readPipe.destroyed}, receivedBytes=${this.receivedBytes}, messages=${this.receivedMessages}, bufferedBytes=${Buffer.byteLength(this.buffer)}.`,
`Chrome write pipe: writable=${this.writePipe.writable}, ended=${this.writePipe.writableEnded}, destroyed=${this.writePipe.destroyed}, completedWrites=${this.completedWrites}, writtenBytes=${this.writtenBytes}, bufferedBytes=${this.writePipe.writableLength}.`,
].filter(Boolean).join('\n'));
if (error?.code) failure.code = error.code;
if (error?.method) failure.method = error.method;
return failure;
}
consume(chunk) {
this.receivedBytes += Buffer.byteLength(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;
}
this.receivedMessages += 1;
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);
const error = new Error(`${method}: timed out after ${timeoutMs}ms`);
error.code = 'ERR_CHROME_CDP_TIMEOUT';
error.method = method;
reject(this.failure('protocol timeout', error));
}, timeoutMs);
this.pending.set(id, { method, resolve, reject, timer });
try {
const serialized = `${JSON.stringify(message)}\0`;
this.writePipe.write(serialized, (error) => {
if (error) {
this.failAll(this.failure('write pipe', error));
} else {
this.completedWrites += 1;
this.writtenBytes += Buffer.byteLength(serialized);
}
});
} 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(this.failure('event timeout', 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,
startupTimeoutMs = CHROME_STARTUP_TIMEOUT_MS,
} = {}) {
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.startupTimeoutMs = startupTimeoutMs;
this.sessionPromise = this.attach();
}
async attach() {
// Process launch can be delayed substantially on a busy desktop. Keep the
// longer allowance inside one gate invocation so an authoring agent does
// not turn startup jitter into repeated tool calls or candidate edits.
const targets = await this.cdp.send(
'Target.getTargets', {}, undefined, this.startupTimeoutMs,
);
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, writeScreenshot }) {
const sessionId = await this.sessionPromise;
await this.cdp.send('Emulation.setDeviceMetricsOverride', {
width,
height,
deviceScaleFactor: 1,
mobile: false,
}, sessionId);
const url = pathToFileURL(artifactPath);
url.searchParams.set('theme', theme);
const loaded = this.cdp.waitFor('Page.loadEventFired', sessionId);
// Navigation can fail before this waiter is awaited. Attach a rejection
// handler immediately so a later load failure never escapes as an
// unhandled rejection; awaiting `loaded` below still reports it normally.
loaded.catch(() => {});
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();
if (window.Archify && Archify.layoutStability && typeof Archify.layoutStability.whenStable === 'function') {
return fontsReady.then(function () { return Archify.layoutStability.whenStable(); });
}
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;
var minimumNonEdge = null;
var minimumEdge = null;
if (svg && scale > 0) {
Array.from(svg.querySelectorAll('text[data-node-label], text[data-boundary-label], text[data-detail="context"], g[data-detail="context"] text')).forEach(function (text) {
if (text.getAttribute('data-detail') === 'fine' || text.closest('[data-detail="fine"]')) return;
var edge = text.closest('[data-edge-from][data-edge-to]');
var node = text.closest('[data-node-id]');
var context = text.getAttribute('data-detail') === 'context' || Boolean(text.closest('g[data-detail="context"]'));
var detail = text.hasAttribute('data-node-label')
? 'primary'
: text.hasAttribute('data-boundary-label') ? 'boundary'
: context && edge ? 'edge' : 'context';
if (detail === 'context' && !node) return;
var sourceFontPx = parseFloat(text.getAttribute('font-size') || '');
if (!Number.isFinite(sourceFontPx)) return;
var projectedFontPx = sourceFontPx * scale;
var entry = {
text: (text.textContent || '').trim(),
detail: detail,
owner: edge ? {
kind: 'edge',
id: edge.getAttribute('data-edge-id'),
from: edge.getAttribute('data-edge-from'),
to: edge.getAttribute('data-edge-to')
} : node ? { kind: 'node', id: node.getAttribute('data-node-id') }
: detail === 'boundary' ? { kind: 'boundary', id: null } : null,
sourceFontPx: sourceFontPx,
projectedFontPx: projectedFontPx
};
if (!minimum || projectedFontPx < minimum.projectedFontPx) minimum = entry;
if (detail === 'edge') {
if (!minimumEdge || projectedFontPx < minimumEdge.projectedFontPx) minimumEdge = entry;
} else if (!minimumNonEdge || projectedFontPx < minimumNonEdge.projectedFontPx) {
minimumNonEdge = entry;
}
});
}
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);
// The dock may lift to the viewport floor while the page scrolls; the
// stage contract is about its resting position.
var navigationDockRect = !navigationDock ? null
: window.Archify && Archify.viewerChromeLayout && typeof Archify.viewerChromeLayout.dockRect === 'function'
? Archify.viewerChromeLayout.dockRect()
: navigationDock.getBoundingClientRect();
var stageDockIntersectionArea = intersectionArea(stageRect, navigationDockRect);
var viewerChromeReceipt = window.Archify && Archify.viewerChromeLayout
&& typeof Archify.viewerChromeLayout.receipt === 'function'
? Archify.viewerChromeLayout.receipt()
: null;
// Read rendered boxes only; source ownership, offsets and hard pins are unknown.
var workflowLanes = svg ? Array.from(svg.querySelectorAll('rect[data-composition-frame-kind="lane"]')).map(function (lane) {
var rect = lane.getBoundingClientRect();
var members = Array.from(svg.querySelectorAll('g[data-node-id]')).map(function (node) {
var box = node.querySelector('rect');
if (!box) return null;
var bounds = box.getBoundingClientRect();
if (bounds.top < rect.top - 1 || bounds.bottom > rect.bottom + 1
|| bounds.left < rect.left - 1 || bounds.right > rect.right + 1) return null;
return { id: node.getAttribute('data-node-id'), top: bounds.top, bottom: bounds.bottom };
}).filter(Boolean);
var top = members.length ? members.reduce(function (minimum, node) { return Math.min(minimum, node.top); }, Infinity) : null;
var bottom = members.length ? members.reduce(function (maximum, node) { return Math.max(maximum, node.bottom); }, -Infinity) : null;
return {
frameId: lane.getAttribute('data-composition-frame-id'),
heightPx: Math.round(rect.height),
nodeCount: members.length,
nodeIds: members.slice(0, 12).map(function (node) { return node.id; }),
nodeSpanPx: top === null ? null : Math.round(bottom - top),
spaceAboveNodesPx: top === null ? null : Math.round(top - rect.top),
spaceBelowNodesPx: bottom === null ? null : Math.round(rect.bottom - bottom)
};
}).sort(function (a, b) { return b.heightPx - a.heightPx; }).slice(0, 6) : [];
// Vertical page budget: every block that stacks above or below the SVG,
// so an overflow receipt can say which part must give up how many pixels.
function outerHeight(element) {
if (!element) return 0;
var style = window.getComputedStyle(element);
if (element.hidden || style.display === 'none') return 0;
return element.getBoundingClientRect().height
+ (parseFloat(style.marginTop) || 0) + (parseFloat(style.marginBottom) || 0);
}
var bodyStyle = window.getComputedStyle(document.body);
var diagramStyle = diagram ? window.getComputedStyle(diagram) : null;
var svgHeight = svg ? svg.getBoundingClientRect().height : 0;
var notesBelowFold = Boolean(reader) && document.documentElement.getAttribute('data-reader-rail') === 'bottom';
var pageComposition = {
bodyPaddingPx: Math.round((parseFloat(bodyStyle.paddingTop) || 0) + (parseFloat(bodyStyle.paddingBottom) || 0)),
headerPx: Math.round(outerHeight(reader && reader.querySelector('.header'))),
diagramChromePx: Math.round(outerHeight(diagram) - svgHeight),
svgPx: Math.round(svgHeight),
cardsPx: notesBelowFold ? 0 : Math.round(outerHeight(reader && reader.querySelector('.cards'))),
viewBoxHeight: viewBox ? viewBox.height : 0
};
var svgRect = svg && svg.getBoundingClientRect();
var diagramRect = diagram && diagram.getBoundingClientRect();
var documentScrollUnclipped = Boolean(svgRect && diagramRect
&& svgRect.top >= diagramRect.top - 1 && svgRect.left >= diagramRect.left - 1
&& svgRect.bottom <= diagramRect.bottom + 1 && svgRect.right <= diagramRect.right + 1
&& diagram.scrollHeight <= diagram.clientHeight + 1
&& diagram.scrollWidth <= diagram.clientWidth + 1
&& [document.documentElement, document.body].every(function (element) {
return !['hidden', 'clip'].includes(window.getComputedStyle(element).overflowY);
}));
return {
pageComposition: pageComposition,
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
scrollWidth: Math.ceil(document.documentElement.scrollWidth),
// Bottom notes and index are reading material below the fold; the
// Reader excludes them from the one-screen fit, and so does this check.
scrollHeight: Math.max(window.innerHeight, Math.ceil(document.documentElement.scrollHeight - (
notesBelowFold ? outerHeight(reader.querySelector('.reader-rail')) : 0))),
resolvedTheme: document.documentElement.getAttribute('data-theme') || '',
readerLayout: document.documentElement.getAttribute('data-reader-layout') || null,
readerOverflow: document.documentElement.getAttribute('data-reader-overflow') || null,
readerFit: svg ? svg.getAttribute('data-reader-fit') : null,
diagramType: svg ? svg.getAttribute('data-diagram-type') : null,
documentScrollUnclipped: documentScrollUnclipped,
readerWidth: reader ? reader.getBoundingClientRect().width : 0,
diagramWidth: diagramWidth,
viewBoxWidth: viewBoxWidth,
workflowLanes: workflowLanes,
minimumProjectedNodeTextPx: minimum ? minimum.projectedFontPx : null,
minimumProjectedNonEdgeTextPx: minimumNonEdge ? minimumNonEdge.projectedFontPx : null,
minimumProjectedEdgeTextPx: minimumEdge ? minimumEdge.projectedFontPx : null,
minimumProjectedNodeText: minimum ? minimum.text : null,
minimumProjectedNodeTextDetail: minimum ? minimum.detail : null,
minimumProjectedNodeTextOwner: minimum ? minimum.owner : 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.');
const screenshot = Buffer.from(capture.data, 'base64');
if (writeScreenshot) writeScreenshot(screenshot);
else fs.writeFileSync(screenshotPath, screenshot, { flag: 'wx' });
}
return metrics;
}
close() {
if (!this.closePromise) this.closePromise = this.finishClose();
return this.closePromise;
}
async finishClose() {
this.cdp.failAll(new Error('visual-check finished'));
try {
if (this.child.exitCode === null && this.child.signalCode === null) {
await new Promise((resolve) => {
let timer = null;
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
this.child.removeListener('exit', finish);
resolve();
};
this.child.once('exit', finish);
this.child.kill('SIGTERM');
if (settled) return;
timer = setTimeout(() => {
if (this.child.exitCode === null && this.child.signalCode === null) this.child.kill('SIGKILL');
finish();
}, 1500);
});
}
} finally {
// `exit` only reports that Chrome's main process is gone. A helper may
// still hold an inherited pipe open, so explicitly release every stream
// this browser instance owns before its test cleanup hook resolves.
for (const stream of [this.child.stderr, this.child.stdio[3], this.child.stdio[4]]) {
if (stream && !stream.destroyed) stream.destroy();
}
}
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 minimumProjectedNonEdgeTextPx = metrics.minimumProjectedNonEdgeTextPx == null
? null
: Number(metrics.minimumProjectedNonEdgeTextPx);
const minimumProjectedEdgeTextPx = metrics.minimumProjectedEdgeTextPx == null
? null
: Number(metrics.minimumProjectedEdgeTextPx);
const readabilityOk = minimumProjectedNodeTextPx == null
|| minimumProjectedNodeTextPx >= MIN_PROJECTED_NODE_TEXT_PX;
const readerLayout = metrics.readerLayout || null;
const readerOverflow = metrics.readerOverflow || null;
const readerFit = metrics.readerFit || null;
const verticalScrollAccepted = Boolean(
overflowY
&& !overflowX
&& readabilityOk
&& Number.isFinite(minimumProjectedNodeTextPx)
&& ((readerLayout === 'adaptive' && readerOverflow === 'authored' && readerFit === 'intrinsic-height')
|| (readerFit === 'authored-height' && metrics.diagramType === 'architecture'
&& metrics.documentScrollUnclipped === true))
);
const authoredClipped = readerFit === 'authored-height' && metrics.diagramType === 'architecture'
&& metrics.documentScrollUnclipped !== true;
const containmentOk = !authoredClipped && !overflowX && (!overflowY || verticalScrollAccepted);
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,
verticalScrollAccepted,
overflowDisposition: authoredClipped || overflowX || (overflowY && !verticalScrollAccepted)
? 'unexpected-overflow'
: verticalScrollAccepted ? 'readable-vertical-scroll' : 'contained',
readerLayout,
readerOverflow,
readerFit,
...(readerFit === 'authored-height' ? { diagramType: metrics.diagramType, documentScrollUnclipped: metrics.documentScrollUnclipped === true } : {}),
ok: containmentOk,
readerWidth: Number(metrics.readerWidth) || null,
diagramWidth: Number(metrics.diagramWidth) || null,
...(metrics.pageComposition ? { pageComposition: metrics.pageComposition } : {}),
viewBoxWidth: Number(metrics.viewBoxWidth) || null,
...(metrics.workflowLanes?.length ? { workflowLanes: metrics.workflowLanes } : {}),
minimumProjectedNodeTextPx,
minimumProjectedNonEdgeTextPx,
minimumProjectedEdgeTextPx,
minimumProjectedNodeText: metrics.minimumProjectedNodeText || null,
minimumProjectedNodeTextDetail: metrics.minimumProjectedNodeTextDetail || null,
minimumProjectedNodeTextOwner: metrics.minimumProjectedNodeTextOwner || 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))} · visual-check containment ${htmlEscape(receipt.containment.status)} · perceptual visual review pending