VS Code extension that previews Archify diagrams from their JSON sources (live, as you type) and opens rendered Archify HTML in a viewer tab. Bundles the Archify 3.0.1 renderer and runs it on VS Code's Node runtime. Adds validation diagnostics, JSON schema help, source-link navigation, export saving, render-to-file and open-in-browser commands. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2668 lines
110 KiB
JavaScript
2668 lines
110 KiB
JavaScript
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) => `
|
||
<figure>
|
||
<img src="${htmlEscape(entry.file)}" alt="${htmlEscape(`${entry.theme} ${entry.width} by ${entry.height}`)}">
|
||
<figcaption><strong>${htmlEscape(entry.theme.toUpperCase())}</strong> · ${entry.width}×${entry.height} · containment ${entry.ok ? 'pass' : 'fail'}${entry.verticalScrollAccepted ? ' · readable vertical scroll' : ''}</figcaption>
|
||
</figure>`).join('');
|
||
return `<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Archify automated browser evidence · ${htmlEscape(path.basename(artifactPath))}</title>
|
||
<style>
|
||
*{box-sizing:border-box}body{margin:0;padding:24px;background:#e9eef5;color:#172033;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}header{max-width:1500px;margin:0 auto 18px}h1{margin:0 0 6px;font-size:20px}p{margin:0;color:#526176}.grid{max-width:1500px;margin:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}figure{margin:0;padding:10px;background:white;border:1px solid #c9d4e3;border-radius:12px;box-shadow:0 10px 30px rgba(15,23,42,.08)}img{display:block;width:100%;height:auto;border:1px solid #e2e8f0}figcaption{padding:9px 4px 2px;color:#526176}@media(max-width:900px){.grid{grid-template-columns:1fr}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header><h1>Automated browser evidence</h1><p>${htmlEscape(path.basename(artifactPath))} · visual-check containment ${htmlEscape(receipt.containment.status)} · perceptual visual review pending</p></header>
|
||
<main class="grid">${cards}
|
||
</main>
|
||
</body>
|
||
</html>
|
||
`;
|
||
}
|
||
|
||
const publishedBrowserEvidenceReceipts = new WeakSet();
|
||
|
||
// Keep detailed observations in the bound receipt; expose every review image and
|
||
// every failure without repeating the same viewport metrics in CLI context.
|
||
export function summarizeBrowserEvidence(receipt) {
|
||
const directory = receipt.sidecars?.directory || path.dirname(receipt.artifact.path);
|
||
const evidencePath = (file) => file ? path.resolve(directory, file) : undefined;
|
||
const published = publishedBrowserEvidenceReceipts.has(receipt);
|
||
return {
|
||
schemaVersion: receipt.schemaVersion,
|
||
command: receipt.command,
|
||
ok: receipt.ok,
|
||
status: receipt.status,
|
||
evidenceKind: receipt.evidenceKind,
|
||
visualReview: receipt.visualReview,
|
||
artifact: receipt.artifact,
|
||
provenance: receipt.provenance,
|
||
deliveryReceiptId: receipt.deliveryReceiptId,
|
||
state: receipt.state,
|
||
chrome: receipt.chrome,
|
||
checks: Object.fromEntries(['containment', 'readability', 'viewerChrome', 'themeStates', 'captures']
|
||
.filter((key) => receipt[key])
|
||
.map((key) => [key, receipt[key].status])),
|
||
error: receipt.error,
|
||
publication: receipt.publication,
|
||
diagnostics: receipt.diagnostics || [],
|
||
evidence: {
|
||
receipt: published ? evidencePath(receipt.sidecars?.receipt) : undefined,
|
||
contactSheet: published ? evidencePath(receipt.captures?.contactSheet) : undefined,
|
||
screenshots: (published ? receipt.captures?.screenshots || [] : []).map((entry) => ({
|
||
path: evidencePath(entry.file),
|
||
width: entry.width,
|
||
height: entry.height,
|
||
theme: entry.theme,
|
||
resolvedTheme: entry.resolvedTheme,
|
||
})),
|
||
},
|
||
};
|
||
}
|
||
|
||
function viewportSubject(artifact, entry) {
|
||
return {
|
||
artifact,
|
||
viewport: { width: entry.width, height: entry.height, theme: entry.theme },
|
||
};
|
||
}
|
||
|
||
function failureDiagnostic({ code, message, subject, evidence, supportedFixes, severity = 'error' }) {
|
||
return { code, severity, message, subject, evidence, supportedFixes };
|
||
}
|
||
|
||
function resolveSidecarDirectory(artifactPath, outputs, compareParents) {
|
||
const artifact = path.resolve(artifactPath);
|
||
const artifactDirectory = path.dirname(artifact);
|
||
const sidecarDirectory = path.dirname(outputs.receipt);
|
||
const comparison = compareParents(outputs.receipt, artifact);
|
||
|
||
const sidecars = {
|
||
...(comparison?.status === 'different' ? { directory: sidecarDirectory } : {}),
|
||
receipt: path.basename(outputs.receipt),
|
||
...(outputs.capture === false ? {} : { contactSheet: path.basename(outputs.contactSheet) }),
|
||
};
|
||
if (comparison?.status === 'match' || comparison?.status === 'different') {
|
||
return { ok: true, sidecars };
|
||
}
|
||
|
||
const reason = comparison?.reason || { code: 'invalid-path-identity-result' };
|
||
const error = `visual-check could not determine the physical identity of its evidence directory "${sidecarDirectory}" relative to the artifact directory "${artifactDirectory}".`;
|
||
return {
|
||
ok: false,
|
||
sidecars,
|
||
error,
|
||
diagnostic: failureDiagnostic({
|
||
code: 'viewer/sidecar-directory-identity',
|
||
message: error,
|
||
subject: { artifact, sidecarDirectory },
|
||
evidence: { comparison: reason },
|
||
supportedFixes: [
|
||
'use existing, accessible artifact and evidence directories, then rerun visual-check',
|
||
],
|
||
}),
|
||
};
|
||
}
|
||
|
||
// Turn a measured vertical overflow into the pixel budget an author can act on:
|
||
// which stacked block holds the height, and what the SVG or cards must shrink to.
|
||
export function verticalBudgetFixes(entry) {
|
||
const page = entry.pageComposition;
|
||
if (!entry.overflowY || !page) return [];
|
||
const excess = entry.scrollHeight - entry.innerHeight;
|
||
const fixes = [];
|
||
const stacked = `${page.bodyPaddingPx}px body padding + ${page.headerPx}px header + ${page.diagramChromePx}px diagram chrome + ${page.svgPx}px SVG + ${page.cardsPx}px cards = ${entry.scrollHeight}px against ${entry.innerHeight}px`;
|
||
if (page.svgPx > 0 && page.viewBoxHeight > 0) {
|
||
const targetSvg = page.svgPx - excess;
|
||
const targetViewBoxHeight = Math.floor(page.viewBoxHeight * targetSvg / page.svgPx);
|
||
if (entry.readerLayout === 'adaptive') {
|
||
fixes.push(targetSvg > 0
|
||
? `the page is ${excess}px too tall (${stacked}); the Reader already narrowed the stage to its ${entry.diagramWidth}px minimum, so the SVG height only follows meta.viewBox: keep every node and relationship and reduce the viewBox height to at most ${targetViewBoxHeight} (from ${page.viewBoxHeight}) by tightening vertical gaps and empty rows`
|
||
: `the page is ${excess}px too tall (${stacked}) and the SVG alone exceeds the viewport at the minimum reader width; split the diagram into two`);
|
||
} else {
|
||
fixes.push(`the page is ${excess}px too tall (${stacked}); the SVG spans the full ${entry.diagramWidth}px reader width because its viewBox ratio is below 1.55 and it declares no intrinsic-height fit, so the Reader can neither narrow it nor accept readable vertical scroll: either remove meta.viewBox so the renderer sizes the canvas and declares the fit, or make the viewBox at least 1.55x wider than tall`);
|
||
}
|
||
}
|
||
if (page.cardsPx >= excess) {
|
||
fixes.push(`alternatively, conclusion cards occupy ${page.cardsPx}px below the diagram; shortening card copy or dropping a card row so cards take at most ${page.cardsPx - excess}px also fits`);
|
||
}
|
||
return fixes;
|
||
}
|
||
|
||
function observationDiagnostics({ artifact, allObservations, readabilityObservations, command }) {
|
||
const diagnostics = [];
|
||
for (const entry of allObservations) {
|
||
if (entry.resolvedTheme !== entry.theme) {
|
||
diagnostics.push(failureDiagnostic({
|
||
code: 'viewer/theme-state',
|
||
message: `The rendered artifact resolved ${entry.resolvedTheme || 'no theme'} instead of the requested ${entry.theme} theme at ${entry.width}x${entry.height}.`,
|
||
subject: viewportSubject(artifact, entry),
|
||
evidence: {
|
||
requestedTheme: entry.theme,
|
||
resolvedTheme: entry.resolvedTheme,
|
||
},
|
||
supportedFixes: [
|
||
`restore deterministic ${entry.theme} theme resolution, then rerun ${command}`,
|
||
],
|
||
}));
|
||
}
|
||
if (!entry.ok) {
|
||
const authoredClipped = entry.readerFit === 'authored-height' && entry.diagramType === 'architecture'
|
||
&& !entry.documentScrollUnclipped;
|
||
const budgetFixes = authoredClipped
|
||
? ['restore the full SVG inside the diagram panel and allow normal document scrolling; remove internal scrollers and clipping without changing authored geometry']
|
||
: verticalBudgetFixes(entry);
|
||
diagnostics.push(failureDiagnostic({
|
||
code: authoredClipped ? 'viewer/diagram-clipped' : 'viewer/viewport-overflow',
|
||
message: authoredClipped
|
||
? `The authored Architecture canvas is clipped or cannot scroll in the document at ${entry.width}x${entry.height} (${entry.theme}).`
|
||
: `The rendered artifact overflows the ${entry.width}x${entry.height} ${entry.theme} viewport.`,
|
||
subject: viewportSubject(artifact, entry),
|
||
evidence: {
|
||
innerWidth: entry.innerWidth,
|
||
innerHeight: entry.innerHeight,
|
||
scrollWidth: entry.scrollWidth,
|
||
scrollHeight: entry.scrollHeight,
|
||
overflowX: entry.overflowX,
|
||
overflowY: entry.overflowY,
|
||
overflowDisposition: entry.overflowDisposition,
|
||
readerLayout: entry.readerLayout,
|
||
readerOverflow: entry.readerOverflow,
|
||
readerFit: entry.readerFit,
|
||
...(authoredClipped ? { documentScrollUnclipped: false } : {}),
|
||
...(entry.overflowY && entry.pageComposition ? {
|
||
pageComposition: entry.pageComposition,
|
||
pageCompositionMeasurement: 'CSS pixels at this viewport; body padding, header, diagram chrome, SVG and cards stack vertically and sum to scrollHeight',
|
||
} : {}),
|
||
...(entry.overflowY && entry.workflowLanes?.length ? {
|
||
workflowLanes: entry.workflowLanes,
|
||
measurement: 'CSS pixels; rendered node boxes geometrically contained in each lane frame; spaces include headers and routing, not guaranteed removable space',
|
||
} : {}),
|
||
},
|
||
supportedFixes: [
|
||
...budgetFixes,
|
||
...(entry.overflowY && entry.workflowLanes?.length ? [
|
||
'run validate workflow <source.json> --layout-json and compare the tallest rendered lane frames with source lanes, col and yOffset; frame IDs are rendered indices, not source lane IDs',
|
||
'where ownership and explicit geometry permit, distribute stacked steps across logical columns and meaningful lanes before increasing yOffset; preserve nodes, branches, labels and hard pins',
|
||
`read references/authoring-contract.md#workflow-viewport-repair, then validate and deliver the changed source before rerunning ${command} on the new artifact; this is inspection guidance, not a verified coordinate fix`,
|
||
] : budgetFixes.length ? [] : [`contain the rendered layout within ${entry.width}x${entry.height}, then rerun ${command}`]),
|
||
],
|
||
}));
|
||
}
|
||
if (entry.legendDockIntersectionArea > 0.5) {
|
||
diagnostics.push(failureDiagnostic({
|
||
code: 'viewer/chrome-legend-clearance',
|
||
message: `The navigation Dock obscures the SVG Legend at ${entry.width}x${entry.height} (${entry.theme}).`,
|
||
subject: viewportSubject(artifact, entry),
|
||
evidence: { legendDockIntersectionArea: entry.legendDockIntersectionArea },
|
||
supportedFixes: [
|
||
`move the SVG Legend or Viewer Dock until legendDockIntersectionArea is 0, then rerun ${command}`,
|
||
],
|
||
}));
|
||
}
|
||
if (!entry.viewerChromeStageOk) {
|
||
const stageOverlapsDock = entry.dockStageIntersectionArea > 0.5;
|
||
diagnostics.push(failureDiagnostic({
|
||
code: 'viewer/chrome-stage-clearance',
|
||
message: stageOverlapsDock
|
||
? `Navigation Dock enters the protected SVG stage at ${entry.width}x${entry.height} (${entry.theme}).`
|
||
: `Navigation Dock clearance from the protected SVG stage is below the required gap at ${entry.width}x${entry.height} (${entry.theme}).`,
|
||
subject: viewportSubject(artifact, entry),
|
||
evidence: {
|
||
dockStageIntersectionArea: entry.dockStageIntersectionArea,
|
||
dockStageGap: entry.dockStageGap,
|
||
requiredDockStageGap: entry.requiredDockStageGap,
|
||
},
|
||
supportedFixes: [
|
||
`adjust Viewer stage reservation or clipping until dockStageGap is at least ${entry.requiredDockStageGap} and dockStageIntersectionArea is 0, then rerun ${command}`,
|
||
],
|
||
}));
|
||
}
|
||
}
|
||
for (const entry of readabilityObservations) {
|
||
if (entry.readabilityOk) continue;
|
||
diagnostics.push(failureDiagnostic({
|
||
code: 'viewer/projected-text-readability',
|
||
message: `Projected ${entry.minimumProjectedNodeTextDetail || 'node'} text is below the readability floor at ${entry.width}x${entry.height}.`,
|
||
subject: viewportSubject(artifact, entry),
|
||
evidence: {
|
||
text: entry.minimumProjectedNodeText,
|
||
detail: entry.minimumProjectedNodeTextDetail,
|
||
owner: entry.minimumProjectedNodeTextOwner,
|
||
minimumProjectedNodeTextPx: entry.minimumProjectedNodeTextPx,
|
||
minimumRequiredNodeTextPx: entry.minimumRequiredNodeTextPx,
|
||
},
|
||
supportedFixes: [
|
||
`increase projected ${entry.minimumProjectedNodeTextOwner?.kind === 'edge' ? 'relationship label' : entry.minimumProjectedNodeTextOwner?.kind === 'boundary' ? 'boundary label' : 'node text'} to at least ${entry.minimumRequiredNodeTextPx}px at ${entry.width}x${entry.height}, then rerun ${command}`,
|
||
],
|
||
}));
|
||
}
|
||
return diagnostics;
|
||
}
|
||
|
||
function baseReceipt({ artifactPath, artifact, sidecars, chrome, deliveryProvenance, command, capture }) {
|
||
return {
|
||
schemaVersion: 1,
|
||
ok: false,
|
||
command,
|
||
evidenceKind: 'automated-browser',
|
||
status: 'fail',
|
||
visualReview: capture ? 'pending' : 'not-requested',
|
||
...(deliveryProvenance ? {
|
||
provenance: deliveryProvenance.status,
|
||
...(deliveryProvenance.receiptId ? { deliveryReceiptId: deliveryProvenance.receiptId } : {}),
|
||
} : {}),
|
||
artifact: {
|
||
path: artifactPath,
|
||
sha256: sha256(artifact),
|
||
bytes: artifact.byteLength,
|
||
},
|
||
state: { detail: 'read', motion: 'still' },
|
||
chrome,
|
||
diagnostics: [],
|
||
containment: {
|
||
status: 'fail',
|
||
policy: 'fit-or-reader-declared-readable-vertical-scroll',
|
||
viewports: [],
|
||
},
|
||
themeStates: { status: 'fail', viewports: [] },
|
||
readability: { status: 'fail', minimumProjectedNodeTextPx: MIN_PROJECTED_NODE_TEXT_PX, viewports: [] },
|
||
viewerChrome: { status: 'fail', viewports: [] },
|
||
captures: { status: capture ? 'fail' : 'not-requested', screenshots: [], contactSheet: null },
|
||
sidecars: { ...sidecars, files: [] },
|
||
};
|
||
}
|
||
|
||
function appendEvidencePublicationFailure(receipt, failure) {
|
||
if (!failure?.ok) {
|
||
if (!receipt.error) receipt.error = failure.error;
|
||
receipt.diagnostics = [...(receipt.diagnostics || []), failure.diagnostic];
|
||
}
|
||
}
|
||
|
||
function appendEvidenceCleanupWarning(receipt, publication) {
|
||
if (publication?.status !== 'committed-with-warning') return false;
|
||
const committedStatus = receipt.status;
|
||
const cleanupErrors = publication.cleanupErrors || [];
|
||
const cleanupMessage = publication.recoveryDirectory
|
||
? `The evidence set was committed, but cleanup is incomplete. Recovery directory: "${publication.recoveryDirectory}".`
|
||
: 'The evidence set was committed, but cleanup is incomplete.';
|
||
receipt.ok = false;
|
||
receipt.status = 'fail';
|
||
receipt.error = receipt.error ? `${receipt.error} ${cleanupMessage}` : cleanupMessage;
|
||
receipt.publication = {
|
||
status: publication.status,
|
||
committedReceipt: publication.committedReceipt,
|
||
...(publication.recoveryDirectory
|
||
? { recoveryDirectory: publication.recoveryDirectory } : {}),
|
||
cleanupErrors,
|
||
committedStatus,
|
||
};
|
||
receipt.diagnostics = [...(receipt.diagnostics || []), failureDiagnostic({
|
||
code: 'viewer/evidence-cleanup-incomplete',
|
||
severity: 'warning',
|
||
message: cleanupMessage,
|
||
subject: {
|
||
artifact: receipt.artifact?.path,
|
||
receipt: publication.committedReceipt,
|
||
},
|
||
evidence: {
|
||
publicationStatus: publication.status,
|
||
...(publication.recoveryDirectory
|
||
? { recoveryDirectory: publication.recoveryDirectory } : {}),
|
||
cleanupErrors,
|
||
},
|
||
supportedFixes: publication.recoveryDirectory
|
||
? [`inspect and safely retire the retained files under "${publication.recoveryDirectory}"`]
|
||
: ['resolve the reported filesystem cleanup failure before rerunning visual-check'],
|
||
})];
|
||
return true;
|
||
}
|
||
|
||
function publishReceiptOnly(artifactPath, outputs, receipt, ownership) {
|
||
const cleanupErrors = cleanupStagedEvidence(ownership);
|
||
if (cleanupErrors.length) {
|
||
const failure = evidenceWriteFailure(
|
||
artifactPath,
|
||
'visual-check could not safely retire this run\'s incomplete staged evidence.',
|
||
cleanupErrors,
|
||
);
|
||
cleanupStagedEvidence(ownership, { removeDirectory: true });
|
||
ownership.active = false;
|
||
return failure;
|
||
}
|
||
try {
|
||
writeStagedEvidence(ownership, outputs.receipt, `${JSON.stringify(receipt, null, 2)}\n`);
|
||
} catch (error) {
|
||
const failure = evidenceWriteFailure(
|
||
artifactPath,
|
||
'visual-check could not stage its evidence receipt.',
|
||
[{ file: stagedPathFor(ownership, outputs.receipt), reason: error.message }],
|
||
);
|
||
cleanupStagedEvidence(ownership, { removeDirectory: true });
|
||
ownership.active = false;
|
||
return failure;
|
||
}
|
||
return commitVisualEvidence(artifactPath, outputs, ownership, [outputs.receipt]);
|
||
}
|
||
|
||
function persistBrowserEvidenceFailure(
|
||
artifactPath,
|
||
failure,
|
||
{ outDir, compareSidecarParents = sameParent, ownership, command = 'visual-check', capture = true } = {},
|
||
) {
|
||
const outputs = evidenceSidecarPaths(artifactPath, { outDir, command, capture });
|
||
const directory = resolveSidecarDirectory(artifactPath, outputs, compareSidecarParents);
|
||
const capturedArtifact = readRegularFileContents(artifactPath, {
|
||
subject: 'failure-artifact',
|
||
});
|
||
const artifactBytes = capturedArtifact.ok ? capturedArtifact.bytes : null;
|
||
const receipt = {
|
||
...failure,
|
||
ok: false, status: 'fail',
|
||
...(artifactBytes ? {
|
||
artifact: {
|
||
path: path.resolve(artifactPath),
|
||
sha256: sha256(artifactBytes),
|
||
bytes: artifactBytes.byteLength,
|
||
},
|
||
} : {}),
|
||
containment: {
|
||
status: 'fail',
|
||
policy: 'fit-or-reader-declared-readable-vertical-scroll',
|
||
viewports: [],
|
||
},
|
||
captures: { status: capture ? 'fail' : 'not-requested', screenshots: [], contactSheet: null },
|
||
sidecars: { ...directory.sidecars, files: [] },
|
||
};
|
||
if (!directory.ok) {
|
||
receipt.error = directory.error;
|
||
receipt.diagnostics = [...(failure.diagnostics || []), directory.diagnostic];
|
||
return receipt;
|
||
}
|
||
let activeOwnership = ownership;
|
||
if (!visualEvidenceOwnershipMatches(activeOwnership, artifactPath, outputs)) {
|
||
if (!artifactBytes) {
|
||
const conflict = evidencePathConflict(artifactPath, outputs, outputs.receipt, {
|
||
code: 'artifact-unreadable',
|
||
});
|
||
receipt.error = conflict.error;
|
||
receipt.diagnostics = [...(failure.diagnostics || []), conflict.diagnostic];
|
||
return receipt;
|
||
}
|
||
const preflight = beginVisualEvidenceWrite(artifactPath, artifactBytes, outputs);
|
||
if (!preflight.ok) {
|
||
receipt.error = preflight.error;
|
||
receipt.diagnostics = [...(failure.diagnostics || []), preflight.diagnostic];
|
||
return receipt;
|
||
}
|
||
activeOwnership = preflight.ownership;
|
||
}
|
||
const publication = publishReceiptOnly(artifactPath, outputs, receipt, activeOwnership);
|
||
if (publication.ok) publishedBrowserEvidenceReceipts.add(receipt);
|
||
appendEvidencePublicationFailure(receipt, publication);
|
||
appendEvidenceCleanupWarning(receipt, publication);
|
||
return receipt;
|
||
}
|
||
|
||
async function runBrowserEvidence({
|
||
artifactPath,
|
||
outDir,
|
||
chromePath,
|
||
resolveChrome = findChrome,
|
||
browserFactory = async (resolvedChrome) => new ChromeVisualBrowser(resolvedChrome),
|
||
deliveryProvenance,
|
||
verifyArtifact,
|
||
compareSidecarParents = sameParent,
|
||
command, capture,
|
||
} = {}) {
|
||
if (!artifactPath) throw new Error(`${command} requires one delivered HTML artifact.`);
|
||
const artifact = path.resolve(artifactPath);
|
||
if (!/\.html?$/i.test(artifact)) throw new Error(`${command} requires an .html artifact.`);
|
||
const capturedArtifact = captureRegularFileBinding(artifact, {
|
||
subject: 'visual-check-artifact',
|
||
expectedLinks: 1,
|
||
includeContent: true,
|
||
});
|
||
if (capturedArtifact.status !== 'captured') {
|
||
throw regularFileCaptureError('The visual-check artifact', capturedArtifact);
|
||
}
|
||
const artifactBytes = capturedArtifact.content.buffer;
|
||
try {
|
||
const outputs = evidenceSidecarPaths(artifact, { outDir, command, capture });
|
||
const directory = resolveSidecarDirectory(artifact, outputs, compareSidecarParents);
|
||
const receipt = baseReceipt({
|
||
artifactPath: artifact,
|
||
artifact: artifactBytes,
|
||
sidecars: directory.sidecars,
|
||
chrome: { status: 'not-checked', executable: null },
|
||
deliveryProvenance, command, capture,
|
||
});
|
||
if (!directory.ok) {
|
||
receipt.error = directory.error;
|
||
receipt.diagnostics = [directory.diagnostic];
|
||
return { exitCode: EXIT.fail, receipt };
|
||
}
|
||
const preflight = beginVisualEvidenceWrite(artifact, artifactBytes, outputs);
|
||
if (!preflight.ok) {
|
||
receipt.error = preflight.error;
|
||
receipt.diagnostics = [preflight.diagnostic];
|
||
return { exitCode: EXIT.fail, receipt };
|
||
}
|
||
const ownership = preflight.ownership;
|
||
let inspectionArtifact;
|
||
try {
|
||
// Chrome's file loader cannot reliably open long Windows/UNC paths. Keep
|
||
// the one inspected snapshot local; publish evidence on its target volume.
|
||
ownership.inspection = {
|
||
directory: fs.mkdtempSync(path.join(os.tmpdir(), 'archify-inspection-')),
|
||
};
|
||
const stat = fs.lstatSync(ownership.inspection.directory, { bigint: true });
|
||
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.ino === 0n) {
|
||
throw new Error('the private inspection directory has no stable identity');
|
||
}
|
||
ownership.inspection.identity = fileIdentity(stat);
|
||
inspectionArtifact = path.join(ownership.inspection.directory, 'artifact-snapshot.html');
|
||
ownership.stagingPaths.set(artifact, inspectionArtifact);
|
||
writeStagedEvidence(ownership, artifact, artifactBytes);
|
||
} catch (error) {
|
||
return { exitCode: EXIT.fail, receipt: persistBrowserEvidenceFailure(artifact, {
|
||
...receipt,
|
||
status: 'fail',
|
||
ok: false,
|
||
error: `visual-check could not stage its private artifact snapshot: ${error.message}`,
|
||
diagnostics: [failureDiagnostic({
|
||
code: 'viewer/artifact-snapshot',
|
||
message: 'visual-check could not bind a private artifact snapshot.',
|
||
subject: { artifact },
|
||
evidence: { reason: error.message },
|
||
supportedFixes: ['retry after resolving the reported staging filesystem error'],
|
||
})],
|
||
}, { outDir, compareSidecarParents, ownership, command, capture }) };
|
||
}
|
||
const verifyInspectionArtifact = () => {
|
||
const entry = ownership.stagedEntries.get(artifact);
|
||
if (!entry || !currentEvidenceMatches(entry.path, entry.identity, entry.evidence)) {
|
||
throw new Error('The private visual-check artifact snapshot changed during inspection.');
|
||
}
|
||
};
|
||
try {
|
||
verifyArtifact?.(artifactBytes);
|
||
} catch (error) {
|
||
return { exitCode: EXIT.fail, receipt: persistBrowserEvidenceFailure(artifact, {
|
||
schemaVersion: 1, command, evidenceKind: 'automated-browser', visualReview: capture ? 'pending' : 'not-requested',
|
||
artifact: { path: artifact, sha256: sha256(artifactBytes), bytes: artifactBytes.byteLength },
|
||
provenance: error.deliveryProvenance?.status, error: error.message,
|
||
diagnostics: error.archifyDiagnostics || [],
|
||
}, { outDir, compareSidecarParents, ownership, command, capture }) };
|
||
}
|
||
|
||
const resolvedChrome = chromePath || resolveChrome();
|
||
receipt.chrome = resolvedChrome
|
||
? { status: 'available', executable: resolvedChrome }
|
||
: { status: 'unavailable', executable: null };
|
||
|
||
if (!resolvedChrome) {
|
||
receipt.status = 'skipped';
|
||
receipt.containment.status = 'skipped';
|
||
receipt.readability.status = 'skipped';
|
||
receipt.viewerChrome.status = 'skipped';
|
||
receipt.themeStates.status = 'skipped';
|
||
if (capture) receipt.captures.status = 'skipped';
|
||
receipt.error = 'Chrome or Chromium is unavailable. Set ARCHIFY_CHROME to its executable path.';
|
||
receipt.diagnostics = [failureDiagnostic({
|
||
code: 'viewer/chrome-unavailable',
|
||
severity: 'warning',
|
||
message: receipt.error,
|
||
subject: { artifact },
|
||
evidence: { executable: null },
|
||
supportedFixes: [`set ARCHIFY_CHROME to a Chrome or Chromium executable and rerun ${command}`],
|
||
})];
|
||
const publication = publishReceiptOnly(artifact, outputs, receipt, ownership);
|
||
if (publication.ok) publishedBrowserEvidenceReceipts.add(receipt);
|
||
if (!publication.ok) {
|
||
appendEvidencePublicationFailure(receipt, publication);
|
||
return { exitCode: EXIT.fail, receipt };
|
||
}
|
||
if (appendEvidenceCleanupWarning(receipt, publication)) {
|
||
return { exitCode: EXIT.fail, receipt };
|
||
}
|
||
return { exitCode: EXIT.skipped, receipt };
|
||
}
|
||
|
||
let browser;
|
||
try {
|
||
browser = await browserFactory(resolvedChrome);
|
||
const observations = new Map();
|
||
const screenshotsByKey = new Map(outputs.screenshots.map((entry, index) => [
|
||
screenshotKey(entry.width, entry.height, entry.theme),
|
||
{ ...entry, stagedPath: ownership.stagedOutputs.screenshots[index].path },
|
||
]));
|
||
|
||
for (const viewport of VISUAL_CHECK_VIEWPORTS) {
|
||
const key = screenshotKey(viewport.width, viewport.height, 'light');
|
||
const screenshot = screenshotsByKey.get(key);
|
||
const metrics = await browser.inspect({
|
||
artifactPath: inspectionArtifact,
|
||
...viewport,
|
||
theme: 'light',
|
||
...(screenshot ? {
|
||
screenshotPath: screenshot.stagedPath,
|
||
writeScreenshot: (bytes) => writeStagedEvidence(ownership, screenshot.path, bytes),
|
||
} : {}),
|
||
});
|
||
verifyInspectionArtifact();
|
||
if (screenshot) {
|
||
if (screenshot && !ownership.stagedEntries.has(screenshot.path)) {
|
||
registerStagedEvidence(ownership, screenshot.path);
|
||
} else {
|
||
const staged = ownership.stagedEntries.get(screenshot.path);
|
||
if (!currentEvidenceMatches(staged.path, staged.identity, staged.evidence)) {
|
||
throw new Error('The staged visual-check screenshot changed after capture.');
|
||
}
|
||
}
|
||
}
|
||
observations.set(key, observation({ ...viewport, theme: 'light', metrics }));
|
||
}
|
||
for (const viewport of CAPTURE_VIEWPORTS) {
|
||
const key = screenshotKey(viewport.width, viewport.height, 'dark');
|
||
const screenshot = screenshotsByKey.get(key);
|
||
const metrics = await browser.inspect({
|
||
artifactPath: inspectionArtifact,
|
||
...viewport,
|
||
theme: 'dark',
|
||
...(screenshot ? {
|
||
screenshotPath: screenshot.stagedPath,
|
||
writeScreenshot: (bytes) => writeStagedEvidence(ownership, screenshot.path, bytes),
|
||
} : {}),
|
||
});
|
||
verifyInspectionArtifact();
|
||
if (screenshot && !ownership.stagedEntries.has(screenshot.path)) {
|
||
registerStagedEvidence(ownership, screenshot.path);
|
||
} else if (screenshot) {
|
||
const staged = ownership.stagedEntries.get(screenshot.path);
|
||
if (!currentEvidenceMatches(staged.path, staged.identity, staged.evidence)) {
|
||
throw new Error('The staged visual-check screenshot changed after capture.');
|
||
}
|
||
}
|
||
observations.set(key, observation({ ...viewport, theme: 'dark', metrics }));
|
||
}
|
||
|
||
let artifactVerification = verifyRegularFileBinding(capturedArtifact.binding);
|
||
if (artifactVerification.status !== 'match') {
|
||
throw regularFileCaptureError(`The delivered artifact changed while ${command} was running`, artifactVerification);
|
||
}
|
||
verifyArtifact?.(artifactBytes);
|
||
artifactVerification = verifyRegularFileBinding(capturedArtifact.binding);
|
||
if (artifactVerification.status !== 'match') {
|
||
throw regularFileCaptureError(`The delivered artifact changed while ${command} was running`, artifactVerification);
|
||
}
|
||
|
||
receipt.containment.viewports = VISUAL_CHECK_VIEWPORTS.map(({ width, height }) => (
|
||
observations.get(screenshotKey(width, height, 'light'))
|
||
));
|
||
receipt.readability.viewports = receipt.containment.viewports.map((entry) => ({ ...entry }));
|
||
receipt.viewerChrome.viewports = receipt.containment.viewports.map((entry) => ({ ...entry }));
|
||
receipt.captures.screenshots = outputs.screenshots.map((entry) => ({
|
||
...observations.get(screenshotKey(entry.width, entry.height, entry.theme)),
|
||
file: path.basename(entry.path),
|
||
}));
|
||
const allObservations = [...observations.values()];
|
||
receipt.themeStates.viewports = allObservations.map((entry) => ({
|
||
width: entry.width,
|
||
height: entry.height,
|
||
requestedTheme: entry.theme,
|
||
resolvedTheme: entry.resolvedTheme,
|
||
ok: entry.resolvedTheme === entry.theme,
|
||
}));
|
||
const themeStatePass = receipt.themeStates.viewports.every(entry => entry.ok);
|
||
const containmentPass = allObservations.every((entry) => entry.ok);
|
||
const readabilityPass = receipt.readability.viewports.every((entry) => entry.readabilityOk);
|
||
const viewerChromePass = allObservations.every((entry) => entry.viewerChromeOk);
|
||
receipt.diagnostics = observationDiagnostics({
|
||
artifact,
|
||
allObservations,
|
||
readabilityObservations: receipt.readability.viewports, command,
|
||
});
|
||
receipt.themeStates.status = themeStatePass ? 'pass' : 'fail';
|
||
receipt.containment.status = containmentPass ? 'pass' : 'fail';
|
||
receipt.readability.status = readabilityPass ? 'pass' : 'fail';
|
||
receipt.viewerChrome.status = viewerChromePass ? 'pass' : 'fail';
|
||
if (capture) {
|
||
receipt.captures.status = 'pass';
|
||
receipt.captures.contactSheet = path.basename(outputs.contactSheet);
|
||
}
|
||
receipt.status = containmentPass && themeStatePass && readabilityPass && viewerChromePass ? 'pass' : 'fail';
|
||
receipt.ok = containmentPass && themeStatePass && readabilityPass && viewerChromePass;
|
||
if (capture) writeStagedEvidence(ownership, outputs.contactSheet, contactSheetHtml({
|
||
artifactPath: artifact,
|
||
receipt,
|
||
screenshots: receipt.captures.screenshots,
|
||
}));
|
||
recordVisualEvidenceFiles(receipt, outputs, ownership);
|
||
writeStagedEvidence(ownership, outputs.receipt, `${JSON.stringify(receipt, null, 2)}\n`);
|
||
const publication = commitVisualEvidence(
|
||
artifact,
|
||
outputs,
|
||
ownership,
|
||
visualEvidenceTargets(outputs).map((target) => target.path),
|
||
);
|
||
if (!publication.ok) {
|
||
appendEvidencePublicationFailure(receipt, publication);
|
||
receipt.ok = false;
|
||
receipt.status = 'fail';
|
||
return { exitCode: EXIT.fail, receipt };
|
||
}
|
||
publishedBrowserEvidenceReceipts.add(receipt);
|
||
if (appendEvidenceCleanupWarning(receipt, publication)) {
|
||
return { exitCode: EXIT.fail, receipt };
|
||
}
|
||
return { exitCode: receipt.ok ? EXIT.pass : EXIT.fail, receipt };
|
||
} catch (error) {
|
||
const startupTimeout = error.code === 'ERR_CHROME_CDP_TIMEOUT'
|
||
&& error.method === 'Target.getTargets';
|
||
receipt.status = 'fail';
|
||
receipt.ok = false;
|
||
receipt.error = error.message;
|
||
receipt.containment.status = 'fail';
|
||
receipt.readability.status = 'fail';
|
||
receipt.viewerChrome.status = 'fail';
|
||
receipt.themeStates.status = 'fail';
|
||
receipt.captures.status = capture ? 'fail' : 'not-requested';
|
||
receipt.captures.screenshots = [];
|
||
receipt.captures.contactSheet = null;
|
||
if (error.deliveryProvenance) receipt.provenance = error.deliveryProvenance.status;
|
||
receipt.diagnostics = error.archifyDiagnostics || [failureDiagnostic({
|
||
code: startupTimeout ? 'viewer/chrome-startup-timeout' : `viewer/${command}-runtime`,
|
||
message: startupTimeout
|
||
? 'Chrome did not finish its initial DevTools handshake within the startup window.'
|
||
: `${command} could not complete its Chrome inspection.`,
|
||
subject: { artifact },
|
||
evidence: { reason: error.message },
|
||
supportedFixes: startupTimeout
|
||
? [
|
||
'do not edit or simplify the artifact because this is a browser-startup failure',
|
||
`retry ${command} once after host load subsides; if it repeats, stop and report the environment failure`,
|
||
]
|
||
: [`resolve the reported Chrome inspection error, then rerun ${command}`],
|
||
})];
|
||
return {
|
||
exitCode: EXIT.fail,
|
||
receipt: persistBrowserEvidenceFailure(artifact, receipt, {
|
||
outDir,
|
||
compareSidecarParents,
|
||
ownership, command, capture,
|
||
}),
|
||
};
|
||
} finally {
|
||
if (browser?.close) await browser.close();
|
||
}
|
||
} finally {
|
||
releaseRegularFileBinding(capturedArtifact.binding);
|
||
}
|
||
}
|
||
|
||
export function persistVisualCheckFailure(artifactPath, failure, options = {}) {
|
||
return persistBrowserEvidenceFailure(artifactPath, failure, { ...options, command: 'visual-check', capture: true });
|
||
}
|
||
export function persistBrowserCheckFailure(artifactPath, failure, options = {}) {
|
||
return persistBrowserEvidenceFailure(artifactPath, failure, { ...options, command: 'browser-check', capture: false });
|
||
}
|
||
export function runVisualCheck(options = {}) {
|
||
return runBrowserEvidence({ ...options, command: 'visual-check', capture: true });
|
||
}
|
||
export function runBrowserCheck(options = {}) {
|
||
return runBrowserEvidence({ ...options, command: 'browser-check', capture: false });
|
||
}
|