- Added a new Archify skill, enabling users to create polished architecture, workflow, sequence, data-flow, and lifecycle diagrams. - Implemented comprehensive functionality including rendering, validation, and delivery of diagrams in various formats. - Integrated a user-friendly command-line interface for generating and previewing diagrams. - Developed supporting files including package.json, LICENSE, and SKILL.md for documentation and licensing. - Added unit tests to ensure reliability and functionality of the new skill. These changes enhance the application by providing a structured approach to visualizing system architecture and workflows, improving user experience and data representation.
1841 lines
98 KiB
JavaScript
1841 lines
98 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import { execFileSync, spawn } from 'node:child_process';
|
||
import { once } from 'node:events';
|
||
import fs from 'node:fs';
|
||
import net from 'node:net';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
const skillRoot = path.resolve(__dirname, '..');
|
||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-webm-artifact-'));
|
||
const externalReachSource = process.env.ARCHIFY_REACH_CARD_SOURCE
|
||
? path.resolve(process.env.ARCHIFY_REACH_CARD_SOURCE)
|
||
: '';
|
||
const externalReachOutput = process.env.ARCHIFY_REACH_CARD_OUTPUT
|
||
? path.resolve(process.env.ARCHIFY_REACH_CARD_OUTPUT)
|
||
: '';
|
||
assert.equal(Boolean(externalReachSource), Boolean(externalReachOutput), 'ARCHIFY_REACH_CARD_SOURCE and ARCHIFY_REACH_CARD_OUTPUT must be set together');
|
||
|
||
function executable(candidates) {
|
||
for (const candidate of candidates) {
|
||
if (!candidate) continue;
|
||
if (candidate.includes(path.sep)) {
|
||
if (fs.existsSync(candidate)) return candidate;
|
||
continue;
|
||
}
|
||
try {
|
||
return execFileSync('sh', ['-c', `command -v "$1"`, 'archify-which', candidate], { encoding: 'utf8' }).trim();
|
||
} catch (_) {
|
||
// Try the next platform-specific name.
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
const chrome = executable([
|
||
process.env.ARCHIFY_CHROME,
|
||
'google-chrome',
|
||
'google-chrome-stable',
|
||
'chromium',
|
||
'chromium-browser',
|
||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||
]);
|
||
const ffmpeg = executable([process.env.ARCHIFY_FFMPEG, 'ffmpeg']);
|
||
|
||
assert.ok(chrome, 'Chrome/Chromium is required for the WebM artifact smoke test (or set ARCHIFY_CHROME)');
|
||
assert.ok(ffmpeg, 'ffmpeg is required for the WebM artifact smoke test (or set ARCHIFY_FFMPEG)');
|
||
|
||
const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/web-app.architecture.json'), 'utf8'));
|
||
source.meta.animation = 'trace';
|
||
source.meta.visual_preset = 'signal-flow';
|
||
|
||
const input = path.join(tmp, 'motion.architecture.json');
|
||
const output = path.join(tmp, 'motion.html');
|
||
fs.writeFileSync(input, JSON.stringify(source));
|
||
execFileSync(process.execPath, [
|
||
path.join(skillRoot, 'renderers/architecture/render-architecture.mjs'),
|
||
input,
|
||
output,
|
||
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||
|
||
const sequenceOutput = path.join(tmp, 'sequence.html');
|
||
execFileSync(process.execPath, [
|
||
path.join(skillRoot, 'renderers/sequence/render-sequence.mjs'),
|
||
path.join(skillRoot, 'examples/cache-miss-request.sequence.json'),
|
||
sequenceOutput,
|
||
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||
|
||
const routeOutputs = {
|
||
architecture: output,
|
||
sequence: sequenceOutput,
|
||
};
|
||
for (const [mode, example] of Object.entries({
|
||
workflow: 'agent-tool-call.workflow.json',
|
||
dataflow: 'product-analytics.dataflow.json',
|
||
lifecycle: 'agent-run.lifecycle.json',
|
||
})) {
|
||
const rendered = path.join(tmp, `${mode}.html`);
|
||
execFileSync(process.execPath, [
|
||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||
path.join(skillRoot, 'examples', example),
|
||
rendered,
|
||
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||
routeOutputs[mode] = rendered;
|
||
}
|
||
|
||
function renderLegendFixture(mode, name, document) {
|
||
const inputPath = path.join(tmp, `${name}.${mode}.json`);
|
||
const outputPath = path.join(tmp, `${name}.${mode}.html`);
|
||
fs.writeFileSync(inputPath, JSON.stringify(document));
|
||
execFileSync(process.execPath, [
|
||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||
inputPath,
|
||
outputPath,
|
||
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||
return outputPath;
|
||
}
|
||
|
||
const legendOutputs = {
|
||
dataflow: renderLegendFixture('dataflow', 'issue-52-default-flow', {
|
||
schema_version: 1,
|
||
diagram_type: 'dataflow',
|
||
meta: { title: 'Default Flow With Store' },
|
||
stages: [{ label: 'Input' }, { label: 'Output' }],
|
||
nodes: [
|
||
{ id: 'input', type: 'backend', label: 'Input', stage: 0, row: 0 },
|
||
{ id: 'output', type: 'database', label: 'Output Store', stage: 1, row: 0 },
|
||
],
|
||
flows: [{ from: 'input', to: 'output', label: 'request', route: 'straight' }],
|
||
}),
|
||
lifecycle: renderLegendFixture('lifecycle', 'issue-52-no-waiting', {
|
||
schema_version: 1,
|
||
diagram_type: 'lifecycle',
|
||
meta: { title: 'No Waiting or Failure', viewBox: [720, 566] },
|
||
lanes: [{ id: 'main', label: 'Lifecycle' }],
|
||
states: [
|
||
{ id: 'started', type: 'start', label: 'Started', lane: 'main', col: 0 },
|
||
{ id: 'running', type: 'active', label: 'Running', lane: 'main', col: 1 },
|
||
{ id: 'completed', type: 'success', label: 'Completed', lane: 'main', col: 2 },
|
||
],
|
||
transitions: [{ from: 'started', to: 'running' }, { from: 'running', to: 'completed' }],
|
||
}),
|
||
custom: renderLegendFixture('architecture', 'issue-52-custom-label', {
|
||
schema_version: 1,
|
||
diagram_type: 'architecture',
|
||
meta: {
|
||
title: 'Custom Legend Label',
|
||
viewBox: [720, 420],
|
||
legend: {
|
||
entries: {
|
||
frontend: { label: 'Reader <UI> & ops' },
|
||
external: { label: 'Future integration', visible: true },
|
||
},
|
||
},
|
||
views: [{ id: 'main', label: 'Main', focus: ['ui', 'store'] }],
|
||
},
|
||
components: [
|
||
{ id: 'ui', type: 'frontend', label: 'UI', pos: [60, 90] },
|
||
{ id: 'store', type: 'database', label: 'Store', pos: [300, 90] },
|
||
],
|
||
connections: [],
|
||
}),
|
||
hidden: renderLegendFixture('dataflow', 'issue-52-hidden', {
|
||
schema_version: 1,
|
||
diagram_type: 'dataflow',
|
||
meta: { title: 'Hidden Legend', legend: { mode: 'hidden', entries: { database: { visible: true } } } },
|
||
stages: [{ label: 'Input' }, { label: 'Output' }],
|
||
nodes: [
|
||
{ id: 'input', type: 'backend', label: 'Input', stage: 0, row: 0 },
|
||
{ id: 'output', type: 'backend', label: 'Output', stage: 1, row: 0 },
|
||
],
|
||
flows: [{ from: 'input', to: 'output', label: 'request', route: 'straight' }],
|
||
}),
|
||
};
|
||
|
||
const parallelSource = JSON.parse(JSON.stringify(source));
|
||
parallelSource.meta.title = 'Parallel Route Identity';
|
||
parallelSource.connections[0].label = '';
|
||
parallelSource.connections.splice(1, 0, {
|
||
id: 'users-to-cdn-alternate',
|
||
from: 'users',
|
||
to: 'cdn',
|
||
label: '',
|
||
});
|
||
const parallelInput = path.join(tmp, 'parallel.architecture.json');
|
||
const parallelOutput = path.join(tmp, 'parallel.html');
|
||
fs.writeFileSync(parallelInput, JSON.stringify(parallelSource));
|
||
execFileSync(process.execPath, [
|
||
path.join(skillRoot, 'renderers/architecture/render-architecture.mjs'),
|
||
parallelInput,
|
||
parallelOutput,
|
||
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||
|
||
const specialSourceLabel = '入口 <script>window.__routeLabelExecuted = true</script> 汉字 🚀 with an intentionally very long endpoint label';
|
||
const specialTargetLabel = '终点服务 ✅ emoji + CJK + a second deliberately long endpoint label for header fitting';
|
||
const specialComponents = Array.from({ length: 11 }, (_, index) => ({
|
||
id: index === 0 ? 'Route_Source-01' : index === 10 ? 'Route_Target-10' : `route_step-${index}`,
|
||
type: index === 0 ? 'external' : index === 10 ? 'database' : 'backend',
|
||
label: index === 0 ? specialSourceLabel : index === 10 ? specialTargetLabel : `步骤 ${index} · service_${index} ⚙️`,
|
||
sublabel: `hop ${index}`,
|
||
pos: [40 + index * 820, 280],
|
||
size: [index === 0 || index === 10 ? 780 : 220, 72],
|
||
}));
|
||
const specialRouteSource = {
|
||
schema_version: 1,
|
||
diagram_type: 'architecture',
|
||
meta: {
|
||
title: '多语言 Route Share Card 🚀 with a deliberately long original diagram title that must fit safely',
|
||
subtitle: 'Ten exact authored hops',
|
||
animation: 'trace',
|
||
},
|
||
components: specialComponents,
|
||
boundaries: [],
|
||
connections: specialComponents.slice(0, -1).map((component, index) => ({
|
||
id: `route_edge-${index + 1}`,
|
||
from: component.id,
|
||
to: specialComponents[index + 1].id,
|
||
label: '',
|
||
})),
|
||
cards: [],
|
||
};
|
||
const specialRouteInput = path.join(tmp, 'special-route.architecture.json');
|
||
const specialRouteOutput = path.join(tmp, 'special-route.html');
|
||
fs.writeFileSync(specialRouteInput, JSON.stringify(specialRouteSource));
|
||
execFileSync(process.execPath, [
|
||
path.join(skillRoot, 'renderers/architecture/render-architecture.mjs'),
|
||
specialRouteInput,
|
||
specialRouteOutput,
|
||
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||
|
||
assert.equal(typeof WebSocket, 'function', 'Node.js 22+ is required for the Chrome DevTools smoke harness');
|
||
|
||
function delay(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
async function waitForExit(child, timeoutMs) {
|
||
if (child.exitCode !== null || child.signalCode !== null) return true;
|
||
return Promise.race([
|
||
once(child, 'exit').then(() => true),
|
||
delay(timeoutMs).then(() => false),
|
||
]);
|
||
}
|
||
|
||
async function removeTempTree(directory) {
|
||
const transientCodes = new Set(['EBUSY', 'ENOTEMPTY', 'EPERM']);
|
||
let lastError;
|
||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||
try {
|
||
fs.rmSync(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||
return;
|
||
} catch (error) {
|
||
if (!transientCodes.has(error?.code)) throw error;
|
||
lastError = error;
|
||
await delay(100 * (attempt + 1));
|
||
}
|
||
}
|
||
console.warn(`warning: temporary Chrome profile cleanup deferred (${lastError?.code || 'unknown'}): ${directory}`);
|
||
}
|
||
|
||
async function withTimeout(promise, ms, label) {
|
||
let timer;
|
||
try {
|
||
return await Promise.race([
|
||
promise,
|
||
new Promise((_, reject) => {
|
||
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
||
}),
|
||
]);
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
async function freePort() {
|
||
const server = net.createServer();
|
||
await new Promise((resolve, reject) => {
|
||
server.once('error', reject);
|
||
server.listen(0, '127.0.0.1', resolve);
|
||
});
|
||
const port = server.address().port;
|
||
await new Promise((resolve) => server.close(resolve));
|
||
return port;
|
||
}
|
||
|
||
async function devtoolsEndpoint(port, chromeProcess, diagnostics) {
|
||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||
try {
|
||
const response = await fetch(`http://127.0.0.1:${port}/json/version`);
|
||
if (response.ok) return (await response.json()).webSocketDebuggerUrl;
|
||
} catch (_) {
|
||
// Chrome may need a moment to bind the debugging port.
|
||
}
|
||
if (chromeProcess.exitCode !== null) break;
|
||
await delay(50);
|
||
}
|
||
const stderr = diagnostics().trim();
|
||
const exit = chromeProcess.exitCode === null ? 'still running' : `exited with code ${chromeProcess.exitCode}`;
|
||
throw new Error(`Chrome did not expose a DevTools endpoint (${exit})${stderr ? `:\n${stderr}` : ''}`);
|
||
}
|
||
|
||
async function connectCdp(webSocketUrl) {
|
||
const socket = new WebSocket(webSocketUrl);
|
||
await new Promise((resolve, reject) => {
|
||
socket.addEventListener('open', resolve, { once: true });
|
||
socket.addEventListener('error', reject, { once: true });
|
||
});
|
||
let nextId = 0;
|
||
const pending = new Map();
|
||
socket.addEventListener('message', (event) => {
|
||
const message = JSON.parse(String(event.data));
|
||
if (!message.id || !pending.has(message.id)) return;
|
||
const request = pending.get(message.id);
|
||
pending.delete(message.id);
|
||
if (message.error) request.reject(new Error(message.error.message));
|
||
else request.resolve(message.result);
|
||
});
|
||
socket.addEventListener('close', () => {
|
||
for (const request of pending.values()) request.reject(new Error('Chrome DevTools connection closed'));
|
||
pending.clear();
|
||
});
|
||
return {
|
||
socket,
|
||
send(method, params = {}, sessionId) {
|
||
const id = ++nextId;
|
||
return new Promise((resolve, reject) => {
|
||
pending.set(id, { resolve, reject });
|
||
socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
||
});
|
||
},
|
||
};
|
||
}
|
||
|
||
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 || 'browser evaluation failed');
|
||
}
|
||
return response.result?.value;
|
||
}
|
||
|
||
const port = await freePort();
|
||
let chromeStderr = '';
|
||
const chromeProcess = spawn(chrome, [
|
||
'--headless=new',
|
||
'--disable-gpu',
|
||
'--disable-dev-shm-usage',
|
||
'--no-sandbox',
|
||
'--allow-file-access-from-files',
|
||
'--autoplay-policy=no-user-gesture-required',
|
||
'--log-level=3',
|
||
`--user-data-dir=${path.join(tmp, 'chrome-profile')}`,
|
||
'--remote-debugging-address=127.0.0.1',
|
||
`--remote-debugging-port=${port}`,
|
||
'about:blank',
|
||
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||
chromeProcess.stderr.setEncoding('utf8');
|
||
chromeProcess.stderr.on('data', (chunk) => {
|
||
chromeStderr = `${chromeStderr}${chunk}`.slice(-64 * 1024);
|
||
});
|
||
|
||
let cdp;
|
||
let targetId;
|
||
|
||
try {
|
||
cdp = await connectCdp(await devtoolsEndpoint(port, chromeProcess, () => chromeStderr));
|
||
({ targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' }));
|
||
await cdp.send('Target.activateTarget', { targetId });
|
||
const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
|
||
await cdp.send('Page.enable', {}, sessionId);
|
||
await cdp.send('Runtime.enable', {}, sessionId);
|
||
|
||
async function navigateReady(file, condition, label) {
|
||
const url = file instanceof URL ? file.href : pathToFileURL(file).href;
|
||
await cdp.send('Page.navigate', { url }, sessionId);
|
||
await cdp.send('Page.bringToFront', {}, sessionId);
|
||
await cdp.send('Emulation.setFocusEmulationEnabled', { enabled: true }, sessionId);
|
||
let ready = false;
|
||
for (let attempt = 0; attempt < 100 && !ready; attempt += 1) {
|
||
ready = await evaluate(cdp, sessionId, `document.readyState === "complete" && (${condition})`);
|
||
if (!ready) await delay(50);
|
||
}
|
||
assert.equal(ready, true, `${label} did not expose its browser export surface`);
|
||
}
|
||
|
||
async function verifyResolvedLegendContract(outputs) {
|
||
async function inspectKinds(file, expectedKinds, theme) {
|
||
const url = new URL(pathToFileURL(file).href);
|
||
url.searchParams.set('theme', theme);
|
||
await navigateReady(url, '!!(window.Archify && Archify.semanticLens)', `legend ${theme}`);
|
||
const result = await evaluate(cdp, sessionId, `(() => {
|
||
var svg = document.querySelector('.diagram-container > svg');
|
||
var entries = Array.from(svg.querySelectorAll('[data-legend-semantic-kind]'));
|
||
var vb = svg.viewBox.baseVal;
|
||
return {
|
||
theme: document.documentElement.getAttribute('data-theme'),
|
||
kinds: entries.map(function (entry) { return entry.getAttribute('data-legend-semantic-kind'); }),
|
||
bridge: !!svg.querySelector('[data-legend-bridge]'),
|
||
roles: entries.map(function (entry) { return entry.getAttribute('role'); }),
|
||
aria: entries.map(function (entry) { return entry.getAttribute('aria-label'); }),
|
||
counts: entries.map(function (entry) { return entry.getAttribute('data-legend-count'); }),
|
||
tabStops: entries.filter(function (entry) { return entry.getAttribute('tabindex') === '0'; }).length,
|
||
inside: entries.every(function (entry) {
|
||
var box = entry.getBBox();
|
||
return box.x >= vb.x && box.y >= vb.y && box.x + box.width <= vb.x + vb.width && box.y + box.height <= vb.y + vb.height;
|
||
})
|
||
};
|
||
})()`);
|
||
assert.equal(result.theme, theme);
|
||
assert.deepEqual(result.kinds, expectedKinds);
|
||
assert.equal(result.inside, true);
|
||
return result;
|
||
}
|
||
|
||
for (const theme of ['dark', 'light']) {
|
||
const dataflow = await inspectKinds(outputs.dataflow, ['database', 'default'], theme);
|
||
assert.equal(dataflow.bridge, true);
|
||
assert.deepEqual(dataflow.roles, ['button', null]);
|
||
assert.deepEqual(dataflow.aria, ['Inspect data store, 1 node', null]);
|
||
assert.deepEqual(dataflow.counts, ['1', null]);
|
||
assert.equal(dataflow.tabStops, 1);
|
||
const lifecycle = await inspectKinds(outputs.lifecycle, ['start', 'active', 'success'], theme);
|
||
assert.equal(lifecycle.bridge, true);
|
||
}
|
||
|
||
await navigateReady(outputs.dataflow, '!!(window.Archify && Archify.semanticLens && Archify.exportMenu)', 'Dataflow database legend runtime');
|
||
const databaseRuntime = await evaluate(cdp, sessionId, String.raw`(async function () {
|
||
var entry = document.querySelector('[data-legend-kind="database"]');
|
||
entry.focus();
|
||
entry.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||
var originalCreateObjectURL = URL.createObjectURL;
|
||
var originalAnchorClick = HTMLAnchorElement.prototype.click;
|
||
var captured;
|
||
URL.createObjectURL = function (blob) {
|
||
captured = blob.text();
|
||
return 'blob:archify-dataflow-legend-smoke';
|
||
};
|
||
HTMLAnchorElement.prototype.click = function () {};
|
||
try {
|
||
await Archify.exportMenu.run('svg');
|
||
var exportedText = await captured;
|
||
var exported = new DOMParser().parseFromString(exportedText, 'image/svg+xml').documentElement;
|
||
return {
|
||
selected: Archify.semanticLens.active(),
|
||
lensOpen: Archify.semanticLens.isOpen(),
|
||
exportedKinds: Array.from(exported.querySelectorAll('[data-legend-semantic-kind]')).map(function (item) { return item.getAttribute('data-legend-semantic-kind'); }),
|
||
exportedBridgeResidue: exported.querySelectorAll('[data-legend-bridge], [data-legend-kind], [data-legend-label], [data-legend-count], [data-legend-bridge-runtime]').length
|
||
};
|
||
} finally {
|
||
URL.createObjectURL = originalCreateObjectURL;
|
||
HTMLAnchorElement.prototype.click = originalAnchorClick;
|
||
}
|
||
})()`, true);
|
||
assert.deepEqual(databaseRuntime.selected, ['database']);
|
||
assert.equal(databaseRuntime.lensOpen, true);
|
||
assert.deepEqual(databaseRuntime.exportedKinds, ['database', 'default']);
|
||
assert.equal(databaseRuntime.exportedBridgeResidue, 0);
|
||
|
||
await navigateReady(outputs.custom, '!!(window.Archify && Archify.semanticLens && Archify.exportMenu)', 'custom legend runtime');
|
||
const runtime = await evaluate(cdp, sessionId, String.raw`(async function () {
|
||
var svg = document.querySelector('.diagram-container > svg');
|
||
var entries = Array.from(svg.querySelectorAll('[data-legend-semantic-kind]'));
|
||
var interactive = entries.filter(function (entry) { return entry.hasAttribute('data-legend-kind'); });
|
||
var first = interactive[0];
|
||
var second = interactive[1];
|
||
first.focus();
|
||
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||
var arrowMoved = document.activeElement === second;
|
||
second.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||
var selected = Archify.semanticLens.active();
|
||
var guidedActivated = Archify.guidedViews.activate('main', { updateUrl: false });
|
||
var guidedActive = Archify.guidedViews.active();
|
||
var visualMatrix = [];
|
||
for (var preset of ['classic', 'signal-flow', 'blueprint', 'editorial']) {
|
||
if (!Archify.preset.apply(preset)) throw new Error('could not apply preset ' + preset);
|
||
for (var theme of ['dark', 'light']) {
|
||
document.documentElement.setAttribute('data-theme', theme);
|
||
visualMatrix.push({
|
||
preset: preset,
|
||
theme: theme,
|
||
kinds: entries.map(function (entry) { return entry.getAttribute('data-legend-semantic-kind'); }),
|
||
labels: entries.map(function (entry) { return entry.querySelector('text').textContent; })
|
||
});
|
||
}
|
||
}
|
||
|
||
var originalCreateObjectURL = URL.createObjectURL;
|
||
var originalAnchorClick = HTMLAnchorElement.prototype.click;
|
||
var captured;
|
||
URL.createObjectURL = function (blob) {
|
||
captured = blob.text();
|
||
return 'blob:archify-legend-smoke';
|
||
};
|
||
HTMLAnchorElement.prototype.click = function () {};
|
||
try {
|
||
await Archify.exportMenu.run('svg');
|
||
var exportedText = await captured;
|
||
var exported = new DOMParser().parseFromString(exportedText, 'image/svg+xml').documentElement;
|
||
return {
|
||
labels: interactive.map(function (entry) { return entry.getAttribute('aria-label'); }),
|
||
roles: entries.map(function (entry) { return entry.getAttribute('role'); }),
|
||
counts: interactive.map(function (entry) { return entry.getAttribute('data-legend-count'); }),
|
||
tabStops: interactive.filter(function (entry) { return entry.getAttribute('tabindex') === '0'; }).length,
|
||
arrowMoved: arrowMoved,
|
||
selected: selected,
|
||
lensOpen: Archify.semanticLens.isOpen(),
|
||
guidedActivated: guidedActivated,
|
||
guidedActive: guidedActive,
|
||
visualMatrix: visualMatrix,
|
||
forcedUnusedInteractive: entries.at(-1).hasAttribute('data-legend-kind'),
|
||
exportedKinds: Array.from(exported.querySelectorAll('[data-legend-semantic-kind]')).map(function (entry) { return entry.getAttribute('data-legend-semantic-kind'); }),
|
||
exportedBridgeResidue: exported.querySelectorAll('[data-legend-bridge], [data-legend-kind], [data-legend-label], [data-legend-count], [data-legend-bridge-runtime]').length,
|
||
exportedLabels: Array.from(exported.querySelectorAll('[data-legend-semantic-kind] text')).map(function (text) { return text.textContent; })
|
||
};
|
||
} finally {
|
||
URL.createObjectURL = originalCreateObjectURL;
|
||
HTMLAnchorElement.prototype.click = originalAnchorClick;
|
||
}
|
||
})()`, true);
|
||
assert.deepEqual(runtime.labels, ['Inspect Reader <UI> & ops, 1 node', 'Inspect Database, 1 node']);
|
||
assert.deepEqual(runtime.roles, ['button', 'button', null]);
|
||
assert.deepEqual(runtime.counts, ['1', '1']);
|
||
assert.equal(runtime.tabStops, 1);
|
||
assert.equal(runtime.arrowMoved, true);
|
||
assert.deepEqual(runtime.selected, ['database']);
|
||
assert.equal(runtime.lensOpen, false);
|
||
assert.equal(runtime.guidedActivated, true);
|
||
assert.equal(runtime.guidedActive, 'main');
|
||
assert.equal(runtime.visualMatrix.length, 8);
|
||
for (const entry of runtime.visualMatrix) {
|
||
assert.deepEqual(entry.kinds, ['frontend', 'database', 'external']);
|
||
assert.deepEqual(entry.labels, ['Reader <UI> & ops', 'Database', 'Future integration']);
|
||
}
|
||
assert.equal(runtime.forcedUnusedInteractive, false);
|
||
assert.deepEqual(runtime.exportedKinds, ['frontend', 'database', 'external']);
|
||
assert.equal(runtime.exportedBridgeResidue, 0);
|
||
assert.deepEqual(runtime.exportedLabels, ['Reader <UI> & ops', 'Database', 'Future integration']);
|
||
|
||
await cdp.send('Emulation.setEmulatedMedia', { media: 'print' }, sessionId);
|
||
const printState = await evaluate(cdp, sessionId, `(() => ({
|
||
legendDisplay: getComputedStyle(document.querySelector('[data-legend]')).display,
|
||
runtimeBadgesHidden: Array.from(document.querySelectorAll('[data-legend-bridge-runtime]')).every(function (entry) { return getComputedStyle(entry).display === 'none'; })
|
||
}))()`);
|
||
assert.notEqual(printState.legendDisplay, 'none');
|
||
assert.equal(printState.runtimeBadgesHidden, true);
|
||
await cdp.send('Emulation.setEmulatedMedia', { media: '' }, sessionId);
|
||
|
||
const embedUrl = new URL(pathToFileURL(outputs.custom).href);
|
||
embedUrl.searchParams.set('embed', '1');
|
||
await navigateReady(embedUrl, '!!(window.Archify && Archify.semanticLens)', 'embedded legend');
|
||
const embed = await evaluate(cdp, sessionId, `(() => ({
|
||
roles: document.querySelectorAll('[data-legend-kind][role]').length,
|
||
runtime: document.querySelectorAll('[data-legend-bridge-runtime]').length,
|
||
kinds: Array.from(document.querySelectorAll('[data-legend-semantic-kind]')).map(function (entry) { return entry.getAttribute('data-legend-semantic-kind'); })
|
||
}))()`);
|
||
assert.deepEqual(embed, { roles: 0, runtime: 0, kinds: ['frontend', 'database', 'external'] });
|
||
|
||
await navigateReady(outputs.hidden, '!!(window.Archify && Archify.semanticLens)', 'hidden legend');
|
||
const hidden = await evaluate(cdp, sessionId, `(() => ({
|
||
root: !!document.querySelector('[data-legend]'),
|
||
bridge: !!document.querySelector('[data-legend-bridge]'),
|
||
title: Array.from(document.querySelectorAll('.diagram-container svg text')).some(function (text) { return text.textContent.trim() === 'Legend'; })
|
||
}))()`);
|
||
assert.deepEqual(hidden, { root: false, bridge: false, title: false });
|
||
console.log('ok legend runtime: labels, counts, keyboard, export, print, embed, hidden, and dual themes');
|
||
}
|
||
|
||
async function verifySemanticPassportDismissal(file) {
|
||
await navigateReady(file, '!!(window.Archify && Archify.focus && document.querySelector("#btn-focus-clear"))', 'Semantic Passport dismissal');
|
||
const result = await evaluate(cdp, sessionId, `(() => {
|
||
var chip = document.querySelector('#focus-chip');
|
||
var close = document.querySelector('#btn-focus-clear');
|
||
var container = document.querySelector('.diagram-container');
|
||
var svg = container.querySelector(':scope > svg');
|
||
var origin = svg.querySelector('[data-node-id="clients"]');
|
||
var neighbor = svg.querySelector('[data-node-id]:not([data-node-id="clients"])');
|
||
if (!origin || !neighbor) return { ok: false, error: 'missing smoke-test nodes' };
|
||
function state() {
|
||
return { hidden: chip.hidden, active: Archify.focus.active() };
|
||
}
|
||
|
||
Archify.focus.set('clients', { toggle: false, updateUrl: false });
|
||
var cardRect = chip.getBoundingClientRect();
|
||
var closeRect = close.getBoundingClientRect();
|
||
var layout = {
|
||
topGap: Math.round(closeRect.top - cardRect.top),
|
||
rightGap: Math.round(cardRect.right - closeRect.right),
|
||
label: close.getAttribute('aria-label'),
|
||
title: close.getAttribute('title'),
|
||
text: close.textContent.trim()
|
||
};
|
||
close.click();
|
||
var afterClose = state();
|
||
var restoredFocus = document.activeElement && document.activeElement.getAttribute('data-node-id');
|
||
|
||
Archify.focus.set('clients', { toggle: false, updateUrl: false });
|
||
chip.querySelector('.relationship-lens-head').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||
var afterInside = state();
|
||
container.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||
var afterOutside = state();
|
||
|
||
Archify.focus.set('clients', { toggle: false, updateUrl: false });
|
||
neighbor.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||
var afterNode = state();
|
||
return {
|
||
ok: true,
|
||
layout: layout,
|
||
afterClose: afterClose,
|
||
restoredFocus: restoredFocus,
|
||
afterInside: afterInside,
|
||
afterOutside: afterOutside,
|
||
afterNode: afterNode,
|
||
neighborId: neighbor.getAttribute('data-node-id')
|
||
};
|
||
})()`);
|
||
assert.equal(result?.ok, true, result?.error || 'Semantic Passport smoke failed');
|
||
assert.equal(result.layout.label, 'Close semantic passport');
|
||
assert.equal(result.layout.title, 'Close');
|
||
assert.equal(result.layout.text, '×');
|
||
assert.ok(result.layout.topGap >= 0 && result.layout.topGap <= 16, `Semantic Passport close top gap ${result.layout.topGap}px`);
|
||
assert.ok(result.layout.rightGap >= 0 && result.layout.rightGap <= 16, `Semantic Passport close right gap ${result.layout.rightGap}px`);
|
||
assert.deepEqual(result.afterClose, { hidden: true, active: null });
|
||
assert.equal(result.restoredFocus, 'clients');
|
||
assert.deepEqual(result.afterInside, { hidden: false, active: 'clients' });
|
||
assert.deepEqual(result.afterOutside, { hidden: true, active: null });
|
||
assert.deepEqual(result.afterNode, { hidden: false, active: result.neighborId });
|
||
console.log('ok Semantic Passport: close control, focus return, inside preservation, and outside dismissal');
|
||
}
|
||
|
||
async function verifyArchitectureDeltaNavigator(file) {
|
||
await cdp.send('Emulation.setEmulatedMedia', {
|
||
media: 'screen',
|
||
features: [{ name: 'prefers-reduced-motion', value: 'no-preference' }],
|
||
}, sessionId);
|
||
const readyCondition = '!!document.querySelector("#review-play") && !document.querySelector("#review-play").disabled';
|
||
async function waitForSelected(changeKey, label) {
|
||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||
if (await evaluate(cdp, sessionId, `document.querySelector('.change-row[aria-current="step"]')?.dataset.changeKey === ${JSON.stringify(changeKey)}`)) return;
|
||
await delay(50);
|
||
}
|
||
const observed = await evaluate(cdp, sessionId, `({
|
||
selected: document.querySelector('.change-row[aria-current="step"]')?.dataset.changeKey || null,
|
||
pressed: document.querySelector('#review-play')?.getAttribute('aria-pressed'),
|
||
label: document.querySelector('#review-play')?.textContent,
|
||
status: document.querySelector('#review-status')?.textContent,
|
||
reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||
hidden: document.hidden
|
||
})`);
|
||
assert.fail(`${label} did not select ${changeKey} within the bounded wait: ${JSON.stringify(observed)}`);
|
||
}
|
||
async function waitForReviewFinished(label) {
|
||
for (let attempt = 0; attempt < 400; attempt += 1) {
|
||
const done = await evaluate(cdp, sessionId, `document.querySelector('#review-play').getAttribute('aria-pressed') === 'false' && document.querySelector('#review-play').textContent === 'Replay'`);
|
||
if (done) return;
|
||
await delay(50);
|
||
}
|
||
assert.fail(`${label} did not finish within the bounded wait`);
|
||
}
|
||
await navigateReady(file, readyCondition, 'architecture-delta navigator');
|
||
const initial = await evaluate(cdp, sessionId, `({
|
||
status: document.querySelector('#review-status').textContent,
|
||
selected: document.querySelectorAll('.change-row[aria-current="step"]').length,
|
||
current: document.querySelectorAll('[data-delta-review-current]').length,
|
||
rows: document.querySelectorAll('.change-row').length
|
||
})`);
|
||
assert.deepEqual(initial, { status: 'Overview · 11 authored changes', selected: 0, current: 0, rows: 11 });
|
||
|
||
await evaluate(cdp, sessionId, `document.querySelector('#review-play').click()`);
|
||
await waitForSelected('relationship:fraud-check', 'architecture-delta initial playback');
|
||
const advanced = await evaluate(cdp, sessionId, `({
|
||
selected: document.querySelector('.change-row[aria-current="step"]')?.dataset.changeKey,
|
||
pressed: document.querySelector('#review-play').getAttribute('aria-pressed')
|
||
})`);
|
||
assert.deepEqual(advanced, { selected: 'relationship:fraud-check', pressed: 'true' });
|
||
|
||
await evaluate(cdp, sessionId, `document.querySelector('[role="tab"][data-target="base"]').click()`);
|
||
const pausedKey = await evaluate(cdp, sessionId, `document.querySelector('.change-row[aria-current="step"]')?.dataset.changeKey`);
|
||
await delay(1550);
|
||
assert.equal(await evaluate(cdp, sessionId, `document.querySelector('.change-row[aria-current="step"]')?.dataset.changeKey`), pausedKey);
|
||
assert.equal(await evaluate(cdp, sessionId, `document.querySelector('#review-play').getAttribute('aria-pressed')`), 'false');
|
||
|
||
const overview = await evaluate(cdp, sessionId, `(() => {
|
||
document.querySelector('[role="tab"][data-target="delta"]').click();
|
||
document.querySelector('#review-overview').click();
|
||
return {
|
||
active: document.querySelector('[data-view="delta"]').hasAttribute('data-delta-review-active'),
|
||
current: document.querySelectorAll('[data-delta-review-current]').length,
|
||
selected: document.querySelectorAll('.change-row[aria-current="step"]').length
|
||
};
|
||
})()`);
|
||
assert.deepEqual(overview, { active: false, current: 0, selected: 0 });
|
||
|
||
await navigateReady(file, readyCondition, 'architecture-delta manual lifecycle navigator');
|
||
await evaluate(cdp, sessionId, `document.querySelector('#review-play').click()`);
|
||
await waitForSelected('relationship:fraud-check', 'architecture-delta previous control');
|
||
const previousPause = await evaluate(cdp, sessionId, `(() => {
|
||
document.querySelector('#review-previous').click();
|
||
return {
|
||
selected: document.querySelector('.change-row[aria-current="step"]')?.dataset.changeKey,
|
||
pressed: document.querySelector('#review-play').getAttribute('aria-pressed')
|
||
};
|
||
})()`);
|
||
assert.deepEqual(previousPause, { selected: 'component:fraud', pressed: 'false' });
|
||
await delay(1450);
|
||
assert.equal(await evaluate(cdp, sessionId, `document.querySelector('.change-row[aria-current="step"]')?.dataset.changeKey`), 'component:fraud');
|
||
|
||
await evaluate(cdp, sessionId, `document.querySelector('#review-play').click()`);
|
||
const nextPause = await evaluate(cdp, sessionId, `(() => {
|
||
document.querySelector('#review-next').click();
|
||
return {
|
||
selected: document.querySelector('.change-row[aria-current="step"]')?.dataset.changeKey,
|
||
pressed: document.querySelector('#review-play').getAttribute('aria-pressed')
|
||
};
|
||
})()`);
|
||
assert.deepEqual(nextPause, { selected: 'relationship:fraud-check', pressed: 'false' });
|
||
|
||
await evaluate(cdp, sessionId, `document.querySelector('#review-play').click()`);
|
||
const rowPause = await evaluate(cdp, sessionId, `(() => {
|
||
document.querySelector('.change-row[data-change-key="component:queue"]').click();
|
||
return {
|
||
selected: document.querySelector('.change-row[aria-current="step"]')?.dataset.changeKey,
|
||
pressed: document.querySelector('#review-play').getAttribute('aria-pressed')
|
||
};
|
||
})()`);
|
||
assert.deepEqual(rowPause, { selected: 'component:queue', pressed: 'false' });
|
||
|
||
await navigateReady(file, readyCondition, 'architecture-delta focus lifecycle navigator');
|
||
const focusedPause = await evaluate(cdp, sessionId, `(() => {
|
||
document.querySelector('#review-play').click();
|
||
document.querySelector('#review-next').focus();
|
||
return {
|
||
selected: document.querySelector('.change-row[aria-current="step"]')?.dataset.changeKey,
|
||
pressed: document.querySelector('#review-play').getAttribute('aria-pressed')
|
||
};
|
||
})()`);
|
||
assert.deepEqual(focusedPause, { selected: 'component:fraud', pressed: 'false' });
|
||
await delay(1450);
|
||
assert.equal(await evaluate(cdp, sessionId, `document.querySelector('.change-row[aria-current="step"]')?.dataset.changeKey`), 'component:fraud');
|
||
|
||
await navigateReady(file, readyCondition, 'architecture-delta hidden lifecycle navigator');
|
||
const hiddenPause = await evaluate(cdp, sessionId, `(() => {
|
||
document.querySelector('#review-play').click();
|
||
Object.defineProperty(document, 'hidden', { configurable: true, get: function () { return true; } });
|
||
document.dispatchEvent(new Event('visibilitychange'));
|
||
return {
|
||
hidden: document.hidden,
|
||
selected: document.querySelector('.change-row[aria-current="step"]')?.dataset.changeKey,
|
||
pressed: document.querySelector('#review-play').getAttribute('aria-pressed')
|
||
};
|
||
})()`);
|
||
assert.deepEqual(hiddenPause, { hidden: true, selected: 'component:fraud', pressed: 'false' });
|
||
await delay(1450);
|
||
assert.equal(await evaluate(cdp, sessionId, `document.querySelector('.change-row[aria-current="step"]')?.dataset.changeKey`), 'component:fraud');
|
||
|
||
await navigateReady(file, readyCondition, 'architecture-delta print lifecycle navigator');
|
||
const printPause = await evaluate(cdp, sessionId, `(() => {
|
||
document.querySelector('#review-play').click();
|
||
window.dispatchEvent(new Event('beforeprint'));
|
||
return {
|
||
status: document.querySelector('#review-status').textContent,
|
||
selected: document.querySelectorAll('.change-row[aria-current="step"]').length,
|
||
pressed: document.querySelector('#review-play').getAttribute('aria-pressed')
|
||
};
|
||
})()`);
|
||
assert.deepEqual(printPause, { status: 'Overview · 11 authored changes', selected: 0, pressed: 'false' });
|
||
await delay(1450);
|
||
assert.equal(await evaluate(cdp, sessionId, `document.querySelectorAll('.change-row[aria-current="step"]').length`), 0);
|
||
|
||
const keyboard = await evaluate(cdp, sessionId, `(() => {
|
||
document.querySelector('details').open = true;
|
||
var first = document.querySelector('.change-row');
|
||
first.click();
|
||
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||
var focused = document.activeElement.dataset.changeKey;
|
||
document.activeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||
return { focused: focused, selected: document.querySelector('.change-row[aria-current="step"]').dataset.changeKey };
|
||
})()`);
|
||
assert.deepEqual(keyboard, { focused: 'relationship:fraud-check', selected: 'relationship:fraud-check' });
|
||
|
||
await cdp.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] }, sessionId);
|
||
await navigateReady(file, readyCondition, 'architecture-delta reduced-motion navigator');
|
||
await evaluate(cdp, sessionId, `document.querySelector('#review-play').click()`);
|
||
await delay(1550);
|
||
const reduced = await evaluate(cdp, sessionId, `({
|
||
selected: document.querySelector('.change-row[aria-current="step"]')?.dataset.changeKey,
|
||
pressed: document.querySelector('#review-play').getAttribute('aria-pressed'),
|
||
nextDisabled: document.querySelector('#review-next').disabled
|
||
})`);
|
||
assert.deepEqual(reduced, { selected: 'component:fraud', pressed: 'false', nextDisabled: false });
|
||
await evaluate(cdp, sessionId, `document.querySelector('#review-next').click()`);
|
||
|
||
await evaluate(cdp, sessionId, `document.querySelector('.change-row').click()`);
|
||
await cdp.send('Emulation.setEmulatedMedia', { media: 'print' }, sessionId);
|
||
await delay(200);
|
||
const printState = await evaluate(cdp, sessionId, `({
|
||
strip: getComputedStyle(document.querySelector('.review-strip')).display,
|
||
base: getComputedStyle(document.querySelector('[data-view="base"]')).display,
|
||
delta: getComputedStyle(document.querySelector('[data-view="delta"]')).display,
|
||
head: getComputedStyle(document.querySelector('[data-view="head"]')).display,
|
||
current: getComputedStyle(document.querySelector('[data-delta-review-current]')).opacity,
|
||
same: getComputedStyle(document.querySelector('[data-view="delta"] [data-delta-state="same"]')).opacity
|
||
})`);
|
||
assert.deepEqual(printState, { strip: 'none', base: 'none', delta: 'block', head: 'none', current: '1', same: '1' });
|
||
await cdp.send('Emulation.setEmulatedMedia', {
|
||
media: 'screen',
|
||
features: [{ name: 'prefers-reduced-motion', value: 'no-preference' }],
|
||
}, sessionId);
|
||
|
||
await navigateReady(file, readyCondition, 'architecture-delta tamper navigator');
|
||
const tampered = await evaluate(cdp, sessionId, `(() => {
|
||
var companion = document.querySelector('[data-view="delta"] g[data-edge-id="fraud-check"]');
|
||
companion.parentNode.insertBefore(companion.cloneNode(true), companion.nextSibling);
|
||
document.querySelector('.change-row[data-change-key="relationship:fraud-check"]').click();
|
||
return {
|
||
status: document.querySelector('#review-status').textContent,
|
||
disabled: Array.from(document.querySelectorAll('.review-step,.change-row')).every(function (button) { return button.disabled; }),
|
||
canvases: document.querySelectorAll('[data-view]').length
|
||
};
|
||
})()`);
|
||
assert.deepEqual(tampered, { status: 'Review unavailable · compare identity mismatch', disabled: true, canvases: 3 });
|
||
|
||
await navigateReady(file, readyCondition, 'architecture-delta finite navigator');
|
||
await evaluate(cdp, sessionId, `document.querySelector('#review-play').click()`);
|
||
await waitForReviewFinished('architecture-delta finite playback');
|
||
const finished = await evaluate(cdp, sessionId, `({
|
||
selected: document.querySelector('.change-row[aria-current="step"]')?.dataset.changeKey,
|
||
label: document.querySelector('#review-play').textContent,
|
||
pressed: document.querySelector('#review-play').getAttribute('aria-pressed')
|
||
})`);
|
||
assert.deepEqual(finished, { selected: 'relationship:publish-order', label: 'Replay', pressed: 'false' });
|
||
await evaluate(cdp, sessionId, `document.querySelector('#review-play').click()`);
|
||
const replayStarted = await evaluate(cdp, sessionId, `({
|
||
selected: document.querySelector('.change-row[aria-current="step"]')?.dataset.changeKey,
|
||
label: document.querySelector('#review-play').textContent,
|
||
pressed: document.querySelector('#review-play').getAttribute('aria-pressed')
|
||
})`);
|
||
assert.deepEqual(replayStarted, { selected: 'component:fraud', label: 'Pause', pressed: 'true' });
|
||
await waitForReviewFinished('architecture-delta replay playback');
|
||
const replayFinished = await evaluate(cdp, sessionId, `({
|
||
selected: document.querySelector('.change-row[aria-current="step"]')?.dataset.changeKey,
|
||
label: document.querySelector('#review-play').textContent,
|
||
pressed: document.querySelector('#review-play').getAttribute('aria-pressed')
|
||
})`);
|
||
assert.deepEqual(replayFinished, { selected: 'relationship:publish-order', label: 'Replay', pressed: 'false' });
|
||
|
||
const exportProof = await withTimeout(evaluate(cdp, sessionId, String.raw`(async function () {
|
||
var frames = Array.from(document.querySelectorAll('.snapshot-frame'));
|
||
var explorers = frames.map(function (frame) {
|
||
var child = frame.contentWindow;
|
||
return Boolean(child && child.Archify && child.Archify.focus && child.Archify.routeProbe && child.document.querySelector('#btn-node-finder') && child.document.querySelector('#guided-view-play'));
|
||
});
|
||
var svgA = Archify.deltaExport.canonicalSvg();
|
||
document.querySelector('#theme').click();
|
||
document.querySelector('#preset').click();
|
||
document.querySelector('.change-row').click();
|
||
var svgB = Archify.deltaExport.canonicalSvg();
|
||
var parsed = new DOMParser().parseFromString(svgB, 'image/svg+xml');
|
||
var exportStyle = parsed.querySelector('style')?.textContent || '';
|
||
var blob = await Archify.deltaExport.shareCard();
|
||
var bytes = new Uint8Array(await blob.arrayBuffer());
|
||
return {
|
||
explorers: explorers,
|
||
stable: svgA === svgB,
|
||
reviewResidue: parsed.querySelectorAll('[data-delta-review-current]').length,
|
||
boundaryStyle: exportStyle.includes('text[data-delta-boundary-state="added"]{fill:#34d399!important}'),
|
||
markerStyle: exportStyle.includes('.delta-edge-marker[data-delta-state],.delta-boundary-marker[data-delta-state]{color:var(--delta)}'),
|
||
frameStyle: exportStyle.includes('rect[data-graph-role="structural-frame"]'),
|
||
boundaryMarkers: Array.from(parsed.querySelectorAll('.delta-boundary-marker')).map(function (marker) { return marker.textContent; }),
|
||
type: blob.type,
|
||
size: blob.size,
|
||
signature: Array.from(bytes.slice(0, 8)).map(function (byte) { return byte.toString(16).padStart(2, '0'); }).join('')
|
||
};
|
||
})()`, true), 15_000, 'Architecture Delta export');
|
||
assert.deepEqual(exportProof.explorers, [true, true]);
|
||
assert.equal(exportProof.stable, true);
|
||
assert.equal(exportProof.reviewResidue, 0);
|
||
assert.equal(exportProof.boundaryStyle, true);
|
||
assert.equal(exportProof.markerStyle, true);
|
||
assert.equal(exportProof.frameStyle, true);
|
||
assert.deepEqual(exportProof.boundaryMarkers, ['~', '~']);
|
||
assert.equal(exportProof.type, 'image/png');
|
||
assert.ok(exportProof.size > 20_000, `Architecture Delta Share Card is unexpectedly small (${exportProof.size} bytes)`);
|
||
assert.equal(exportProof.signature, '89504e470d0a1a0a');
|
||
console.log(`ok Architecture Delta navigator + export: exact identity, complete explorers, static SVG, and ${exportProof.size}-byte Share Card`);
|
||
}
|
||
|
||
async function captureShareCard(file, label) {
|
||
await navigateReady(file, '!!(window.Archify && Archify.exportMenu && Archify.exportMenu.shareCard)', label);
|
||
const sharePayload = await withTimeout(evaluate(cdp, sessionId, String.raw`(async function () {
|
||
try {
|
||
var blob = await Archify.exportMenu.shareCard();
|
||
var bytes = new Uint8Array(await blob.arrayBuffer());
|
||
var binary = '';
|
||
for (var offset = 0; offset < bytes.length; offset += 32768) {
|
||
binary += String.fromCharCode.apply(null, bytes.subarray(offset, offset + 32768));
|
||
}
|
||
return { ok: true, type: blob.type, size: blob.size, base64: btoa(binary) };
|
||
} catch (error) {
|
||
return { ok: false, error: String(error && error.message || error) };
|
||
}
|
||
})()`, true), 10_000, `${label} Share Card export`);
|
||
|
||
assert.equal(sharePayload?.ok, true, sharePayload?.error || `${label} Share Card export failed`);
|
||
assert.equal(sharePayload.type, 'image/png');
|
||
assert.ok(sharePayload.size > 20_000, `${label} Share Card is unexpectedly small (${sharePayload.size} bytes)`);
|
||
|
||
const png = Buffer.from(sharePayload.base64, 'base64');
|
||
assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a', `${label} output is not a PNG`);
|
||
assert.equal(png.readUInt32BE(16), 1200, `${label} Share Card width`);
|
||
assert.equal(png.readUInt32BE(20), 630, `${label} Share Card height`);
|
||
|
||
const pngPath = path.join(tmp, `${label}.share-card.png`);
|
||
fs.writeFileSync(pngPath, png);
|
||
const pixels = execFileSync(ffmpeg, [
|
||
'-v', 'error',
|
||
'-i', pngPath,
|
||
'-vf', 'scale=120:63',
|
||
'-frames:v', '1',
|
||
'-f', 'rawvideo',
|
||
'-pix_fmt', 'rgb24',
|
||
'-',
|
||
], { maxBuffer: 4 * 1024 * 1024 });
|
||
const colors = new Set();
|
||
const counts = new Map();
|
||
for (let offset = 0; offset < pixels.length; offset += 3) {
|
||
const color = pixels.subarray(offset, offset + 3).toString('hex');
|
||
colors.add(color);
|
||
counts.set(color, (counts.get(color) || 0) + 1);
|
||
}
|
||
const largestColorShare = Math.max(...counts.values()) / (pixels.length / 3);
|
||
assert.ok(colors.size >= 24, `${label} Share Card has only ${colors.size} sampled colors`);
|
||
assert.ok(largestColorShare < 0.96, `${label} Share Card is visually near-blank (${Math.round(largestColorShare * 100)}% one color)`);
|
||
console.log(`ok ${label} Share Card: ${sharePayload.size} bytes, 1200x630, ${colors.size} sampled colors`);
|
||
}
|
||
|
||
async function captureCopiedShareCard(file, label) {
|
||
await navigateReady(file, '!!(window.Archify && Archify.exportMenu && Archify.exportMenu.copyShareCard)', label);
|
||
const copiedPayload = await withTimeout(evaluate(cdp, sessionId, String.raw`(async function () {
|
||
try {
|
||
Object.defineProperty(window, 'ClipboardItem', {
|
||
configurable: true,
|
||
value: function ClipboardItem(items) { this.items = items; }
|
||
});
|
||
Object.defineProperty(navigator, 'clipboard', {
|
||
configurable: true,
|
||
value: {
|
||
write: async function (items) {
|
||
window.__archifyCopiedShareCard = await Promise.resolve(items[0].items['image/png']);
|
||
}
|
||
}
|
||
});
|
||
window.alert = function (message) { window.__archifyCopyAlert = message; };
|
||
await Archify.exportMenu.copyShareCard();
|
||
var blob = window.__archifyCopiedShareCard;
|
||
if (!blob) throw new Error(window.__archifyCopyAlert || 'clipboard received no blob');
|
||
var bytes = new Uint8Array(await blob.arrayBuffer());
|
||
var binary = '';
|
||
for (var offset = 0; offset < bytes.length; offset += 32768) {
|
||
binary += String.fromCharCode.apply(null, bytes.subarray(offset, offset + 32768));
|
||
}
|
||
return {
|
||
ok: true,
|
||
type: blob.type,
|
||
size: blob.size,
|
||
base64: btoa(binary),
|
||
receipt: {
|
||
format: document.documentElement.getAttribute('data-last-export-format'),
|
||
width: document.documentElement.getAttribute('data-last-export-width'),
|
||
height: document.documentElement.getAttribute('data-last-export-height'),
|
||
canonical: document.documentElement.getAttribute('data-last-export-canonical'),
|
||
error: document.documentElement.getAttribute('data-last-export-error')
|
||
}
|
||
};
|
||
} catch (error) {
|
||
return { ok: false, error: String(error && error.message || error) };
|
||
}
|
||
})()`, true), 10_000, `${label} Copy Share Card`);
|
||
|
||
assert.equal(copiedPayload?.ok, true, copiedPayload?.error || `${label} Copy Share Card failed`);
|
||
assert.equal(copiedPayload.type, 'image/png');
|
||
assert.ok(copiedPayload.size > 20_000, `${label} copied Share Card is unexpectedly small`);
|
||
const png = Buffer.from(copiedPayload.base64, 'base64');
|
||
assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a', `${label} copied output is not a PNG`);
|
||
assert.equal(png.readUInt32BE(16), 1200, `${label} copied Share Card width`);
|
||
assert.equal(png.readUInt32BE(20), 630, `${label} copied Share Card height`);
|
||
assert.deepEqual(copiedPayload.receipt, {
|
||
format: 'share-card',
|
||
width: '1200',
|
||
height: '630',
|
||
canonical: 'true',
|
||
error: null,
|
||
});
|
||
console.log(`ok ${label} Copy Share Card: ${copiedPayload.size} bytes, image/png, truthful receipt`);
|
||
}
|
||
|
||
async function captureRouteShareCard(file, label, sourceId, targetId, options = {}) {
|
||
await navigateReady(file, '!!(window.Archify && Archify.routeProbe && Archify.exportMenu && Archify.exportMenu.downloadRouteShareCard)', label);
|
||
const routePayload = await withTimeout(evaluate(cdp, sessionId, String.raw`(async function () {
|
||
try {
|
||
window.alert = function (message) { window.__archifyRouteAlert = message; };
|
||
Archify.routeProbe.begin({ source: ${JSON.stringify(sourceId)}, focusNode: false });
|
||
if (!Archify.routeProbe.choose(${JSON.stringify(targetId)}, { updateUrl: false })) {
|
||
throw new Error('route did not resolve');
|
||
}
|
||
var snapshot = Archify.routeProbe.exportSnapshot();
|
||
if (!snapshot) throw new Error('resolved route exposed no export snapshot');
|
||
Archify.exportMenu.syncRouteShare();
|
||
var routeMenuItem = document.querySelector('[data-action="route-share-card"]');
|
||
var menuResolved = !!routeMenuItem && !routeMenuItem.hidden && !routeMenuItem.disabled;
|
||
var svg = document.querySelector('.diagram-container svg');
|
||
var firstEdgeKey = snapshot.edges[0].key;
|
||
var primaryCarrier = Array.from(svg.querySelectorAll('[data-edge-key]')).find(function (element) {
|
||
return element.getAttribute('data-edge-key') === firstEdgeKey && hasDrawableGeometry(element);
|
||
});
|
||
if (!primaryCarrier) throw new Error('route exposed no primary geometry carrier');
|
||
var duplicateCarrier = primaryCarrier.cloneNode(true);
|
||
var duplicateGeometry = /^(path|line|polyline)$/i.test(duplicateCarrier.tagName)
|
||
? duplicateCarrier
|
||
: duplicateCarrier.querySelector('path, line, polyline');
|
||
if (duplicateGeometry.tagName.toLowerCase() === 'path') {
|
||
duplicateGeometry.setAttribute('d', duplicateGeometry.getAttribute('d') + ' M 0 0 L 1 1');
|
||
}
|
||
svg.appendChild(duplicateCarrier);
|
||
var duplicateGeometryRejected = Archify.routeProbe.exportSnapshot() === null;
|
||
var duplicateExportError = '';
|
||
try { await Archify.exportMenu.shareCard({ variant: 'route' }); }
|
||
catch (error) { duplicateExportError = String(error && error.message || error); }
|
||
duplicateCarrier.remove();
|
||
var primaryGeometry = /^(path|line|polyline)$/i.test(primaryCarrier.tagName)
|
||
? primaryCarrier
|
||
: primaryCarrier.querySelector('path, line, polyline');
|
||
var geometryAttribute = primaryGeometry.tagName.toLowerCase() === 'path' ? 'd' :
|
||
primaryGeometry.tagName.toLowerCase() === 'polyline' ? 'points' : 'x2';
|
||
var originalGeometry = primaryGeometry.getAttribute(geometryAttribute);
|
||
primaryGeometry.setAttribute(geometryAttribute, '');
|
||
var emptyGeometryRejected = Archify.routeProbe.exportSnapshot() === null;
|
||
var emptyGeometryExportError = '';
|
||
try { await Archify.exportMenu.shareCard({ variant: 'route' }); }
|
||
catch (error) { emptyGeometryExportError = String(error && error.message || error); }
|
||
primaryGeometry.setAttribute(geometryAttribute, originalGeometry);
|
||
await new Promise(function (resolve) {
|
||
requestAnimationFrame(function () { requestAnimationFrame(resolve); });
|
||
});
|
||
function stableLiveSnapshot() {
|
||
var clone = svg.cloneNode(true);
|
||
clone.style.removeProperty('transform');
|
||
clone.style.removeProperty('clip-path');
|
||
clone.removeAttribute('data-view-scale');
|
||
Array.from(clone.querySelectorAll('[data-legend-bridge-runtime]')).forEach(function (element) { element.remove(); });
|
||
return clone.outerHTML;
|
||
}
|
||
var liveBefore = stableLiveSnapshot();
|
||
var captured = [];
|
||
var downloads = [];
|
||
var originalCreateObjectURL = URL.createObjectURL.bind(URL);
|
||
var originalAnchorClick = HTMLAnchorElement.prototype.click;
|
||
var originalFillText = CanvasRenderingContext2D.prototype.fillText;
|
||
var headerMetrics = [];
|
||
URL.createObjectURL = function (blob) {
|
||
if (blob && blob.type && blob.type.indexOf('image/svg+xml') === 0) {
|
||
captured.push(blob.text());
|
||
}
|
||
return originalCreateObjectURL(blob);
|
||
};
|
||
HTMLAnchorElement.prototype.click = function () { downloads.push(this.download); };
|
||
CanvasRenderingContext2D.prototype.fillText = function (text, x, y) {
|
||
if (headerMetrics.length < 2 && (y === 62 || y === 87)) {
|
||
headerMetrics.push({
|
||
y: y,
|
||
text: String(text),
|
||
width: this.measureText(String(text)).width,
|
||
maxWidth: y === 62 ? 798 : 848
|
||
});
|
||
}
|
||
return originalFillText.apply(this, arguments);
|
||
};
|
||
|
||
var blob;
|
||
var canonicalBlob;
|
||
var routeReceipt;
|
||
var ordinaryReceipt;
|
||
var routeFingerprints = [];
|
||
try {
|
||
blob = await Archify.exportMenu.downloadRouteShareCard();
|
||
routeReceipt = {
|
||
format: document.documentElement.getAttribute('data-last-export-format'),
|
||
variant: document.documentElement.getAttribute('data-last-export-variant'),
|
||
width: document.documentElement.getAttribute('data-last-export-width'),
|
||
height: document.documentElement.getAttribute('data-last-export-height'),
|
||
canonical: document.documentElement.getAttribute('data-last-export-canonical'),
|
||
routeStateClean: document.documentElement.getAttribute('data-last-export-route-state-clean'),
|
||
error: document.documentElement.getAttribute('data-last-export-error')
|
||
};
|
||
var routeSvgText = captured[0] ? await captured[0] : '';
|
||
function fingerprint(text) {
|
||
var hash = 2166136261;
|
||
for (var index = 0; index < text.length; index++) {
|
||
hash ^= text.charCodeAt(index);
|
||
hash = Math.imul(hash, 16777619);
|
||
}
|
||
return (hash >>> 0).toString(16);
|
||
}
|
||
routeFingerprints.push(fingerprint(routeSvgText));
|
||
|
||
if (${JSON.stringify(options.journeyInvariance === true)}) {
|
||
async function captureJourneySource(action) {
|
||
action();
|
||
var captureIndex = captured.length;
|
||
await Archify.exportMenu.shareCard({ variant: 'route' });
|
||
routeFingerprints.push(fingerprint(await captured[captureIndex]));
|
||
}
|
||
await captureJourneySource(function () { Archify.routeProbe.selectJourneyIndex(0); });
|
||
await captureJourneySource(function () { Archify.routeProbe.selectJourneyIndex(Math.floor(snapshot.nodeIds.length / 2)); });
|
||
await captureJourneySource(function () { Archify.routeProbe.selectJourneyIndex(snapshot.nodeIds.length - 1); });
|
||
Archify.routeProbe.showOverview({ reveal: false });
|
||
var started = Archify.routeProbe.playJourney();
|
||
if (!started) throw new Error('Route Journey could not enter playing state for invariance check');
|
||
await captureJourneySource(function () {});
|
||
Archify.routeProbe.pauseJourney({ preserveElapsed: false });
|
||
await captureJourneySource(function () {});
|
||
document.documentElement.setAttribute('data-motion', 'still');
|
||
await captureJourneySource(function () {});
|
||
document.documentElement.setAttribute('data-motion', 'live');
|
||
Archify.routeProbe.showOverview({ reveal: false });
|
||
}
|
||
|
||
var toBlobError = '';
|
||
var originalToBlob = HTMLCanvasElement.prototype.toBlob;
|
||
HTMLCanvasElement.prototype.toBlob = function (callback) { callback(null); };
|
||
try { await Archify.exportMenu.shareCard({ variant: 'route' }); }
|
||
catch (error) { toBlobError = String(error && error.message || error); }
|
||
finally { HTMLCanvasElement.prototype.toBlob = originalToBlob; }
|
||
|
||
var missingToBlobError = '';
|
||
HTMLCanvasElement.prototype.toBlob = undefined;
|
||
try { await Archify.exportMenu.shareCard({ variant: 'route' }); }
|
||
catch (error) { missingToBlobError = String(error && error.message || error); }
|
||
finally { HTMLCanvasElement.prototype.toBlob = originalToBlob; }
|
||
|
||
var missingContextError = '';
|
||
var originalGetContext = HTMLCanvasElement.prototype.getContext;
|
||
HTMLCanvasElement.prototype.getContext = function () { return null; };
|
||
try { await Archify.exportMenu.shareCard({ variant: 'route' }); }
|
||
catch (error) { missingContextError = String(error && error.message || error); }
|
||
finally { HTMLCanvasElement.prototype.getContext = originalGetContext; }
|
||
|
||
var imageDecodeError = '';
|
||
var OriginalImage = window.Image;
|
||
function FailingImage() {}
|
||
Object.defineProperty(FailingImage.prototype, 'src', {
|
||
set: function () {
|
||
var instance = this;
|
||
queueMicrotask(function () {
|
||
if (typeof instance.onerror === 'function') instance.onerror(new Event('error'));
|
||
});
|
||
}
|
||
});
|
||
window.Image = FailingImage;
|
||
try { await Archify.exportMenu.shareCard({ variant: 'route' }); }
|
||
catch (error) { imageDecodeError = String(error && error.message || error); }
|
||
finally { window.Image = OriginalImage; }
|
||
|
||
var unknownVariantError = '';
|
||
try { await Archify.exportMenu.shareCard({ variant: 'unknown' }); }
|
||
catch (error) { unknownVariantError = String(error && error.message || error); }
|
||
|
||
var canonicalIndex = captured.length;
|
||
canonicalBlob = await Archify.exportMenu.shareCard();
|
||
var canonicalSvgText = captured[canonicalIndex] ? await captured[canonicalIndex] : '';
|
||
await Archify.exportMenu.run('share-card');
|
||
ordinaryReceipt = {
|
||
format: document.documentElement.getAttribute('data-last-export-format'),
|
||
variant: document.documentElement.getAttribute('data-last-export-variant'),
|
||
width: document.documentElement.getAttribute('data-last-export-width'),
|
||
height: document.documentElement.getAttribute('data-last-export-height'),
|
||
canonical: document.documentElement.getAttribute('data-last-export-canonical'),
|
||
routeStateClean: document.documentElement.getAttribute('data-last-export-route-state-clean'),
|
||
error: document.documentElement.getAttribute('data-last-export-error')
|
||
};
|
||
var svgDownloadIndex = captured.length;
|
||
await Archify.exportMenu.run('svg');
|
||
var exportedSvgText = captured[svgDownloadIndex] ? await captured[svgDownloadIndex] : '';
|
||
var svgReceipt = {
|
||
format: document.documentElement.getAttribute('data-last-export-format'),
|
||
variant: document.documentElement.getAttribute('data-last-export-variant'),
|
||
canonical: document.documentElement.getAttribute('data-last-export-canonical')
|
||
};
|
||
await Archify.exportMenu.run('png');
|
||
var pngReceipt = {
|
||
format: document.documentElement.getAttribute('data-last-export-format'),
|
||
variant: document.documentElement.getAttribute('data-last-export-variant'),
|
||
canonical: document.documentElement.getAttribute('data-last-export-canonical')
|
||
};
|
||
|
||
var parser = new DOMParser();
|
||
var routeSvg = parser.parseFromString(routeSvgText, 'image/svg+xml').documentElement;
|
||
var canonicalSvg = parser.parseFromString(canonicalSvgText, 'image/svg+xml').documentElement;
|
||
var exportedSvg = parser.parseFromString(exportedSvgText, 'image/svg+xml').documentElement;
|
||
var matchedNodeIds = Array.from(routeSvg.querySelectorAll('[data-node-id][data-share-route-match]')).map(function (node) {
|
||
return { id: node.getAttribute('data-node-id'), step: Number(node.getAttribute('data-share-route-step')) };
|
||
}).sort(function (a, b) { return a.step - b.step; }).map(function (item) { return item.id; });
|
||
var edgeSteps = new Map();
|
||
Array.from(routeSvg.querySelectorAll('[data-edge-key][data-share-route-match]')).forEach(function (edge) {
|
||
edgeSteps.set(edge.getAttribute('data-edge-key'), Number(edge.getAttribute('data-share-route-step')));
|
||
});
|
||
var matchedEdgeKeys = Array.from(edgeSteps).sort(function (a, b) { return a[1] - b[1]; }).map(function (entry) { return entry[0]; });
|
||
var expectedEdgeKeys = snapshot.edges.map(function (edge) { return edge.key; });
|
||
var routeNodeIds = Array.from(routeSvg.querySelectorAll('[data-node-id]')).map(function (node) { return node.getAttribute('data-node-id'); }).sort();
|
||
var liveNodeIds = Array.from(svg.querySelectorAll('[data-node-id]')).map(function (node) { return node.getAttribute('data-node-id'); }).sort();
|
||
var routeEdgeKeys = Array.from(new Set(Array.from(routeSvg.querySelectorAll('[data-edge-key]')).map(function (edge) { return edge.getAttribute('data-edge-key'); }))).sort();
|
||
var liveEdgeKeys = Array.from(new Set(Array.from(svg.querySelectorAll('[data-edge-key]')).map(function (edge) { return edge.getAttribute('data-edge-key'); }))).sort();
|
||
|
||
var bytes = new Uint8Array(await blob.arrayBuffer());
|
||
var binary = '';
|
||
for (var offset = 0; offset < bytes.length; offset += 32768) {
|
||
binary += String.fromCharCode.apply(null, bytes.subarray(offset, offset + 32768));
|
||
}
|
||
|
||
await new Promise(function (resolve) {
|
||
requestAnimationFrame(function () { requestAnimationFrame(resolve); });
|
||
});
|
||
var liveAfter = stableLiveSnapshot();
|
||
var liveUnchanged = liveBefore === liveAfter;
|
||
var liveDiff = '';
|
||
if (!liveUnchanged) {
|
||
var diffIndex = 0;
|
||
while (diffIndex < liveBefore.length && diffIndex < liveAfter.length && liveBefore[diffIndex] === liveAfter[diffIndex]) diffIndex++;
|
||
liveDiff = 'at ' + diffIndex + ': before=' + liveBefore.slice(Math.max(0, diffIndex - 80), diffIndex + 160) +
|
||
' after=' + liveAfter.slice(Math.max(0, diffIndex - 80), diffIndex + 160);
|
||
}
|
||
|
||
var asyncClearIndex = captured.length;
|
||
var asyncClearPromise = Archify.exportMenu.shareCard({ variant: 'route' });
|
||
Archify.routeProbe.clear({ updateUrl: false, preserveView: true });
|
||
var asyncClearBlob = await asyncClearPromise;
|
||
var asyncClearFingerprint = fingerprint(await captured[asyncClearIndex]);
|
||
var hiddenAfterClear = routeMenuItem.hidden && routeMenuItem.disabled &&
|
||
getComputedStyle(routeMenuItem).display === 'none';
|
||
var staleError = '';
|
||
try { await Archify.exportMenu.shareCard({ variant: 'route' }); }
|
||
catch (error) { staleError = String(error && error.message || error); }
|
||
await Archify.exportMenu.downloadRouteShareCard();
|
||
var failedReceipt = {
|
||
format: document.documentElement.getAttribute('data-last-export-format'),
|
||
variant: document.documentElement.getAttribute('data-last-export-variant'),
|
||
errorFormat: document.documentElement.getAttribute('data-last-export-error-format'),
|
||
error: document.documentElement.getAttribute('data-last-export-error')
|
||
};
|
||
|
||
return {
|
||
ok: true,
|
||
type: blob.type,
|
||
size: blob.size,
|
||
base64: btoa(binary),
|
||
snapshot: snapshot,
|
||
matchedNodeIds: matchedNodeIds,
|
||
matchedEdgeKeys: matchedEdgeKeys,
|
||
expectedEdgeKeys: expectedEdgeKeys,
|
||
routeShare: routeSvg.hasAttribute('data-share-route'),
|
||
routeLiveResidue: routeSvg.querySelectorAll('[data-route-match], [data-route-step], [data-route-start], [data-route-end], [data-route-journey-state], [data-route-journey-current], [data-route-journey-overlay]').length,
|
||
routeMotionResidue: (routeSvg.hasAttribute('data-animation') ? 1 : 0) + routeSvg.querySelectorAll('[data-animate]').length,
|
||
routeNodeIds: routeNodeIds,
|
||
liveNodeIds: liveNodeIds,
|
||
routeEdgeKeys: routeEdgeKeys,
|
||
liveEdgeKeys: liveEdgeKeys,
|
||
canonicalRouteResidue: canonicalSvg.querySelectorAll('[data-route-match], [data-route-step], [data-route-start], [data-route-end], [data-share-route-match], [data-share-route-step], [data-share-route-start], [data-share-route-end], [data-share-route-middle]').length,
|
||
canonicalRouteActive: canonicalSvg.hasAttribute('data-route-active') || canonicalSvg.hasAttribute('data-share-route'),
|
||
canonicalSize: canonicalBlob.size,
|
||
liveUnchanged: liveUnchanged,
|
||
liveDiff: liveDiff,
|
||
menuResolved: menuResolved,
|
||
duplicateGeometryRejected: duplicateGeometryRejected,
|
||
duplicateExportError: duplicateExportError,
|
||
emptyGeometryRejected: emptyGeometryRejected,
|
||
emptyGeometryExportError: emptyGeometryExportError,
|
||
toBlobError: toBlobError,
|
||
missingToBlobError: missingToBlobError,
|
||
missingContextError: missingContextError,
|
||
imageDecodeError: imageDecodeError,
|
||
unknownVariantError: unknownVariantError,
|
||
headerMetrics: headerMetrics,
|
||
routeLabelExecuted: !!window.__routeLabelExecuted,
|
||
svgReceipt: svgReceipt,
|
||
pngReceipt: pngReceipt,
|
||
exportedSvgRouteResidue: exportedSvg.hasAttribute('data-route-active') ||
|
||
exportedSvg.hasAttribute('data-share-route') ||
|
||
exportedSvg.querySelectorAll('[data-route-match], [data-route-step], [data-route-start], [data-route-end], [data-share-route-match], [data-share-route-step], [data-share-route-start], [data-share-route-end], [data-share-route-middle]').length > 0,
|
||
exportedSvgDualTheme: /prefers-color-scheme:\s*light/.test(Array.from(exportedSvg.querySelectorAll('style')).map(function (style) { return style.textContent; }).join('\n')),
|
||
asyncClearStable: asyncClearBlob && asyncClearBlob.type === 'image/png' && asyncClearFingerprint === routeFingerprints[0],
|
||
hiddenAfterClear: hiddenAfterClear,
|
||
staleSnapshot: Archify.routeProbe.exportSnapshot(),
|
||
staleError: staleError,
|
||
routeFingerprints: routeFingerprints,
|
||
downloads: downloads,
|
||
routeReceipt: routeReceipt,
|
||
ordinaryReceipt: ordinaryReceipt,
|
||
failedReceipt: failedReceipt
|
||
};
|
||
} finally {
|
||
URL.createObjectURL = originalCreateObjectURL;
|
||
HTMLAnchorElement.prototype.click = originalAnchorClick;
|
||
CanvasRenderingContext2D.prototype.fillText = originalFillText;
|
||
}
|
||
} catch (error) {
|
||
return { ok: false, error: String(error && error.message || error) };
|
||
}
|
||
})()`, true), 25_000, `${label} Route Card export`);
|
||
|
||
assert.equal(routePayload?.ok, true, routePayload?.error || `${label} Route Card export failed`);
|
||
assert.equal(routePayload.type, 'image/png');
|
||
assert.ok(routePayload.size > 20_000, `${label} Route Card is unexpectedly small (${routePayload.size} bytes)`);
|
||
const png = Buffer.from(routePayload.base64, 'base64');
|
||
assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a', `${label} Route Card is not a PNG`);
|
||
assert.equal(png.readUInt32BE(16), 1200, `${label} Route Card width`);
|
||
assert.equal(png.readUInt32BE(20), 630, `${label} Route Card height`);
|
||
const pngPath = path.join(tmp, `${label}.route-share-card.png`);
|
||
fs.writeFileSync(pngPath, png);
|
||
const sampledPixels = execFileSync(ffmpeg, [
|
||
'-v', 'error',
|
||
'-i', pngPath,
|
||
'-vf', 'scale=120:63',
|
||
'-frames:v', '1',
|
||
'-f', 'rawvideo',
|
||
'-pix_fmt', 'rgb24',
|
||
'-',
|
||
], { maxBuffer: 4 * 1024 * 1024 });
|
||
const sampledColors = new Set();
|
||
for (let offset = 0; offset < sampledPixels.length; offset += 3) {
|
||
sampledColors.add(sampledPixels.subarray(offset, offset + 3).toString('hex'));
|
||
}
|
||
assert.ok(sampledColors.size >= 20, `${label} Route Card has only ${sampledColors.size} sampled colors`);
|
||
assert.equal(routePayload.routeShare, true);
|
||
assert.deepEqual(routePayload.matchedNodeIds, routePayload.snapshot.nodeIds);
|
||
assert.deepEqual(routePayload.matchedEdgeKeys, routePayload.expectedEdgeKeys);
|
||
assert.equal(routePayload.routeLiveResidue, 0);
|
||
assert.equal(routePayload.routeMotionResidue, 0);
|
||
assert.deepEqual(routePayload.routeNodeIds, routePayload.liveNodeIds);
|
||
assert.deepEqual(routePayload.routeEdgeKeys, routePayload.liveEdgeKeys);
|
||
assert.equal(routePayload.canonicalRouteResidue, 0);
|
||
assert.equal(routePayload.canonicalRouteActive, false);
|
||
assert.equal(routePayload.liveUnchanged, true, routePayload.liveDiff);
|
||
assert.equal(routePayload.menuResolved, true);
|
||
assert.equal(routePayload.duplicateGeometryRejected, true);
|
||
assert.match(routePayload.duplicateExportError, /Trace a route before exporting a Route Share Card/);
|
||
assert.equal(routePayload.emptyGeometryRejected, true);
|
||
assert.match(routePayload.emptyGeometryExportError, /Trace a route before exporting a Route Share Card/);
|
||
assert.match(routePayload.toBlobError, /canvas\.toBlob returned no data for Share Card/);
|
||
assert.match(routePayload.missingToBlobError, /canvas\.toBlob unavailable for Share Card/);
|
||
assert.match(routePayload.missingContextError, /2D canvas context unavailable for Share Card/);
|
||
assert.ok(routePayload.imageDecodeError);
|
||
assert.match(routePayload.unknownVariantError, /Unknown Share Card variant: unknown/);
|
||
assert.equal(routePayload.routeLabelExecuted, false);
|
||
assert.deepEqual(routePayload.headerMetrics.map((metric) => metric.y), [62, 87]);
|
||
assert.ok(routePayload.headerMetrics.every((metric) => metric.width <= metric.maxWidth + 0.5), `${label} title/subtitle overflowed the Share Card header`);
|
||
assert.deepEqual(routePayload.svgReceipt, { format: 'svg', variant: null, canonical: 'true' });
|
||
assert.deepEqual(routePayload.pngReceipt, { format: 'png', variant: null, canonical: 'true' });
|
||
assert.equal(routePayload.exportedSvgRouteResidue, false);
|
||
assert.equal(routePayload.exportedSvgDualTheme, true);
|
||
assert.equal(routePayload.asyncClearStable, true);
|
||
assert.equal(routePayload.hiddenAfterClear, true);
|
||
assert.equal(routePayload.staleSnapshot, null);
|
||
assert.match(routePayload.staleError, /Trace a route before exporting a Route Share Card/);
|
||
assert.ok(routePayload.routeFingerprints.every((fingerprint) => fingerprint === routePayload.routeFingerprints[0]), `${label} Route source changed across Journey state`);
|
||
assert.match(routePayload.downloads[0], /-route-share-card\.png$/);
|
||
assert.doesNotMatch(routePayload.downloads[0], /-route-.+-to-.+\.png$/);
|
||
assert.deepEqual(routePayload.routeReceipt, {
|
||
format: 'share-card',
|
||
variant: 'route',
|
||
width: '1200',
|
||
height: '630',
|
||
canonical: 'false',
|
||
routeStateClean: 'true',
|
||
error: null,
|
||
});
|
||
assert.deepEqual(routePayload.ordinaryReceipt, {
|
||
format: 'share-card',
|
||
variant: null,
|
||
width: '1200',
|
||
height: '630',
|
||
canonical: 'true',
|
||
routeStateClean: null,
|
||
error: null,
|
||
});
|
||
assert.equal(routePayload.failedReceipt.format, null);
|
||
assert.equal(routePayload.failedReceipt.variant, null);
|
||
assert.equal(routePayload.failedReceipt.errorFormat, 'share-card');
|
||
assert.match(routePayload.failedReceipt.error, /Trace a route before exporting a Route Share Card/);
|
||
if (options.expectedHops !== undefined) assert.equal(routePayload.snapshot.hops, options.expectedHops);
|
||
if (options.expectedSourceLabel !== undefined) assert.equal(routePayload.snapshot.source.label, options.expectedSourceLabel);
|
||
if (options.expectedTargetLabel !== undefined) assert.equal(routePayload.snapshot.target.label, options.expectedTargetLabel);
|
||
console.log(`ok ${label} Route Card: ${routePayload.size} bytes, ${routePayload.snapshot.nodeIds.length} nodes, ${routePayload.snapshot.edges.length} exact hops`);
|
||
}
|
||
|
||
async function verifyDynamicReducedMotionRoute(file, label, sourceId, targetId) {
|
||
await cdp.send('Emulation.setEmulatedMedia', {
|
||
features: [{ name: 'prefers-reduced-motion', value: 'no-preference' }],
|
||
}, sessionId);
|
||
await navigateReady(file, '!!(window.Archify && Archify.routeProbe && Archify.exportMenu && Archify.motionGovernor)', label);
|
||
|
||
async function sourceFingerprint() {
|
||
return withTimeout(evaluate(cdp, sessionId, String.raw`(async function () {
|
||
var originalCreateObjectURL = URL.createObjectURL.bind(URL);
|
||
var sourcePromise = null;
|
||
URL.createObjectURL = function (blob) {
|
||
if (!sourcePromise && blob && blob.type && blob.type.indexOf('image/svg+xml') === 0) sourcePromise = blob.text();
|
||
return originalCreateObjectURL(blob);
|
||
};
|
||
try {
|
||
await Archify.exportMenu.shareCard({ variant: 'route' });
|
||
var source = await sourcePromise;
|
||
var hash = 2166136261;
|
||
for (var index = 0; index < source.length; index++) {
|
||
hash ^= source.charCodeAt(index);
|
||
hash = Math.imul(hash, 16777619);
|
||
}
|
||
return (hash >>> 0).toString(16);
|
||
} finally {
|
||
URL.createObjectURL = originalCreateObjectURL;
|
||
}
|
||
})()`, true), 10_000, `${label} source fingerprint`);
|
||
}
|
||
|
||
const setup = await evaluate(cdp, sessionId, `(function () {
|
||
Archify.routeProbe.begin({ source: ${JSON.stringify(sourceId)}, focusNode: false });
|
||
if (!Archify.routeProbe.choose(${JSON.stringify(targetId)}, { updateUrl: false })) return { resolved: false };
|
||
Archify.routeProbe.showOverview({ reveal: false });
|
||
return {
|
||
resolved: true,
|
||
started: Archify.routeProbe.playJourney(),
|
||
playing: Archify.routeProbe.isJourneyPlaying(),
|
||
motion: document.documentElement.getAttribute('data-motion')
|
||
};
|
||
})()`);
|
||
assert.deepEqual(setup, { resolved: true, started: true, playing: true, motion: 'live' });
|
||
const before = await sourceFingerprint();
|
||
|
||
await cdp.send('Emulation.setEmulatedMedia', {
|
||
features: [{ name: 'prefers-reduced-motion', value: 'reduce' }],
|
||
}, sessionId);
|
||
let reduced = null;
|
||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||
reduced = await evaluate(cdp, sessionId, `({
|
||
matches: matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||
motion: document.documentElement.getAttribute('data-motion'),
|
||
playing: Archify.routeProbe.isJourneyPlaying()
|
||
})`);
|
||
if (reduced.matches && reduced.motion === 'still' && !reduced.playing) break;
|
||
await delay(20);
|
||
}
|
||
assert.deepEqual(reduced, { matches: true, motion: 'still', playing: false });
|
||
const after = await sourceFingerprint();
|
||
assert.equal(after, before, `${label} source changed after dynamic reduced-motion paused Journey`);
|
||
|
||
await cdp.send('Emulation.setEmulatedMedia', {
|
||
features: [{ name: 'prefers-reduced-motion', value: 'no-preference' }],
|
||
}, sessionId);
|
||
console.log(`ok ${label}: dynamic reduced-motion paused Journey without changing Route Card source`);
|
||
}
|
||
|
||
async function captureRouteVisualMatrix(file, label, sourceId, targetId) {
|
||
await navigateReady(file, '!!(window.Archify && Archify.preset && Archify.routeProbe && Archify.exportMenu)', label);
|
||
const matrix = await withTimeout(evaluate(cdp, sessionId, String.raw`(async function () {
|
||
Archify.routeProbe.begin({ source: ${JSON.stringify(sourceId)}, focusNode: false });
|
||
if (!Archify.routeProbe.choose(${JSON.stringify(targetId)}, { updateUrl: false })) {
|
||
throw new Error('route did not resolve');
|
||
}
|
||
var identity = JSON.stringify(Archify.routeProbe.exportSnapshot());
|
||
var results = [];
|
||
for (var preset of ['classic', 'signal-flow', 'blueprint', 'editorial']) {
|
||
if (!Archify.preset.apply(preset)) throw new Error('could not apply preset ' + preset);
|
||
for (var theme of ['dark', 'light']) {
|
||
document.documentElement.setAttribute('data-theme', theme);
|
||
var blob = await Archify.exportMenu.shareCard({ variant: 'route' });
|
||
var bytes = new Uint8Array(await blob.arrayBuffer());
|
||
var hash = 2166136261;
|
||
for (var index = 0; index < bytes.length; index++) {
|
||
hash ^= bytes[index];
|
||
hash = Math.imul(hash, 16777619);
|
||
}
|
||
var view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||
results.push({
|
||
preset: preset,
|
||
theme: theme,
|
||
type: blob.type,
|
||
size: blob.size,
|
||
width: view.getUint32(16),
|
||
height: view.getUint32(20),
|
||
hash: (hash >>> 0).toString(16),
|
||
identity: JSON.stringify(Archify.routeProbe.exportSnapshot())
|
||
});
|
||
}
|
||
}
|
||
return { identity: identity, results: results };
|
||
})()`, true), 20_000, `${label} Route visual matrix`);
|
||
|
||
assert.equal(matrix.results.length, 8);
|
||
for (const result of matrix.results) {
|
||
assert.equal(result.type, 'image/png', `${label} ${result.preset}/${result.theme} MIME`);
|
||
assert.equal(result.width, 1200, `${label} ${result.preset}/${result.theme} width`);
|
||
assert.equal(result.height, 630, `${label} ${result.preset}/${result.theme} height`);
|
||
assert.ok(result.size > 20_000, `${label} ${result.preset}/${result.theme} is unexpectedly small`);
|
||
assert.equal(result.identity, matrix.identity, `${label} ${result.preset}/${result.theme} changed route identity`);
|
||
}
|
||
assert.equal(new Set(matrix.results.map((result) => result.hash)).size, 8, `${label} presets/themes should produce eight distinct PNGs`);
|
||
console.log(`ok ${label} Route visual matrix: Classic/Flow/Blueprint/Editorial x dark/light`);
|
||
}
|
||
|
||
async function captureReachShareCard(file, label, originId, direction, options = {}) {
|
||
await navigateReady(file, '!!(window.Archify && Archify.focus && Archify.focus.reachabilitySnapshot && Archify.exportMenu && Archify.exportMenu.downloadReachShareCard)', label);
|
||
const reachPayload = await withTimeout(evaluate(cdp, sessionId, String.raw`(async function () {
|
||
try {
|
||
window.alert = function (message) { window.__archifyReachAlert = String(message); };
|
||
if (!Archify.focus.set(${JSON.stringify(originId)}, { toggle: false, updateUrl: false })) {
|
||
throw new Error('focus origin did not resolve');
|
||
}
|
||
if (!Archify.focus.reach(${JSON.stringify(direction)}, { toggle: false, updateUrl: false, reveal: false })) {
|
||
throw new Error('authored reach did not resolve');
|
||
}
|
||
var snapshot = Archify.focus.reachabilitySnapshot();
|
||
if (!snapshot) throw new Error('active authored reach exposed no export snapshot');
|
||
Archify.exportMenu.syncReachShare();
|
||
var reachMenuItem = document.querySelector('[data-action="reach-share-card"]');
|
||
var menuResolved = !!reachMenuItem && !reachMenuItem.hidden && !reachMenuItem.disabled &&
|
||
getComputedStyle(reachMenuItem).display !== 'none';
|
||
var svg = document.querySelector('.diagram-container svg');
|
||
|
||
var firstEdge = snapshot.edges[0];
|
||
var primaryCarrier = Array.from(svg.querySelectorAll('[data-edge-key]')).find(function (element) {
|
||
return element.getAttribute('data-edge-key') === firstEdge.key && hasDrawableGeometry(element);
|
||
});
|
||
if (!primaryCarrier) throw new Error('reach exposed no primary geometry carrier');
|
||
var duplicateCarrier = primaryCarrier.cloneNode(true);
|
||
var duplicateGeometry = /^(path|line|polyline)$/i.test(duplicateCarrier.tagName)
|
||
? duplicateCarrier
|
||
: duplicateCarrier.querySelector('path, line, polyline');
|
||
if (duplicateGeometry.tagName.toLowerCase() === 'path') {
|
||
duplicateGeometry.setAttribute('d', duplicateGeometry.getAttribute('d') + ' M 0 0 L 1 1');
|
||
}
|
||
svg.appendChild(duplicateCarrier);
|
||
var duplicateGeometryRejected = Archify.focus.reachabilitySnapshot() === null;
|
||
var duplicateExportError = '';
|
||
try { await Archify.exportMenu.shareCard({ variant: 'reach' }); }
|
||
catch (error) { duplicateExportError = String(error && error.message || error); }
|
||
duplicateCarrier.remove();
|
||
snapshot = Archify.focus.reachabilitySnapshot();
|
||
if (!snapshot) throw new Error('reach snapshot did not recover after geometry restoration');
|
||
|
||
function stableLiveSnapshot() {
|
||
var clone = svg.cloneNode(true);
|
||
clone.style.removeProperty('transform');
|
||
clone.style.removeProperty('clip-path');
|
||
clone.removeAttribute('data-view-scale');
|
||
Array.from(clone.querySelectorAll('[data-legend-bridge-runtime]')).forEach(function (element) { element.remove(); });
|
||
return clone.outerHTML;
|
||
}
|
||
function fingerprintBytes(bytes) {
|
||
var hash = 2166136261;
|
||
for (var index = 0; index < bytes.length; index++) {
|
||
hash ^= bytes[index];
|
||
hash = Math.imul(hash, 16777619);
|
||
}
|
||
return (hash >>> 0).toString(16);
|
||
}
|
||
var liveBefore = stableLiveSnapshot();
|
||
var captured = [];
|
||
var downloads = [];
|
||
var originalCreateObjectURL = URL.createObjectURL.bind(URL);
|
||
var originalAnchorClick = HTMLAnchorElement.prototype.click;
|
||
URL.createObjectURL = function (blob) {
|
||
if (blob && blob.type && blob.type.indexOf('image/svg+xml') === 0) captured.push(blob.text());
|
||
return originalCreateObjectURL(blob);
|
||
};
|
||
HTMLAnchorElement.prototype.click = function () { downloads.push(this.download); };
|
||
|
||
try {
|
||
var blob = await Archify.exportMenu.shareCard({ variant: 'reach' });
|
||
var reachSvgText = captured[0] ? await captured[0] : '';
|
||
var downloadedBlob = await Archify.exportMenu.downloadReachShareCard();
|
||
var reachReceipt = {
|
||
format: document.documentElement.getAttribute('data-last-export-format'),
|
||
variant: document.documentElement.getAttribute('data-last-export-variant'),
|
||
width: document.documentElement.getAttribute('data-last-export-width'),
|
||
height: document.documentElement.getAttribute('data-last-export-height'),
|
||
canonical: document.documentElement.getAttribute('data-last-export-canonical'),
|
||
routeStateClean: document.documentElement.getAttribute('data-last-export-route-state-clean'),
|
||
reachStateClean: document.documentElement.getAttribute('data-last-export-reach-state-clean'),
|
||
error: document.documentElement.getAttribute('data-last-export-error')
|
||
};
|
||
|
||
var parser = new DOMParser();
|
||
var reachSvg = parser.parseFromString(reachSvgText, 'image/svg+xml').documentElement;
|
||
var matchedNodeIds = Array.from(reachSvg.querySelectorAll('[data-node-id][data-share-reach-match]')).map(function (node) {
|
||
return node.getAttribute('data-node-id');
|
||
});
|
||
var matchedEdgeKeys = Array.from(new Set(Array.from(reachSvg.querySelectorAll('[data-edge-key][data-share-reach-match]')).map(function (edge) {
|
||
return edge.getAttribute('data-edge-key');
|
||
})));
|
||
var reachNodeIds = Array.from(reachSvg.querySelectorAll('[data-node-id]')).map(function (node) {
|
||
return node.getAttribute('data-node-id');
|
||
}).sort();
|
||
var liveNodeIds = Array.from(svg.querySelectorAll('[data-node-id]')).map(function (node) {
|
||
return node.getAttribute('data-node-id');
|
||
}).sort();
|
||
var reachEdgeKeys = Array.from(new Set(Array.from(reachSvg.querySelectorAll('[data-edge-key]')).map(function (edge) {
|
||
return edge.getAttribute('data-edge-key');
|
||
}))).sort();
|
||
var liveEdgeKeys = Array.from(new Set(Array.from(svg.querySelectorAll('[data-edge-key]')).map(function (edge) {
|
||
return edge.getAttribute('data-edge-key');
|
||
}))).sort();
|
||
var reachStyles = Array.from(reachSvg.querySelectorAll('style')).map(function (style) { return style.textContent; }).join('\n');
|
||
|
||
await new Promise(function (resolve) {
|
||
requestAnimationFrame(function () { requestAnimationFrame(resolve); });
|
||
});
|
||
var liveAfter = stableLiveSnapshot();
|
||
|
||
var matrix = [];
|
||
if (${JSON.stringify(options.matrix === true)}) {
|
||
var identity = JSON.stringify(snapshot);
|
||
for (var preset of ['classic', 'signal-flow', 'blueprint', 'editorial']) {
|
||
if (!Archify.preset.apply(preset)) throw new Error('could not apply preset ' + preset);
|
||
for (var theme of ['dark', 'light']) {
|
||
document.documentElement.setAttribute('data-theme', theme);
|
||
var matrixBlob = await Archify.exportMenu.shareCard({ variant: 'reach' });
|
||
var matrixBytes = new Uint8Array(await matrixBlob.arrayBuffer());
|
||
var matrixView = new DataView(matrixBytes.buffer, matrixBytes.byteOffset, matrixBytes.byteLength);
|
||
matrix.push({
|
||
preset: preset,
|
||
theme: theme,
|
||
type: matrixBlob.type,
|
||
size: matrixBlob.size,
|
||
width: matrixView.getUint32(16),
|
||
height: matrixView.getUint32(20),
|
||
hash: fingerprintBytes(matrixBytes),
|
||
identity: JSON.stringify(Archify.focus.reachabilitySnapshot())
|
||
});
|
||
}
|
||
}
|
||
if (matrix.some(function (entry) { return entry.identity !== identity; })) {
|
||
throw new Error('visual matrix changed authored reach identity');
|
||
}
|
||
}
|
||
|
||
Archify.focus.clearReach({ updateUrl: false });
|
||
Archify.exportMenu.syncReachShare();
|
||
var hiddenAfterClear = reachMenuItem.hidden && reachMenuItem.disabled &&
|
||
getComputedStyle(reachMenuItem).display === 'none';
|
||
var staleError = '';
|
||
try { await Archify.exportMenu.shareCard({ variant: 'reach' }); }
|
||
catch (error) { staleError = String(error && error.message || error); }
|
||
await Archify.exportMenu.downloadReachShareCard();
|
||
var failedReceipt = {
|
||
format: document.documentElement.getAttribute('data-last-export-format'),
|
||
variant: document.documentElement.getAttribute('data-last-export-variant'),
|
||
errorFormat: document.documentElement.getAttribute('data-last-export-error-format'),
|
||
error: document.documentElement.getAttribute('data-last-export-error')
|
||
};
|
||
var canonicalIndex = captured.length;
|
||
var canonicalBlob = await Archify.exportMenu.shareCard();
|
||
var canonicalSvgText = captured[canonicalIndex] ? await captured[canonicalIndex] : '';
|
||
var canonicalSvg = parser.parseFromString(canonicalSvgText, 'image/svg+xml').documentElement;
|
||
var canonicalReachResidue = canonicalSvg.hasAttribute('data-share-reach') ||
|
||
canonicalSvg.hasAttribute('data-reach-active') ||
|
||
canonicalSvg.querySelectorAll('[data-share-reach-match], [data-share-reach-origin], [data-share-reach-depth], [data-reach-match], [data-reach-origin], [data-reach-depth]').length > 0;
|
||
|
||
var bytes = new Uint8Array(await blob.arrayBuffer());
|
||
var binary = '';
|
||
for (var offset = 0; offset < bytes.length; offset += 32768) {
|
||
binary += String.fromCharCode.apply(null, bytes.subarray(offset, offset + 32768));
|
||
}
|
||
return {
|
||
ok: true,
|
||
type: blob.type,
|
||
size: blob.size,
|
||
base64: btoa(binary),
|
||
snapshot: snapshot,
|
||
matchedNodeIds: matchedNodeIds,
|
||
matchedEdgeKeys: matchedEdgeKeys,
|
||
expectedEdgeKeys: snapshot.edges.map(function (edge) { return edge.key; }),
|
||
reachDirection: reachSvg.getAttribute('data-share-reach'),
|
||
originCount: reachSvg.querySelectorAll('[data-node-id][data-share-reach-origin]').length,
|
||
reachLiveResidue: reachSvg.hasAttribute('data-reach-active') || reachSvg.querySelectorAll('[data-reach-match], [data-reach-origin], [data-reach-depth]').length > 0,
|
||
routeResidue: reachSvg.hasAttribute('data-share-route') || reachSvg.querySelectorAll('[data-share-route-match], [data-share-route-step], [data-route-match], [data-route-step]').length > 0,
|
||
motionResidue: reachSvg.hasAttribute('data-animation') || reachSvg.querySelectorAll('[data-animate]').length > 0,
|
||
blueprintStaticRule: /data-preset=\"blueprint\"\]\[data-share-reach\][^}]*filter:\s*none/.test(reachStyles),
|
||
reachNodeIds: reachNodeIds,
|
||
liveNodeIds: liveNodeIds,
|
||
reachEdgeKeys: reachEdgeKeys,
|
||
liveEdgeKeys: liveEdgeKeys,
|
||
liveUnchanged: liveBefore === liveAfter,
|
||
menuResolved: menuResolved,
|
||
duplicateGeometryRejected: duplicateGeometryRejected,
|
||
duplicateExportError: duplicateExportError,
|
||
downloadStable: downloadedBlob && downloadedBlob.type === 'image/png',
|
||
downloads: downloads,
|
||
reachReceipt: reachReceipt,
|
||
hiddenAfterClear: hiddenAfterClear,
|
||
staleSnapshot: Archify.focus.reachabilitySnapshot(),
|
||
staleError: staleError,
|
||
failedReceipt: failedReceipt,
|
||
canonicalSize: canonicalBlob.size,
|
||
canonicalReachResidue: canonicalReachResidue,
|
||
matrix: matrix
|
||
};
|
||
} finally {
|
||
URL.createObjectURL = originalCreateObjectURL;
|
||
HTMLAnchorElement.prototype.click = originalAnchorClick;
|
||
}
|
||
} catch (error) {
|
||
return { ok: false, error: String(error && error.message || error) };
|
||
}
|
||
})()`, true), options.matrix ? 35_000 : 15_000, `${label} Reach Card export`);
|
||
|
||
assert.equal(reachPayload?.ok, true, reachPayload?.error || `${label} Reach Card export failed`);
|
||
assert.equal(reachPayload.type, 'image/png');
|
||
assert.ok(reachPayload.size > 20_000, `${label} Reach Card is unexpectedly small (${reachPayload.size} bytes)`);
|
||
const png = Buffer.from(reachPayload.base64, 'base64');
|
||
assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a', `${label} Reach Card is not a PNG`);
|
||
assert.equal(png.readUInt32BE(16), 1200, `${label} Reach Card width`);
|
||
assert.equal(png.readUInt32BE(20), 630, `${label} Reach Card height`);
|
||
if (options.outputPath) {
|
||
fs.mkdirSync(path.dirname(options.outputPath), { recursive: true });
|
||
fs.writeFileSync(options.outputPath, png);
|
||
}
|
||
assert.equal(reachPayload.reachDirection, direction);
|
||
assert.deepEqual(reachPayload.matchedNodeIds.slice().sort(), reachPayload.snapshot.nodeIds.slice().sort());
|
||
assert.deepEqual(reachPayload.matchedEdgeKeys.slice().sort(), reachPayload.expectedEdgeKeys.slice().sort());
|
||
assert.equal(reachPayload.originCount, 1);
|
||
assert.equal(reachPayload.reachLiveResidue, false);
|
||
assert.equal(reachPayload.routeResidue, false);
|
||
assert.equal(reachPayload.motionResidue, false);
|
||
assert.equal(reachPayload.blueprintStaticRule, true);
|
||
assert.deepEqual(reachPayload.reachNodeIds, reachPayload.liveNodeIds);
|
||
assert.deepEqual(reachPayload.reachEdgeKeys, reachPayload.liveEdgeKeys);
|
||
assert.equal(reachPayload.liveUnchanged, true);
|
||
assert.equal(reachPayload.menuResolved, true);
|
||
assert.equal(reachPayload.duplicateGeometryRejected, true);
|
||
assert.match(reachPayload.duplicateExportError, /Trace authored reach before exporting a Reach Share Card/);
|
||
assert.equal(reachPayload.downloadStable, true);
|
||
assert.match(reachPayload.downloads[0], new RegExp(`-${direction}-reach-share-card\\.png$`));
|
||
assert.deepEqual(reachPayload.reachReceipt, {
|
||
format: 'share-card',
|
||
variant: 'reach',
|
||
width: '1200',
|
||
height: '630',
|
||
canonical: 'false',
|
||
routeStateClean: null,
|
||
reachStateClean: 'true',
|
||
error: null,
|
||
});
|
||
assert.equal(reachPayload.hiddenAfterClear, true);
|
||
assert.equal(reachPayload.staleSnapshot, null);
|
||
assert.match(reachPayload.staleError, /Trace authored reach before exporting a Reach Share Card/);
|
||
assert.equal(reachPayload.failedReceipt.format, null);
|
||
assert.equal(reachPayload.failedReceipt.variant, null);
|
||
assert.equal(reachPayload.failedReceipt.errorFormat, 'share-card');
|
||
assert.match(reachPayload.failedReceipt.error, /Trace authored reach before exporting a Reach Share Card/);
|
||
assert.ok(reachPayload.canonicalSize > 20_000);
|
||
assert.equal(reachPayload.canonicalReachResidue, false);
|
||
if (options.matrix) {
|
||
assert.equal(reachPayload.matrix.length, 8);
|
||
assert.equal(new Set(reachPayload.matrix.map((entry) => entry.hash)).size, 8, `${label} Reach presets/themes should produce eight distinct PNGs`);
|
||
for (const entry of reachPayload.matrix) {
|
||
assert.equal(entry.type, 'image/png');
|
||
assert.equal(entry.width, 1200);
|
||
assert.equal(entry.height, 630);
|
||
assert.ok(entry.size > 20_000);
|
||
}
|
||
}
|
||
console.log(`ok ${label} Reach Card: ${reachPayload.size} bytes, ${reachPayload.snapshot.nodeIds.length} nodes, ${reachPayload.snapshot.edges.length} authored links`);
|
||
}
|
||
|
||
await verifyResolvedLegendContract(legendOutputs);
|
||
await verifySemanticPassportDismissal(path.resolve(skillRoot, '../docs/gallery/artifacts/production-deployment.architecture.html'));
|
||
await verifyArchitectureDeltaNavigator(path.resolve(skillRoot, '../examples/checkout-platform-delta.html'));
|
||
await captureShareCard(output, 'architecture-wide');
|
||
await captureShareCard(sequenceOutput, 'sequence-tall');
|
||
await captureCopiedShareCard(output, 'architecture-wide');
|
||
await captureRouteShareCard(routeOutputs.architecture, 'architecture-route', 'users', 'api', { journeyInvariance: true });
|
||
await captureRouteShareCard(routeOutputs.workflow, 'workflow-route', 'user', 'approval');
|
||
await captureRouteShareCard(routeOutputs.sequence, 'sequence-route', 'web', 'db');
|
||
await captureRouteShareCard(routeOutputs.dataflow, 'dataflow-route', 'web', 'dashboard');
|
||
await captureRouteShareCard(routeOutputs.lifecycle, 'lifecycle-route', 'executing', 'cancelled');
|
||
await captureRouteShareCard(parallelOutput, 'parallel-route', 'users', 'cdn');
|
||
await captureRouteShareCard(specialRouteOutput, 'special-10-hop-route', 'Route_Source-01', 'Route_Target-10', {
|
||
expectedHops: 10,
|
||
expectedSourceLabel: specialSourceLabel,
|
||
expectedTargetLabel: specialTargetLabel,
|
||
});
|
||
await captureRouteVisualMatrix(routeOutputs.architecture, 'architecture-route', 'users', 'api');
|
||
await captureRouteVisualMatrix(routeOutputs.sequence, 'sequence-route', 'web', 'db');
|
||
await captureReachShareCard(routeOutputs.architecture, 'architecture-reach', 'users', 'downstream', { matrix: true });
|
||
await captureReachShareCard(routeOutputs.workflow, 'workflow-reach', 'user', 'downstream');
|
||
await captureReachShareCard(routeOutputs.sequence, 'sequence-reach', 'web', 'downstream');
|
||
await captureReachShareCard(routeOutputs.dataflow, 'dataflow-reach', 'web', 'downstream');
|
||
await captureReachShareCard(routeOutputs.lifecycle, 'lifecycle-reach', 'executing', 'downstream');
|
||
if (externalReachSource) {
|
||
assert.ok(fs.existsSync(externalReachSource), `external Reach Card source does not exist: ${externalReachSource}`);
|
||
await captureReachShareCard(
|
||
externalReachSource,
|
||
'external-reach',
|
||
process.env.ARCHIFY_REACH_CARD_ORIGIN || 'router',
|
||
process.env.ARCHIFY_REACH_CARD_DIRECTION || 'downstream',
|
||
{ outputPath: externalReachOutput },
|
||
);
|
||
console.log(`ok external Reach Card artifact: ${externalReachOutput}`);
|
||
}
|
||
await verifyDynamicReducedMotionRoute(routeOutputs.architecture, 'architecture-route reduced motion', 'users', 'api');
|
||
await navigateReady(output, '!!(window.Archify && Archify.motion && Archify.motion.canRecord())', 'motion artifact');
|
||
|
||
const payload = await withTimeout(evaluate(cdp, sessionId, String.raw`(async function () {
|
||
try {
|
||
var blob = await Archify.motion.recordWebm({ duration: 1400, fps: 12 });
|
||
var bytes = new Uint8Array(await blob.arrayBuffer());
|
||
var binary = '';
|
||
for (var offset = 0; offset < bytes.length; offset += 32768) {
|
||
binary += String.fromCharCode.apply(null, bytes.subarray(offset, offset + 32768));
|
||
}
|
||
return { ok: true, type: blob.type, size: blob.size, base64: btoa(binary) };
|
||
} catch (error) {
|
||
return { ok: false, error: String(error && error.message || error) };
|
||
}
|
||
})()`, true), 20_000, 'WebM recording');
|
||
assert.equal(payload?.ok, true, payload?.error || 'WebM recording failed');
|
||
assert.match(payload.type, /^video\/webm/);
|
||
assert.ok(payload.size > 10_000, `WebM is unexpectedly small (${payload.size} bytes)`);
|
||
|
||
const webm = path.join(tmp, 'motion.webm');
|
||
fs.writeFileSync(webm, Buffer.from(payload.base64, 'base64'));
|
||
const frameMd5 = execFileSync(ffmpeg, [
|
||
'-v', 'error',
|
||
'-i', webm,
|
||
'-vf', 'fps=6,scale=320:-2',
|
||
'-f', 'framemd5',
|
||
'-',
|
||
], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 });
|
||
const hashes = frameMd5
|
||
.split('\n')
|
||
.filter((line) => line && !line.startsWith('#'))
|
||
.map((line) => line.split(',').at(-1).trim());
|
||
const uniqueFrames = new Set(hashes);
|
||
|
||
assert.ok(hashes.length >= 4, `decoded only ${hashes.length} WebM frames`);
|
||
assert.ok(uniqueFrames.size >= 2, 'decoded WebM frames are static');
|
||
console.log(`ok WebM artifact: ${payload.size} bytes, ${hashes.length} sampled frames, ${uniqueFrames.size} unique`);
|
||
} finally {
|
||
if (cdp && targetId) await withTimeout(cdp.send('Target.closeTarget', { targetId }), 500, 'target close').catch(() => {});
|
||
if (cdp) cdp.socket.close();
|
||
chromeProcess.kill('SIGTERM');
|
||
if (!(await waitForExit(chromeProcess, 1000))) {
|
||
chromeProcess.kill('SIGKILL');
|
||
await waitForExit(chromeProcess, 1000);
|
||
}
|
||
await removeTempTree(tmp);
|
||
}
|