mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-07 12:17:50 +00:00
355 lines
13 KiB
JavaScript
355 lines
13 KiB
JavaScript
import { createHash } from 'node:crypto';
|
|
import { createReadStream } from 'node:fs';
|
|
import { lstat, readdir, readlink, realpath } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { overlayInsetFailures, pageHeaderFailures } from './layout-contract.mjs';
|
|
|
|
export const schema = 'packaged-macos-ui-conformance/v1';
|
|
export const seededCases = [
|
|
['shell-blank', 'shell does not fill viewport'],
|
|
['clipped-modal', 'clipped overlay'],
|
|
['heading-offset', 'inconsistent primary heading/Back geometry'],
|
|
['context-invalid-header', 'context-invalid board rail control'],
|
|
['overlay-padding', 'inconsistent overlay header padding'],
|
|
['dead-header', 'dead header control'],
|
|
];
|
|
export const routes = [
|
|
['activity', '/activity', 'Activity'],
|
|
['backlog', '/backlog', 'Backlog'],
|
|
['archive', '/archive', 'Archive'],
|
|
['templates', '/templates', 'Task Templates'],
|
|
['workflows', '/workflows', 'Workflows'],
|
|
['operations', '/operations', 'Operations Digest'],
|
|
['evidence', '/evidence', 'Evidence Timeline'],
|
|
['time', '/time', 'Time Breakdowns'],
|
|
['drift', '/drift', 'Behavioral Drift Monitor'],
|
|
['decisions', '/decisions', 'Decision Audit Trail'],
|
|
['scoring', '/scoring', 'Agent Output Scoring'],
|
|
['policies', '/policies', 'Agent Policies'],
|
|
];
|
|
export const settingsSections = [
|
|
'General',
|
|
'Tasks',
|
|
'Notifications',
|
|
'Scheduler',
|
|
'Security',
|
|
'Maintenance',
|
|
];
|
|
export const modes = ['light', 'dark'].flatMap((theme) => [
|
|
{ id: `${theme}-normal`, theme, width: 1700, height: 1000 },
|
|
{ id: `${theme}-minimum`, theme, width: 1180, height: 760 },
|
|
]);
|
|
// The runner cannot reduce this list when a scenario fails or is not implemented.
|
|
export const states = [
|
|
'board',
|
|
'left-rail',
|
|
'right-rail',
|
|
'board-chat',
|
|
'squad-chat',
|
|
'task-chat',
|
|
...routes.map(([id]) => `route-${id}`),
|
|
...settingsSections.map((label) => `settings-${label.toLowerCase()}`),
|
|
'create-task',
|
|
'create-template',
|
|
'edit-template',
|
|
'search',
|
|
'command-palette',
|
|
'task-drawer',
|
|
'task-expanded',
|
|
'preview',
|
|
'confirmation',
|
|
'header-controls',
|
|
'responsive-collapse',
|
|
'relaunch',
|
|
];
|
|
export const requiredEntries = modes.flatMap((mode) =>
|
|
states.map((state) => `${mode.id}/${state}`)
|
|
);
|
|
export function requiresOverlay(id) {
|
|
const state = id.split('/')[1];
|
|
return (
|
|
state?.startsWith('settings-') ||
|
|
[
|
|
'task-chat',
|
|
'task-drawer',
|
|
'task-expanded',
|
|
'create-task',
|
|
'create-template',
|
|
'edit-template',
|
|
'search',
|
|
'command-palette',
|
|
'preview',
|
|
'confirmation',
|
|
].includes(state)
|
|
);
|
|
}
|
|
|
|
// Expected production parts are keyed by the exercised surface, not whatever CSS
|
|
// selectors happen to survive a regression in that surface.
|
|
export function requiredOverlayParts(id) {
|
|
const state = id.split('/')[1];
|
|
if (['task-chat', 'task-drawer', 'task-expanded'].includes(state))
|
|
return ['task-header', 'task-body'];
|
|
if (state?.startsWith('settings-')) return ['header', 'body', 'scroll'];
|
|
if (state === 'search') return ['header', 'body'];
|
|
if (requiresOverlay(id) || ['clipped-modal', 'overlay-padding'].includes(state))
|
|
return ['header', 'body', 'scroll', 'footer'];
|
|
return [];
|
|
}
|
|
|
|
export function seededObservedFailures(seed) {
|
|
if (!seed?.geometry) return ['missing seeded geometry'];
|
|
const errors = geometryFailures(seed.geometry);
|
|
if (['seed/heading-offset', 'seed/context-invalid-header'].includes(seed.id))
|
|
errors.push(
|
|
...pageHeaderFailures(
|
|
seed.geometry.primaryHeader,
|
|
seed.geometry.rem,
|
|
'Activity',
|
|
seed.geometry
|
|
)
|
|
);
|
|
if (seed.id === 'seed/dead-header') {
|
|
const action = seed.behavior;
|
|
if (
|
|
action?.control === 'New Task' &&
|
|
action.visibleBefore === true &&
|
|
action.enabledBefore === true &&
|
|
action.dialogVisibleBefore === false &&
|
|
action.clickCompleted === true &&
|
|
action.dialogVisibleAfter === false &&
|
|
Number.isFinite(action.waitedMs) &&
|
|
action.waitedMs >= 1000
|
|
)
|
|
errors.push('dead header control');
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
export async function fileDigest(file) {
|
|
const hash = createHash('sha256');
|
|
for await (const chunk of createReadStream(file)) hash.update(chunk);
|
|
return hash.digest('hex');
|
|
}
|
|
|
|
// Hash the whole bundle, including packaged web/server resources and native binaries,
|
|
// not just app.asar. Symlink text is included without following framework cycles.
|
|
export async function packageDigest(directory) {
|
|
directory = await realpath(directory);
|
|
const hash = createHash('sha256');
|
|
async function walk(relative) {
|
|
const absolute = path.join(directory, relative);
|
|
const stat = await lstat(absolute);
|
|
if (stat.isSymbolicLink()) {
|
|
const target = path.relative(directory, await realpath(absolute));
|
|
if (target === '..' || target.startsWith(`..${path.sep}`) || path.isAbsolute(target))
|
|
throw new Error(`Package symlink escapes bundle: ${relative}`);
|
|
hash.update(JSON.stringify([relative, 'link', await readlink(absolute)]));
|
|
} else if (stat.isDirectory()) {
|
|
hash.update(JSON.stringify([relative, 'directory']));
|
|
for (const child of (await readdir(absolute)).sort()) await walk(path.join(relative, child));
|
|
} else if (stat.isFile())
|
|
hash.update(JSON.stringify([relative, stat.mode & 0o777, await fileDigest(absolute)]));
|
|
else throw new Error(`Unsupported package entry: ${relative}`);
|
|
}
|
|
await walk('');
|
|
return hash.digest('hex');
|
|
}
|
|
|
|
export function geometryFailures(g) {
|
|
if (!g) return ['missing geometry'];
|
|
const failures = [];
|
|
if (!Number.isFinite(g.rem) || g.rem <= 0) failures.push('missing root font size');
|
|
const validRect = (r) =>
|
|
[r.x, r.y, r.width, r.height, r.right, r.bottom].every(Number.isFinite) &&
|
|
r.width > 0 &&
|
|
r.height > 0 &&
|
|
Math.abs(r.right - r.x - r.width) <= 1 &&
|
|
Math.abs(r.bottom - r.y - r.height) <= 1;
|
|
if (
|
|
![
|
|
g.width,
|
|
g.height,
|
|
g.scrollWidth,
|
|
g.scrollHeight,
|
|
g.shell?.x,
|
|
g.shell?.y,
|
|
g.shell?.width,
|
|
g.shell?.height,
|
|
].every(Number.isFinite)
|
|
)
|
|
return ['invalid geometry'];
|
|
if (g.scrollWidth > g.width + 1 || g.scrollHeight > g.height + 1)
|
|
failures.push('document shell overflow');
|
|
if (
|
|
Math.abs(g.shell.x) > 1 ||
|
|
Math.abs(g.shell.y) > 1 ||
|
|
Math.abs(g.shell.width - g.width) > 1 ||
|
|
Math.abs(g.shell.height - g.height) > 1
|
|
)
|
|
failures.push('shell does not fill viewport');
|
|
for (const overlay of g.overlays ?? []) {
|
|
failures.push(...overlayInsetFailures(overlay, g.rem));
|
|
if (!validRect(overlay) || !Number.isFinite(overlay.overflow))
|
|
failures.push('unmeasured overlay');
|
|
if (!Number.isFinite(overlay.opacity) || overlay.opacity < 0.999 || overlay.opacity > 1)
|
|
failures.push('transparent or unmeasured overlay');
|
|
if (
|
|
!Array.isArray(overlay.parts) ||
|
|
!['header', 'body'].every((name) => overlay.parts.some((part) => part.name === name))
|
|
)
|
|
failures.push('missing overlay header/body');
|
|
if (
|
|
overlay.x < 0 ||
|
|
overlay.y < 0 ||
|
|
overlay.right > g.width + 1 ||
|
|
overlay.bottom > g.height + 1 ||
|
|
overlay.overflow > 1
|
|
)
|
|
failures.push('clipped overlay');
|
|
for (const part of overlay.parts ?? []) {
|
|
if (
|
|
!validRect(part) ||
|
|
part.x < overlay.x - 1 ||
|
|
part.y < overlay.y - 1 ||
|
|
part.right > overlay.right + 1 ||
|
|
part.bottom > overlay.bottom + 1 ||
|
|
!Array.isArray(part.padding) ||
|
|
part.padding.length !== 4 ||
|
|
!part.padding.every((value) => Number.isFinite(value) && value >= 0)
|
|
)
|
|
failures.push(`unreachable or unmeasured overlay ${part.name}`);
|
|
}
|
|
}
|
|
return failures;
|
|
}
|
|
|
|
export function evidenceFailures(report, expected, now = Date.now()) {
|
|
const errors = [];
|
|
if (report?.schema !== schema) return ['unknown or missing native evidence schema'];
|
|
if (report.status !== 'passed') errors.push('native run did not pass');
|
|
if (
|
|
report.boundary !== 'packaged-macos' ||
|
|
report.identity?.packaged !== true ||
|
|
report.identity?.platform !== 'darwin'
|
|
)
|
|
errors.push('not a packaged macOS run');
|
|
if (report.identity?.buildIdentity !== expected.commit || report.commit !== expected.commit)
|
|
errors.push('candidate commit mismatch');
|
|
if (report.packageDigest !== expected.packageDigest) errors.push('candidate package mismatch');
|
|
if (report.version !== expected.version || report.identity?.version !== expected.version)
|
|
errors.push('candidate version mismatch');
|
|
if (report.dirty !== false) errors.push('candidate checkout was dirty');
|
|
const completed = Date.parse(report.completedAt);
|
|
if (!Number.isFinite(completed) || completed > now || now - completed > 24 * 60 * 60 * 1000)
|
|
errors.push('missing or stale native evidence');
|
|
const entries = report.entries ?? [];
|
|
const seeds = report.seededFailures ?? [];
|
|
if (
|
|
seeds.length !== seededCases.length ||
|
|
new Set(seeds.map((seed) => seed.id)).size !== seededCases.length
|
|
)
|
|
errors.push('missing or duplicate seeded renderer checks');
|
|
for (const [id, reason] of seededCases) {
|
|
const seed = seeds.find((entry) => entry.id === `seed/${id}`);
|
|
if (
|
|
seed?.status !== 'detected' ||
|
|
!seed.observedFailures?.includes(reason) ||
|
|
!seededObservedFailures(seed).includes(reason) ||
|
|
!seed.screenshot?.path
|
|
)
|
|
errors.push(`seed/${id}: injected renderer failure not detected`);
|
|
}
|
|
if (
|
|
entries.length !== requiredEntries.length ||
|
|
new Set(entries.map((e) => e.id)).size !== requiredEntries.length
|
|
)
|
|
errors.push('duplicate or incomplete native matrix');
|
|
for (const id of requiredEntries) {
|
|
const entry = entries.find((e) => e.id === id);
|
|
if (!entry || entry.status !== 'passed') {
|
|
errors.push(`${id}: missing or failed`);
|
|
continue;
|
|
}
|
|
const mode = modes.find((m) => id.startsWith(`${m.id}/`));
|
|
if (
|
|
entry.theme !== mode.theme ||
|
|
entry.geometry?.width !== mode.width ||
|
|
entry.geometry?.height !== mode.height ||
|
|
entry.completionGeometry?.width !== mode.width ||
|
|
entry.completionGeometry?.height !== mode.height
|
|
)
|
|
errors.push(`${id}: wrong theme or dimensions`);
|
|
if (!entry.screenshot?.path || !/^[a-f0-9]{64}$/.test(entry.screenshot.sha256 ?? ''))
|
|
errors.push(`${id}: missing screenshot`);
|
|
const native = entry.nativeWindow;
|
|
const validBounds = (bounds) =>
|
|
bounds &&
|
|
[bounds.x, bounds.y, bounds.width, bounds.height].every(Number.isFinite) &&
|
|
bounds.width > 0 &&
|
|
bounds.height > 0;
|
|
if (
|
|
!Number.isFinite(native?.scaleFactor) ||
|
|
native.scaleFactor <= 0 ||
|
|
!validBounds(native?.bounds) ||
|
|
!validBounds(native?.contentBounds) ||
|
|
native.contentBounds.width !== entry.geometry?.width ||
|
|
native.contentBounds.height !== entry.geometry?.height ||
|
|
!report.identity?.osVersion
|
|
)
|
|
errors.push(`${id}: missing native environment`);
|
|
if (requiresOverlay(id) && !entry.geometry?.overlays?.length)
|
|
errors.push(`${id}: missing required overlay`);
|
|
for (const overlay of entry.geometry?.overlays ?? []) {
|
|
for (const kind of requiredOverlayParts(id))
|
|
if (!overlay.parts?.some((part) => part.insetKind === kind))
|
|
errors.push(`${id}: missing required overlay ${kind}`);
|
|
}
|
|
const route = routes.find(([name]) => id.endsWith(`/route-${name}`));
|
|
if (route)
|
|
errors.push(
|
|
...pageHeaderFailures(
|
|
entry.geometry?.primaryHeader,
|
|
entry.geometry?.rem,
|
|
route[2],
|
|
entry.geometry
|
|
).map((error) => `${id}: ${error}`)
|
|
);
|
|
errors.push(...geometryFailures(entry.geometry).map((error) => `${id}: ${error}`));
|
|
errors.push(
|
|
...geometryFailures(entry.completionGeometry).map(
|
|
(error) => `${id}: after interaction: ${error}`
|
|
)
|
|
);
|
|
}
|
|
for (const mode of modes) {
|
|
const headers = routes.map(
|
|
([name]) =>
|
|
entries.find((entry) => entry.id === `${mode.id}/route-${name}`)?.geometry?.primaryHeader
|
|
);
|
|
const baseline = headers[0];
|
|
if (!baseline) continue;
|
|
for (const header of headers.slice(1)) {
|
|
if (!header) continue;
|
|
const values = [
|
|
[header.header?.y, baseline.header?.y],
|
|
[header.title?.y, baseline.title?.y],
|
|
];
|
|
if (mode.width === 1700)
|
|
values.push(
|
|
[header.header?.height, baseline.header?.height],
|
|
[header.content?.y, baseline.content?.y]
|
|
);
|
|
if (
|
|
values.some(
|
|
([actual, expected]) =>
|
|
!Number.isFinite(actual) ||
|
|
!Number.isFinite(expected) ||
|
|
Math.abs(actual - expected) > 2
|
|
)
|
|
)
|
|
errors.push(`${mode.id}: inconsistent route header baseline`);
|
|
}
|
|
}
|
|
return errors;
|
|
}
|