feat: introduce archify skill for generating architecture diagrams
- 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.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
DESKTOP_READABILITY_VIEWPORT,
|
||||
DESKTOP_READER_DIAGRAM_WIDTH,
|
||||
DESKTOP_READER_HORIZONTAL_CHROME,
|
||||
DESKTOP_READER_MIN_WIDTH,
|
||||
MIN_PROJECTED_NODE_TEXT_PX,
|
||||
minimumReadableSourceTextPx,
|
||||
projectedNodeTextPx,
|
||||
} from '../renderers/shared/desktop-readability.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets', 'template.html'), 'utf8');
|
||||
const skill = fs.readFileSync(path.join(skillRoot, 'SKILL.md'), 'utf8');
|
||||
const reader = template.slice(
|
||||
template.indexOf('Adaptive Reader Shell'),
|
||||
template.indexOf('Archify.view = (function ()'),
|
||||
);
|
||||
|
||||
test('wide desktop diagrams use one height-budgeted reader shell instead of breakpoint jumps', () => {
|
||||
assert.match(template, /max-width: var\(--archify-reader-width, 1440px\)/);
|
||||
assert.doesNotMatch(template, /@media \(min-width: 1680px\)[\s\S]{0,180}\.container/);
|
||||
assert.doesNotMatch(template, /@media \(min-width: 1920px\)[\s\S]{0,180}\.container/);
|
||||
assert.match(reader, /var WIDE_RATIO = 1\.55/);
|
||||
assert.match(reader, /var MAX_READER_WIDTH = 1920/);
|
||||
assert.match(reader, /var availableSvgHeight = Math\.max\(1, window\.innerHeight - fixedHeight\)/);
|
||||
assert.match(reader, /var desiredWidth = availableSvgHeight \* ratio \+ chrome\.diagramX/);
|
||||
assert.match(reader, /html\.style\.setProperty\('--archify-reader-width', rounded \+ 'px'\)/);
|
||||
});
|
||||
|
||||
test('desktop readability budget matches the minimum adaptive reader at 1440 by 900', () => {
|
||||
assert.deepEqual(DESKTOP_READABILITY_VIEWPORT, { width: 1440, height: 900 });
|
||||
assert.equal(DESKTOP_READER_MIN_WIDTH, 960);
|
||||
assert.equal(DESKTOP_READER_HORIZONTAL_CHROME, 30);
|
||||
assert.equal(DESKTOP_READER_DIAGRAM_WIDTH, 930);
|
||||
assert.match(reader, new RegExp(`var MIN_READER_WIDTH = ${DESKTOP_READER_MIN_WIDTH}`));
|
||||
assert.match(template, /html\[data-nav-stage-rail="true"\] body \{ padding-block: 0\.375rem; \}/);
|
||||
assert.match(template, /@media \(min-width: 768px\) and \(max-height: 1100px\)[\s\S]*?\.diagram-container \{[\s\S]*?padding: 0\.875rem;[\s\S]*?padding-bottom: calc\(0\.875rem \+ var\(--archify-nav-reserve\)\);/);
|
||||
assert.match(template, /@media \(min-width: 768px\) and \(max-height: 920px\)[\s\S]*?body \{ padding-block: 1\.25rem; \}/);
|
||||
assert.match(template, /\.diagram-container \{[\s\S]*?border: 1px solid var\(--panel-border\)/);
|
||||
});
|
||||
|
||||
test('desktop readability source floor is the inverse of the projected-size gate', () => {
|
||||
const sourceFloor = minimumReadableSourceTextPx(1376);
|
||||
assert.ok(Math.abs(sourceFloor - 8.87741935483871) < 1e-12);
|
||||
assert.ok(Math.abs(projectedNodeTextPx(sourceFloor, 1376) - MIN_PROJECTED_NODE_TEXT_PX) < 1e-12);
|
||||
assert.equal(minimumReadableSourceTextPx(DESKTOP_READER_DIAGRAM_WIDTH), MIN_PROJECTED_NODE_TEXT_PX);
|
||||
assert.equal(minimumReadableSourceTextPx(700), MIN_PROJECTED_NODE_TEXT_PX);
|
||||
assert.ok(Number.isNaN(minimumReadableSourceTextPx(0)));
|
||||
});
|
||||
|
||||
test('adaptive width preserves canonical SVG geometry and yields to specialized viewer modes', () => {
|
||||
assert.match(reader, /window\.innerWidth >= MIN_DESKTOP_WIDTH/);
|
||||
assert.match(reader, /html\.getAttribute\('data-embed'\) !== 'true'/);
|
||||
assert.match(reader, /html\.getAttribute\('data-present'\) !== 'true'/);
|
||||
assert.match(reader, /window\.matchMedia\('print'\)\.matches/);
|
||||
assert.doesNotMatch(reader, /svg\.setAttribute\(['"](?:viewBox|width|height)/);
|
||||
assert.doesNotMatch(reader, /svg\.style\.(?:width|height)/);
|
||||
assert.doesNotMatch(reader, /overflow\s*=\s*['"]hidden/);
|
||||
});
|
||||
|
||||
test('reader remeasures real content and reduces width before allowing desktop page overflow', () => {
|
||||
assert.match(reader, /document\.fonts\.ready\.then\(schedule\)/);
|
||||
assert.match(reader, /new ResizeObserver\(schedule\)/);
|
||||
assert.match(reader, /new MutationObserver\(schedule\)/);
|
||||
assert.match(reader, /document\.documentElement\.scrollHeight/);
|
||||
assert.match(reader, /lastWidth - overflow \* ratio - 4/);
|
||||
assert.match(skill, /1440×900, 1600×1000, and 1920×1080/);
|
||||
assert.match(skill, /2048×1320/);
|
||||
assert.match(skill, /Generate one responsive artifact for laptops and external displays/);
|
||||
assert.match(skill, /preserve the authored SVG\/viewBox, proportions, semantic geometry/);
|
||||
});
|
||||
|
||||
test('reader exposes an explicit stable-dimensions contract for browser evidence', () => {
|
||||
assert.match(reader, /function stableSnapshot\(\)/);
|
||||
assert.match(reader, /function whenStable\(\)/);
|
||||
assert.match(reader, /document\.fonts && document\.fonts\.ready/);
|
||||
assert.match(reader, /Math\.ceil\(document\.body\.scrollHeight\)/);
|
||||
assert.match(reader, /stableFrames >= 3/);
|
||||
assert.match(reader, /whenStable: whenStable/);
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-animation-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
const NODE_COLLECTION = {
|
||||
architecture: 'components',
|
||||
workflow: 'nodes',
|
||||
sequence: 'participants',
|
||||
dataflow: 'nodes',
|
||||
lifecycle: 'states',
|
||||
};
|
||||
|
||||
function render(mode, example, animation = 'trace', visualPreset) {
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', example), 'utf8'));
|
||||
if (animation) doc.meta = { ...doc.meta, animation };
|
||||
else delete doc.meta.animation;
|
||||
if (visualPreset) doc.meta.visual_preset = visualPreset;
|
||||
else if (visualPreset === null) delete doc.meta.visual_preset;
|
||||
const suffix = `${animation || 'static'}-${visualPreset || 'default'}`;
|
||||
const input = path.join(tmp, `${mode}-${suffix}.json`);
|
||||
const output = path.join(tmp, `${mode}-${suffix}.html`);
|
||||
fs.writeFileSync(input, JSON.stringify(doc));
|
||||
execFileSync('node', [path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`), input, output], {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function svgBlock(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('static output omits animation attributes', () => {
|
||||
const svg = svgBlock(render('workflow', CASES.workflow, null, null));
|
||||
assert.doesNotMatch(svg, /data-animation=/);
|
||||
assert.doesNotMatch(svg, /data-animate=/);
|
||||
});
|
||||
|
||||
test('classic preset remains the default for existing diagrams', () => {
|
||||
const html = render('architecture', CASES.architecture, null, null);
|
||||
assert.match(html, /<html lang="en" data-theme="dark" data-preset="classic">/);
|
||||
assert.match(svgBlock(html), /data-preset="classic"/);
|
||||
});
|
||||
|
||||
test('signal-flow preset reaches the page, SVG, and motion export surface', () => {
|
||||
const html = render('workflow', CASES.workflow, 'trace', 'signal-flow');
|
||||
assert.match(html, /<html lang="en" data-theme="dark" data-preset="signal-flow">/);
|
||||
assert.match(svgBlock(html), /data-preset="signal-flow"/);
|
||||
assert.match(html, /content: attr\(data-preset-badge-signal-flow\)/);
|
||||
assert.match(html, /data-preset-badge-signal-flow="SIGNAL FLOW"/);
|
||||
assert.match(html, /data-format="webm"/);
|
||||
assert.match(html, /data-last-motion-bytes/);
|
||||
assert.match(html, /Archify\.motion = \{ canRecord: canRecordMotion, recordWebm: recordWebm \}/);
|
||||
assert.match(html, /recorder\.requestData\(\)/);
|
||||
assert.match(html, /aria-label="Diagram view controls"/);
|
||||
assert.match(html, /Archify\.focus = \(function \(\)/);
|
||||
assert.match(html, /Archify\.view = \(function \(\)/);
|
||||
assert.match(html, /clone\.style\.removeProperty\('transform'\)/);
|
||||
assert.match(html, /clone\.removeAttribute\('data-view-scale'\)/);
|
||||
assert.match(html, /data-last-export-canonical/);
|
||||
assert.match(html, /data-last-export-error-format/);
|
||||
assert.match(html, /data-last-export-error/);
|
||||
assert.match(html, /WebM unavailable in this browser/);
|
||||
assert.match(html, /Motion capture unavailable in this browser/);
|
||||
assert.match(html, /canonicalStateClean: canonicalStateClean/);
|
||||
assert.match(html, /recordExportReceipt\('svg', blob, d\.canonicalStateClean\)/);
|
||||
});
|
||||
|
||||
test('webm renders an explicit time-varying canvas scene instead of replaying one cached SVG bitmap', () => {
|
||||
const html = render('architecture', CASES.architecture, 'trace', 'signal-flow');
|
||||
const recordBlock = html.match(/function recordWebm\(options\) \{[\s\S]*?\n var menu =/)?.[0] || '';
|
||||
|
||||
assert.match(recordBlock, /var motionScene = createMotionScene\(svg\)/);
|
||||
assert.match(recordBlock, /drawMotionFrame\(ctx, backgroundImage, motionScene, elapsed\)/);
|
||||
assert.match(recordBlock, /getPointAtLength/);
|
||||
assert.match(recordBlock, /performance\.now\(\)/);
|
||||
assert.doesNotMatch(
|
||||
recordBlock,
|
||||
/function draw\(\) \{[\s\S]*?ctx\.drawImage\(img, 0, 0, canvas\.width, canvas\.height\);[\s\S]*?requestAnimationFrame\(draw\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('blueprint preset reaches every visual surface without changing the default', () => {
|
||||
const html = render('architecture', CASES.architecture, null, 'blueprint');
|
||||
assert.match(html, /<html lang="en" data-theme="dark" data-preset="blueprint">/);
|
||||
assert.match(svgBlock(html), /data-preset="blueprint"/);
|
||||
assert.match(html, /content: attr\(data-preset-badge-blueprint\)/);
|
||||
assert.match(html, /data-preset-badge-blueprint="BLUEPRINT \/ REV 01"/);
|
||||
assert.match(html, /\[data-preset="blueprint"\]\[data-theme="dark"\]/);
|
||||
assert.match(html, /svg\[data-preset="blueprint"\] \.c-grid/);
|
||||
assert.match(html, /html\[data-preset="blueprint"\] \.guided-views/);
|
||||
assert.match(html, /html\[data-preset="blueprint"\] \.card/);
|
||||
});
|
||||
|
||||
test('blueprint preset is accepted by all five typed renderers', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example, null, 'blueprint');
|
||||
assert.match(html, /data-preset="blueprint"/, mode);
|
||||
assert.match(svgBlock(html), /data-preset="blueprint"/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('editorial preset reaches every visual surface and all five typed renderers', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example, null, 'editorial');
|
||||
assert.match(html, /<html lang="en" data-theme="dark" data-preset="editorial">/, mode);
|
||||
assert.match(svgBlock(html), /data-preset="editorial"/, mode);
|
||||
assert.match(html, /content: attr\(data-preset-badge-editorial\)/, mode);
|
||||
assert.match(html, /data-preset-badge-editorial="EDITORIAL \/ FIELD NOTE"/, mode);
|
||||
assert.match(html, /content: attr\(data-preset-badge-editorial-plate\)/, mode);
|
||||
assert.match(html, /data-preset-badge-editorial-plate="ARCHIFY \/ PLATE 04"/, mode);
|
||||
assert.match(html, /\[data-preset="editorial"\]\[data-theme="dark"\]/, mode);
|
||||
assert.match(html, /html\[data-preset="editorial"\] \.diagram-container/, mode);
|
||||
assert.match(html, /svg\[data-preset="editorial"\] \.story-trail-flow/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('all five renderers add one geometry-neutral semantic sigil per primary node', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', example), 'utf8'));
|
||||
const expected = source[NODE_COLLECTION[mode]].length;
|
||||
const staticHtml = render(mode, example, null, 'classic');
|
||||
const traceHtml = render(mode, example, 'trace', 'classic');
|
||||
const staticSvg = svgBlock(staticHtml);
|
||||
const traceSvg = svgBlock(traceHtml);
|
||||
const sigils = (svg) => [...svg.matchAll(/<g aria-hidden="true" data-semantic-sigil="[^"]+"[\s\S]*?<\/g>/g)].map((match) => match[0]);
|
||||
|
||||
assert.equal(sigils(staticSvg).length, expected, mode);
|
||||
assert.deepEqual(sigils(traceSvg), sigils(staticSvg), `${mode} trace must not change sigil geometry`);
|
||||
assert.match(staticHtml, /svg \.semantic-sigil \{/i, mode);
|
||||
assert.match(staticHtml, /svg \.s-database\s+\{ color: var\(--database-stroke\); \}/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown visual presets are rejected by schema validation', () => {
|
||||
assert.throws(
|
||||
() => render('architecture', CASES.architecture, null, 'hologram'),
|
||||
/visual_preset/,
|
||||
);
|
||||
});
|
||||
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
test(`${mode}: trace animation annotates svg, edges, and nodes`, () => {
|
||||
const svg = svgBlock(render(mode, example));
|
||||
assert.match(svg, /<svg[^>]+data-animation="trace"/);
|
||||
assert.match(svg, /data-animate="edge" style="--step:0"/);
|
||||
assert.match(svg, /data-animate="node" style="--step:0"/);
|
||||
assert.match(svg, /aria-labelledby="archify-diagram-title archify-diagram-description"/);
|
||||
assert.match(svg, /<title id="archify-diagram-title">[^<]+<\/title>/);
|
||||
assert.match(svg, /<desc id="archify-diagram-description">[^<]+<\/desc>/);
|
||||
assert.match(svg, /id="node-[^"]+" data-node-id="[^"]+"[^>]+role="button"[^>]+aria-pressed="false"/);
|
||||
assert.match(svg, /data-edge-from="[^"]+" data-edge-to="[^"]+"/);
|
||||
});
|
||||
}
|
||||
|
||||
test('semantic SVG identity is deterministic for unchanged input', () => {
|
||||
const first = svgBlock(render('workflow', CASES.workflow));
|
||||
const second = svgBlock(render('workflow', CASES.workflow));
|
||||
const hooks = (svg) => [...svg.matchAll(/(?:id="node-|data-edge-from=")[^>]+/g)].map((match) => match[0]);
|
||||
assert.deepEqual(hooks(first), hooks(second));
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,518 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
ArchitectureDeltaError,
|
||||
architectureDeltaChangeRows,
|
||||
canonicalArchitectureJson,
|
||||
compareArchitecture,
|
||||
validateArchitectureDeltaHtml,
|
||||
} from '../delta/architecture-delta.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const cli = path.join(skillRoot, 'bin/archify.mjs');
|
||||
const baseFixture = path.join(skillRoot, 'examples/checkout-platform.base.architecture.json');
|
||||
const headFixture = path.join(skillRoot, 'examples/checkout-platform.head.architecture.json');
|
||||
const checkedArtifact = path.resolve(skillRoot, '../examples/checkout-platform-delta.html');
|
||||
const checkedReceipt = path.resolve(skillRoot, '../examples/checkout-platform-delta.receipt.json');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-delta-'));
|
||||
|
||||
const read = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
const run = (args) => spawnSync(process.execPath, [cli, ...args], { cwd: skillRoot, encoding: 'utf8' });
|
||||
|
||||
test('architecture compare classifies authored facts separately from geometry and presentation', () => {
|
||||
const receipt = compareArchitecture(read(baseFixture), read(headFixture));
|
||||
assert.equal(receipt.command, 'compare');
|
||||
assert.equal(receipt.completeness, 'complete');
|
||||
assert.equal(receipt.proofLevel, 'authored');
|
||||
assert.deepEqual(receipt.summary.components, {
|
||||
added: 1,
|
||||
changed: 1,
|
||||
evidenceChanged: 0,
|
||||
removed: 1,
|
||||
moved: 1,
|
||||
});
|
||||
assert.deepEqual(receipt.summary.connections, {
|
||||
added: 1,
|
||||
changed: 2,
|
||||
removed: 1,
|
||||
rerouted: 1,
|
||||
});
|
||||
assert.equal(receipt.summary.presentationChanged, true);
|
||||
|
||||
const checkout = receipt.changes.components.find((change) => change.id === 'checkout');
|
||||
assert.equal(checkout.status, 'changed');
|
||||
assert.deepEqual(checkout.classifications, ['semantic']);
|
||||
assert.deepEqual(checkout.changedFields, ['/sublabel']);
|
||||
|
||||
const queue = receipt.changes.components.find((change) => change.id === 'queue');
|
||||
assert.equal(queue.status, 'moved');
|
||||
assert.deepEqual(queue.classifications, ['geometry']);
|
||||
assert.deepEqual(queue.changedFields, ['/pos']);
|
||||
|
||||
const authorization = receipt.changes.connections.find((change) => change.id === 'authorize-payment');
|
||||
assert.equal(authorization.status, 'changed');
|
||||
assert.deepEqual(authorization.classifications, ['geometry', 'topology']);
|
||||
assert.deepEqual(authorization.changedFields, ['/from', '/fromSide', '/toSide', '/via']);
|
||||
|
||||
assert.deepEqual(receipt.changes.connections.find((change) => change.status === 'added').classifications, ['topology']);
|
||||
|
||||
const headWithBoundary = read(headFixture);
|
||||
headWithBoundary.boundaries.push({ kind: 'region', label: 'Fraud edge', wraps: ['fraud'] });
|
||||
const boundaryReceipt = compareArchitecture(read(baseFixture), headWithBoundary);
|
||||
assert.deepEqual(boundaryReceipt.changes.boundaries.find((change) => change.label === 'Fraud edge').classifications, ['scope']);
|
||||
});
|
||||
|
||||
test('legend-only changes are presentation changes and never topology changes', () => {
|
||||
const base = read(baseFixture);
|
||||
const head = read(baseFixture);
|
||||
head.meta.legend = {
|
||||
entries: {
|
||||
security: { label: 'Trust boundary', visible: true },
|
||||
database: { visible: false },
|
||||
},
|
||||
};
|
||||
|
||||
const receipt = compareArchitecture(base, head);
|
||||
assert.equal(receipt.summary.presentationChanged, true);
|
||||
assert.deepEqual(receipt.summary.components, {
|
||||
added: 0,
|
||||
changed: 0,
|
||||
evidenceChanged: 0,
|
||||
removed: 0,
|
||||
moved: 0,
|
||||
});
|
||||
assert.deepEqual(receipt.summary.connections, {
|
||||
added: 0,
|
||||
changed: 0,
|
||||
removed: 0,
|
||||
rerouted: 0,
|
||||
});
|
||||
assert.deepEqual(receipt.changes, { components: [], connections: [], boundaries: [] });
|
||||
});
|
||||
|
||||
test('canonical architecture ignores formatting, entity order, and set-like order', () => {
|
||||
const original = read(baseFixture);
|
||||
const reordered = JSON.parse(JSON.stringify(original));
|
||||
reordered.components.reverse();
|
||||
reordered.connections.reverse();
|
||||
reordered.boundaries.reverse();
|
||||
reordered.boundaries.forEach((boundary) => boundary.wraps.reverse());
|
||||
assert.equal(canonicalArchitectureJson(reordered), canonicalArchitectureJson(original));
|
||||
});
|
||||
|
||||
test('change navigator order is exact-ID based, complete, unique, and stable', () => {
|
||||
const receipt = compareArchitecture(read(baseFixture), read(headFixture));
|
||||
const rows = architectureDeltaChangeRows(receipt);
|
||||
assert.deepEqual(rows.map((row) => row.key), [
|
||||
'component:fraud',
|
||||
'relationship:fraud-check',
|
||||
'boundary:region:Production region',
|
||||
'boundary:security-group:Checkout trust zone',
|
||||
'component:checkout',
|
||||
'relationship:authorize-payment',
|
||||
'relationship:persist-order',
|
||||
'component:queue',
|
||||
'component:cache',
|
||||
'relationship:session-read',
|
||||
'relationship:publish-order',
|
||||
]);
|
||||
assert.equal(new Set(rows.map((row) => row.key)).size, rows.length);
|
||||
assert.equal(rows.length, receipt.changes.components.length + receipt.changes.connections.length + receipt.changes.boundaries.length);
|
||||
});
|
||||
|
||||
test('exact identity fails closed instead of guessing relationships or unrelated systems', () => {
|
||||
const base = read(baseFixture);
|
||||
const missingRelationship = read(headFixture);
|
||||
delete missingRelationship.connections[0].id;
|
||||
assert.throws(
|
||||
() => compareArchitecture(base, missingRelationship),
|
||||
(error) => error instanceof ArchitectureDeltaError
|
||||
&& error.code === 'delta/relationship-id-required'
|
||||
&& error.details.paths.includes('/connections/0/id'),
|
||||
);
|
||||
|
||||
const unrelated = read(headFixture);
|
||||
unrelated.components = unrelated.components.map((component, index) => ({ ...component, id: `other${index}` }));
|
||||
unrelated.connections = [];
|
||||
unrelated.boundaries = [];
|
||||
assert.throws(
|
||||
() => compareArchitecture(base, unrelated),
|
||||
(error) => error instanceof ArchitectureDeltaError && error.code === 'delta/no-shared-component-id',
|
||||
);
|
||||
});
|
||||
|
||||
test('evidence-only component changes keep an enabled exact review contract', () => {
|
||||
const base = read(baseFixture);
|
||||
const head = read(baseFixture);
|
||||
base.components[0].sources = [{ path: 'src/entry.js', line: 1, label: 'baseline' }];
|
||||
head.components[0].sources = [{ path: 'src/entry.js', line: 2, label: 'head' }];
|
||||
const receipt = compareArchitecture(base, head);
|
||||
assert.equal(receipt.changes.components.length, 1);
|
||||
assert.equal(receipt.changes.components[0].status, 'evidence-changed');
|
||||
assert.deepEqual(receipt.changes.components[0].classifications, ['evidence']);
|
||||
const runtime = fs.readFileSync(path.join(skillRoot, 'delta/architecture-delta.mjs'), 'utf8');
|
||||
assert.match(runtime, /statuses: \['added', 'changed', 'evidence-changed', 'removed', 'moved'\]/);
|
||||
});
|
||||
|
||||
test('mixed semantic and geometry component changes retain both exact forms', () => {
|
||||
const head = read(headFixture);
|
||||
head.components.find((component) => component.id === 'queue').sublabel = 'durable queue v2';
|
||||
const headPath = path.join(tmp, 'mixed-component-head.json');
|
||||
const output = path.join(tmp, 'mixed-component-delta.html');
|
||||
fs.writeFileSync(headPath, JSON.stringify(head));
|
||||
|
||||
const result = run(['compare', 'architecture', baseFixture, headPath, output, '--json']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
const queue = receipt.changes.components.find((change) => change.id === 'queue');
|
||||
assert.equal(queue.status, 'changed');
|
||||
assert.deepEqual(queue.classifications, ['geometry', 'semantic']);
|
||||
const html = fs.readFileSync(output, 'utf8');
|
||||
assert.match(html, /data-change-key="component:queue"[^>]+data-change-target-signature="g:changed:geometry,semantic\|g:moved-from:geometry,semantic"/);
|
||||
assert.deepEqual(validateArchitectureDeltaHtml(html, receipt), { ok: true, checksPassed: 10, checkCount: 10 });
|
||||
});
|
||||
|
||||
test('mixed semantic and geometry relationship changes retain both exact routes', () => {
|
||||
const head = read(headFixture);
|
||||
head.connections.find((connection) => connection.id === 'publish-order').label = 'accepted event';
|
||||
const headPath = path.join(tmp, 'mixed-relationship-head.json');
|
||||
const output = path.join(tmp, 'mixed-relationship-delta.html');
|
||||
fs.writeFileSync(headPath, JSON.stringify(head));
|
||||
|
||||
const result = run(['compare', 'architecture', baseFixture, headPath, output, '--json']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
const publishOrder = receipt.changes.connections.find((change) => change.id === 'publish-order');
|
||||
assert.equal(publishOrder.status, 'changed');
|
||||
assert.deepEqual(publishOrder.classifications, ['geometry', 'semantic']);
|
||||
const html = fs.readFileSync(output, 'utf8');
|
||||
assert.match(html, /data-change-key="relationship:publish-order"[^>]+data-change-target-signature="g:changed:geometry,semantic\|g:moved-from:geometry,semantic\|path:changed:geometry,semantic\|path:moved-from:geometry,semantic\|text:changed:\|text:moved-from:"/);
|
||||
assert.deepEqual(validateArchitectureDeltaHtml(html, receipt), { ok: true, checksPassed: 10, checkCount: 10 });
|
||||
});
|
||||
|
||||
test('baseline boundary title masks stay below current components and carry delta identity', () => {
|
||||
const documentAt = (pos, pad) => ({
|
||||
schema_version: 1,
|
||||
diagram_type: 'architecture',
|
||||
meta: { title: 'Boundary mask z-order', quality_profile: 'standard', viewBox: [600, 400] },
|
||||
components: [{ id: 'node', type: 'backend', label: 'Current node', pos, size: [120, 60] }],
|
||||
connections: [],
|
||||
boundaries: [{ kind: 'region', label: 'Boundary label', wraps: ['node'], pad }],
|
||||
});
|
||||
const basePath = path.join(tmp, 'boundary-mask.base.json');
|
||||
const headPath = path.join(tmp, 'boundary-mask.head.json');
|
||||
const output = path.join(tmp, 'boundary-mask.delta.html');
|
||||
fs.writeFileSync(basePath, JSON.stringify(documentAt([250, 200], 30)));
|
||||
fs.writeFileSync(headPath, JSON.stringify(documentAt([224, 180], 40)));
|
||||
|
||||
const result = run(['compare', 'architecture', basePath, headPath, output, '--json']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const html = fs.readFileSync(output, 'utf8');
|
||||
const delta = html.match(/<section class="canvas" data-view="delta">([\s\S]*?)<\/section>/)?.[1] || '';
|
||||
const currentComponents = delta.indexOf('<!-- Components -->');
|
||||
const currentNode = delta.indexOf('data-node-id="node"', currentComponents);
|
||||
const phantomMask = delta.match(
|
||||
/<rect data-graph-role="structural-frame-label-mask"[^>]*data-delta-state="moved-from"[^>]*data-delta-boundary-state="moved-from"[^>]*data-delta-boundary-mask-key="region:Boundary label"[^>]*\/>/,
|
||||
)?.[0];
|
||||
const currentNodeRect = delta.slice(currentNode).match(/<rect\b[^>]*\/>/)?.[0];
|
||||
assert.ok(phantomMask && currentNodeRect, 'expected the phantom mask and current component rect');
|
||||
const rect = (tag) => Object.fromEntries(
|
||||
[...tag.matchAll(/\b(x|y|width|height)="([^"]+)"/g)].map((match) => [match[1], Number(match[2])]),
|
||||
);
|
||||
const maskBox = rect(phantomMask);
|
||||
const nodeBox = rect(currentNodeRect);
|
||||
const overlaps = maskBox.x < nodeBox.x + nodeBox.width
|
||||
&& maskBox.x + maskBox.width > nodeBox.x
|
||||
&& maskBox.y < nodeBox.y + nodeBox.height
|
||||
&& maskBox.y + maskBox.height > nodeBox.y;
|
||||
assert.equal(overlaps, true, `expected overlap: ${JSON.stringify({ maskBox, nodeBox })}`);
|
||||
assert.ok(delta.indexOf(phantomMask) < currentNode, 'phantom mask must paint below the current component');
|
||||
});
|
||||
|
||||
test('same-label node id changes remain one removal plus one addition', () => {
|
||||
const base = read(baseFixture);
|
||||
const head = read(baseFixture);
|
||||
const cache = head.components.find((component) => component.id === 'cache');
|
||||
cache.id = 'session-store';
|
||||
head.boundaries.forEach((boundary) => {
|
||||
boundary.wraps = boundary.wraps.map((id) => (id === 'cache' ? 'session-store' : id));
|
||||
});
|
||||
head.connections.find((connection) => connection.id === 'session-read').to = 'session-store';
|
||||
|
||||
const receipt = compareArchitecture(base, head);
|
||||
assert.equal(receipt.changes.components.find((change) => change.id === 'cache').status, 'removed');
|
||||
assert.equal(receipt.changes.components.find((change) => change.id === 'session-store').status, 'added');
|
||||
assert.equal(receipt.changes.components.filter((change) => change.headLabel === 'Session Cache' || change.baseLabel === 'Session Cache').length, 2);
|
||||
});
|
||||
|
||||
test('repository mismatch fails and verified matching revisions remain evidence-bounded', () => {
|
||||
const base = read(baseFixture);
|
||||
const head = read(headFixture);
|
||||
base.meta.repository = { url: 'https://github.com/example/one', revision: 'a'.repeat(40) };
|
||||
head.meta.repository = { url: 'https://github.com/example/two', revision: 'b'.repeat(40) };
|
||||
assert.throws(
|
||||
() => compareArchitecture(base, head),
|
||||
(error) => error instanceof ArchitectureDeltaError && error.code === 'delta/repository-mismatch',
|
||||
);
|
||||
|
||||
head.meta.repository.url = 'https://github.com/EXAMPLE/ONE.git/';
|
||||
const receipt = compareArchitecture(base, head, { baseVerified: true, headVerified: true });
|
||||
assert.equal(receipt.proofLevel, 'revision-pinned');
|
||||
assert.equal(receipt.summary.provenanceChanged, true);
|
||||
});
|
||||
|
||||
test('compare CLI writes a deterministic three-state artifact and complete sidecar receipt', () => {
|
||||
const first = path.join(tmp, 'first.html');
|
||||
const second = path.join(tmp, 'second.html');
|
||||
const result = run(['compare', 'architecture', baseFixture, headFixture, first, '--json']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const repeat = run(['compare', 'architecture', baseFixture, headFixture, second, '--json']);
|
||||
assert.equal(repeat.status, 0, repeat.stderr);
|
||||
|
||||
const firstHtml = fs.readFileSync(first, 'utf8');
|
||||
const secondHtml = fs.readFileSync(second, 'utf8');
|
||||
assert.equal(firstHtml, secondHtml);
|
||||
assert.equal((firstHtml.match(/<section class="canvas" data-view=/g) || []).length, 3);
|
||||
assert.match(firstHtml, /data-view="delta">/);
|
||||
assert.match(firstHtml, /data-node-id="cache"[^>]+data-delta-state="removed"/);
|
||||
assert.match(firstHtml, /data-node-id="fraud"[^>]+data-delta-state="added"/);
|
||||
assert.match(firstHtml, /data-node-id="queue"[^>]+data-delta-state="moved-from"/);
|
||||
assert.match(firstHtml, /aria-label="Authored change review"/);
|
||||
assert.equal((firstHtml.match(/class="change-row"/g) || []).length, 11);
|
||||
assert.match(firstHtml, /data-change-key="component:fraud"/);
|
||||
assert.match(firstHtml, /data-change-key="relationship:authorize-payment"/);
|
||||
assert.match(firstHtml, /data-change-key="boundary:region:Production region"/);
|
||||
assert.match(firstHtml, /data-change-target-signature="[^"]+"/);
|
||||
assert.match(firstHtml, /data-delta-boundary-key="region:Production region"/);
|
||||
assert.equal((firstHtml.match(/class="snapshot-frame"/g) || []).length, 2);
|
||||
assert.match(firstHtml, /title="Before architecture explorer"/);
|
||||
assert.match(firstHtml, /title="After architecture explorer"/);
|
||||
assert.match(firstHtml, /id="export-svg"[^>]*>Export SVG</);
|
||||
assert.match(firstHtml, /id="share-card"[^>]*>Share Card</);
|
||||
assert.match(firstHtml, /window\.Archify\.deltaExport = \{ canonicalSvg: canonicalDeltaSvg, shareCard/);
|
||||
assert.match(firstHtml, /canvas\.width = 1200;[\s\S]*canvas\.height = 630;/);
|
||||
assert.match(firstHtml, /structural-frame.*stroke:var\(--delta\)!important/);
|
||||
assert.match(firstHtml, /structural-frame.*data-delta-state="changed".*stroke-dasharray:2 3!important/);
|
||||
assert.match(firstHtml, /data-delta-boundary-state="added".*fill:#34d399!important/);
|
||||
assert.match(firstHtml, /delta-boundary-marker\[data-delta-state\]\{color:var\(--delta\)\}/);
|
||||
assert.match(firstHtml, /No authored architecture changes ·.*movementSummary/);
|
||||
assert.match(firstHtml, /font-family:"JetBrains Mono",ui-monospace/);
|
||||
assert.doesNotMatch(firstHtml, /font-family:Inter|body\{min-width:1080px/);
|
||||
assert.match(firstHtml, /@media\(max-width:760px\)/);
|
||||
assert.match(firstHtml, /\.canvas svg\{min-width:720px;max-height:none\}/);
|
||||
assert.match(firstHtml, /\.changes\{overflow-x:auto\}/);
|
||||
assert.match(firstHtml, /const REVIEW_DWELL_MS = 1400;/);
|
||||
assert.match(firstHtml, /prefers-reduced-motion: reduce/);
|
||||
assert.match(firstHtml, /:not\(\[data-delta-review-current\]\)/);
|
||||
assert.match(firstHtml, /--review-same-opacity:1;--review-change-opacity:1/);
|
||||
assert.match(firstHtml, /--d-focus:#006b8f/);
|
||||
assert.match(firstHtml, /document\.querySelectorAll\('#archify-compare-receipt'\)\.length !== 1/);
|
||||
assert.match(firstHtml, /targetsMatch\(reviewSources\[index\], row, matches\)/);
|
||||
assert.match(firstHtml, /document\.addEventListener\('visibilitychange'/);
|
||||
assert.match(firstHtml, /window\.addEventListener\('beforeprint', overview\)/);
|
||||
assert.match(firstHtml, /aria-current', 'step'/);
|
||||
assert.match(firstHtml, /event\.key === 'Enter' \|\| event\.key === ' '/);
|
||||
const deltaShell = firstHtml.replace(/<iframe\b[^>]*><\/iframe>/g, '');
|
||||
assert.doesNotMatch(deltaShell, /localStorage|sessionStorage|history\.(?:pushState|replaceState)/);
|
||||
assert.doesNotMatch(deltaShell, /setInterval\(/);
|
||||
assert.doesNotMatch(deltaShell, /\b(?:SAFE|LOW RISK|MERGEABLE|NO IMPACT|VERIFIED PR)\b/i);
|
||||
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
const sidecar = read(path.join(tmp, 'first.receipt.json'));
|
||||
assert.deepEqual(sidecar, receipt);
|
||||
assert.equal(receipt.artifact.sha256, JSON.parse(repeat.stdout).artifact.sha256);
|
||||
assert.equal(receipt.validation.checksPassed, receipt.validation.checkCount);
|
||||
assert.equal(receipt.completeness, 'complete');
|
||||
assert.equal(JSON.stringify(receipt).includes(tmp), false);
|
||||
assert.deepEqual(validateArchitectureDeltaHtml(firstHtml, receipt), { ok: true, checksPassed: 10, checkCount: 10 });
|
||||
});
|
||||
|
||||
test('checked-in Checkout compare artifact is reproducible from its authoritative inputs', () => {
|
||||
const artifact = path.join(tmp, 'checked-artifact.html');
|
||||
const receipt = path.join(tmp, 'checked-artifact.receipt.json');
|
||||
const result = run([
|
||||
'compare',
|
||||
'architecture',
|
||||
baseFixture,
|
||||
headFixture,
|
||||
artifact,
|
||||
'--receipt',
|
||||
receipt,
|
||||
'--quality',
|
||||
'showcase',
|
||||
'--json',
|
||||
]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(fs.readFileSync(artifact, 'utf8'), fs.readFileSync(checkedArtifact, 'utf8'));
|
||||
assert.deepEqual(read(receipt), read(checkedReceipt));
|
||||
});
|
||||
|
||||
test('artifact validation fails closed on missing, duplicate, or self-blessed review identity', () => {
|
||||
const output = path.join(tmp, 'review-identity.html');
|
||||
const result = run(['compare', 'architecture', baseFixture, headFixture, output, '--json']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
const html = fs.readFileSync(output, 'utf8');
|
||||
const deltaSection = html.match(/<section class="canvas" data-view="delta">([\s\S]*?)<\/section>/)?.[1];
|
||||
assert.ok(deltaSection);
|
||||
const fraudTag = deltaSection.match(/<g\s+[^>]*\bdata-node-id="fraud"[^>]*>/)?.[0];
|
||||
assert.ok(fraudTag);
|
||||
|
||||
const missing = html.replace(fraudTag, fraudTag.replace('data-node-id="fraud"', 'data-node-id="tampered"'));
|
||||
assert.throws(
|
||||
() => validateArchitectureDeltaHtml(missing, receipt),
|
||||
(error) => error instanceof ArchitectureDeltaError
|
||||
&& error.code === 'delta/artifact-invalid'
|
||||
&& error.details.failures.includes('ambiguous Delta identity component:fraud'),
|
||||
);
|
||||
|
||||
const duplicate = html.replace(fraudTag, `${fraudTag}${fraudTag}`);
|
||||
assert.throws(
|
||||
() => validateArchitectureDeltaHtml(duplicate, receipt),
|
||||
(error) => error instanceof ArchitectureDeltaError
|
||||
&& error.code === 'delta/artifact-invalid'
|
||||
&& error.details.failures.includes('ambiguous Delta identity component:fraud'),
|
||||
);
|
||||
|
||||
const relationshipGroup = deltaSection.match(/<g\s+[^>]*\bdata-edge-id="fraud-check"[^>]*>[\s\S]*?<\/g>/)?.[0];
|
||||
assert.ok(relationshipGroup);
|
||||
const duplicateCompanion = html.replace(relationshipGroup, `${relationshipGroup}${relationshipGroup}`);
|
||||
assert.throws(
|
||||
() => validateArchitectureDeltaHtml(duplicateCompanion, receipt),
|
||||
(error) => error instanceof ArchitectureDeltaError
|
||||
&& error.code === 'delta/artifact-invalid'
|
||||
&& error.details.failures.includes('ambiguous Delta target signature relationship:fraud-check'),
|
||||
);
|
||||
|
||||
const duplicateRowTag = duplicateCompanion.match(/<button class="change-row"[^>]*data-change-key="relationship:fraud-check"[^>]*>/)?.[0];
|
||||
const storedSignature = duplicateRowTag?.match(/data-change-target-signature="([^"]+)"/)?.[1];
|
||||
assert.ok(duplicateRowTag && storedSignature);
|
||||
const selfBlessedSignature = [...storedSignature.split('|'), 'g:added:topology'].sort().join('|');
|
||||
const selfBlessed = duplicateCompanion.replace(
|
||||
duplicateRowTag,
|
||||
duplicateRowTag.replace(`data-change-target-signature="${storedSignature}"`, `data-change-target-signature="${selfBlessedSignature}"`),
|
||||
);
|
||||
assert.throws(
|
||||
() => validateArchitectureDeltaHtml(selfBlessed, receipt),
|
||||
(error) => error instanceof ArchitectureDeltaError
|
||||
&& error.code === 'delta/artifact-invalid'
|
||||
&& error.details.failures.includes('ambiguous Delta target signature relationship:fraud-check'),
|
||||
);
|
||||
|
||||
const missingCompanionState = html.replace(
|
||||
relationshipGroup,
|
||||
relationshipGroup.replace(/\sdata-delta-state="[^"]+"/, ''),
|
||||
);
|
||||
assert.throws(
|
||||
() => validateArchitectureDeltaHtml(missingCompanionState, receipt),
|
||||
(error) => error instanceof ArchitectureDeltaError
|
||||
&& error.code === 'delta/artifact-invalid'
|
||||
&& error.details.failures.includes('missing Delta target state relationship:fraud-check'),
|
||||
);
|
||||
|
||||
const receiptNode = html.match(/<script id="archify-compare-receipt"[\s\S]*?<\/script>/)?.[0];
|
||||
assert.ok(receiptNode);
|
||||
const duplicateReceipt = html.replace(receiptNode, `${receiptNode}${receiptNode}`);
|
||||
assert.throws(
|
||||
() => validateArchitectureDeltaHtml(duplicateReceipt, receipt),
|
||||
(error) => error instanceof ArchitectureDeltaError
|
||||
&& error.code === 'delta/artifact-invalid'
|
||||
&& error.details.failures.includes('expected exactly one embedded compare receipt'),
|
||||
);
|
||||
|
||||
const extraDeltaSvg = html.replace(
|
||||
'<section class="canvas" data-view="delta">',
|
||||
'<section class="canvas" data-view="delta"><svg viewBox="0 0 1 1"></svg>',
|
||||
);
|
||||
assert.throws(
|
||||
() => validateArchitectureDeltaHtml(extraDeltaSvg, receipt),
|
||||
(error) => error instanceof ArchitectureDeltaError
|
||||
&& error.code === 'delta/artifact-invalid'
|
||||
&& error.details.failures.includes('expected exactly one root SVG in the Delta canvas'),
|
||||
);
|
||||
});
|
||||
|
||||
test('formatting-only input changes raw proof but not semantic hash or artifact bytes', () => {
|
||||
const reorderedPath = path.join(tmp, 'reordered-base.json');
|
||||
const reordered = read(baseFixture);
|
||||
reordered.components.reverse();
|
||||
reordered.connections.reverse();
|
||||
reordered.boundaries.forEach((boundary) => boundary.wraps.reverse());
|
||||
fs.writeFileSync(reorderedPath, JSON.stringify(reordered, null, 4));
|
||||
|
||||
const originalOut = path.join(tmp, 'canonical-original.html');
|
||||
const reorderedOut = path.join(tmp, 'canonical-reordered.html');
|
||||
const original = run(['compare', 'architecture', baseFixture, headFixture, originalOut, '--json']);
|
||||
const changed = run(['compare', 'architecture', reorderedPath, headFixture, reorderedOut, '--json']);
|
||||
assert.equal(original.status, 0, original.stderr);
|
||||
assert.equal(changed.status, 0, changed.stderr);
|
||||
const originalReceipt = JSON.parse(original.stdout);
|
||||
const changedReceipt = JSON.parse(changed.stdout);
|
||||
assert.notEqual(originalReceipt.base.rawSha256, changedReceipt.base.rawSha256);
|
||||
assert.equal(originalReceipt.base.semanticSha256, changedReceipt.base.semanticSha256);
|
||||
assert.equal(fs.readFileSync(originalOut, 'utf8'), fs.readFileSync(reorderedOut, 'utf8'));
|
||||
assert.equal(originalReceipt.artifact.sha256, changedReceipt.artifact.sha256);
|
||||
});
|
||||
|
||||
test('compare failure preserves an existing trusted artifact', () => {
|
||||
const invalid = read(headFixture);
|
||||
delete invalid.connections[0].id;
|
||||
const invalidPath = path.join(tmp, 'invalid-head.json');
|
||||
const output = path.join(tmp, 'preserved.html');
|
||||
fs.writeFileSync(invalidPath, JSON.stringify(invalid));
|
||||
fs.writeFileSync(output, 'trusted artifact');
|
||||
|
||||
const result = run(['compare', 'architecture', baseFixture, invalidPath, output, '--json']);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(fs.readFileSync(output, 'utf8'), 'trusted artifact');
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.ok, false);
|
||||
assert.equal(receipt.diagnostics[0].code, 'delta/relationship-id-required');
|
||||
assert.equal(fs.existsSync(path.join(tmp, 'preserved.receipt.json')), false);
|
||||
});
|
||||
|
||||
test('compare validates raw snapshots before canonicalization can discard invalid fields', () => {
|
||||
const invalid = read(baseFixture);
|
||||
invalid.unknown_top_level_fact = true;
|
||||
const invalidPath = path.join(tmp, 'invalid-raw-base.json');
|
||||
const output = path.join(tmp, 'invalid-raw-base.html');
|
||||
fs.writeFileSync(invalidPath, JSON.stringify(invalid));
|
||||
|
||||
const result = run(['compare', 'architecture', invalidPath, headFixture, output, '--json']);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(fs.existsSync(output), false);
|
||||
assert.equal(fs.existsSync(path.join(tmp, 'invalid-raw-base.receipt.json')), false);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.ok, false);
|
||||
assert.equal(receipt.diagnostics[0].code, 'schema/additionalProperties');
|
||||
assert.equal(receipt.diagnostics[0].subject.side, 'base');
|
||||
assert.equal(receipt.diagnostics[0].subject.path, '/');
|
||||
assert.equal(receipt.diagnostics[0].evidence.additionalProperty, 'unknown_top_level_fact');
|
||||
});
|
||||
|
||||
test('compare commit preflights both targets before replacing a trusted pair', () => {
|
||||
const caseRoot = fs.mkdtempSync(path.join(tmp, 'pair-target-'));
|
||||
const output = path.join(caseRoot, 'review.html');
|
||||
const receiptPath = path.join(caseRoot, 'review.receipt.json');
|
||||
fs.writeFileSync(output, 'trusted html');
|
||||
fs.mkdirSync(receiptPath);
|
||||
|
||||
const result = run([
|
||||
'compare', 'architecture', baseFixture, headFixture, output,
|
||||
'--receipt', receiptPath, '--json',
|
||||
]);
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(fs.readFileSync(output, 'utf8'), 'trusted html');
|
||||
assert.equal(fs.statSync(receiptPath).isDirectory(), true);
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.stage, 'commit');
|
||||
assert.equal(failure.diagnostics[0].code, 'delta/commit-target');
|
||||
assert.equal(failure.diagnostics[0].evidence.targetType, 'directory');
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets/template.html'), 'utf8');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-authored-reach-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
function reachabilityFunction() {
|
||||
const start = template.indexOf('function computeReachability(');
|
||||
const end = template.indexOf('\n function reachabilityFor(', start);
|
||||
assert.ok(start >= 0 && end > start, 'template exposes one extractable reachability function');
|
||||
return vm.runInNewContext(`(${template.slice(start, end)})`);
|
||||
}
|
||||
|
||||
test('authored reachability is available in every typed artifact without entering canonical SVG', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /id="focus-reach" hidden/);
|
||||
assert.match(html, /id="btn-reach-upstream"[^>]+aria-pressed="false"/);
|
||||
assert.match(html, /id="btn-reach-downstream"[^>]+aria-pressed="false"/);
|
||||
assert.match(html, /function computeReachability\(originId, direction, relationships\)/);
|
||||
assert.match(html, /svg\.setAttribute\('data-reach-active', direction\)/);
|
||||
assert.doesNotMatch(canonicalSvg(html), /data-reach-(?:active|match|origin|depth)/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('reachability uses stable breadth-first depth, supports cycles, and deduplicates edge fragments', () => {
|
||||
const compute = reachabilityFunction();
|
||||
const relationships = [
|
||||
{ key: 'a-b', from: 'a', to: 'b' },
|
||||
{ key: 'a-c', from: 'a', to: 'c' },
|
||||
{ key: 'b-d', from: 'b', to: 'd' },
|
||||
{ key: 'c-d', from: 'c', to: 'd' },
|
||||
{ key: 'd-b', from: 'd', to: 'b' },
|
||||
{ key: 'x-a', from: 'x', to: 'a' },
|
||||
{ key: 'a-b', from: 'a', to: 'b' },
|
||||
];
|
||||
|
||||
const downstream = compute('a', 'downstream', relationships);
|
||||
assert.deepEqual(Array.from(downstream.nodeIds), ['a', 'b', 'c', 'd']);
|
||||
assert.deepEqual({ ...downstream.depths }, { a: 0, b: 1, c: 1, d: 2 });
|
||||
assert.deepEqual(Array.from(downstream.edgeKeys), ['a-b', 'a-c', 'b-d', 'c-d', 'd-b']);
|
||||
assert.equal(downstream.maxDepth, 2);
|
||||
|
||||
const upstream = compute('d', 'upstream', relationships);
|
||||
assert.deepEqual(Array.from(upstream.nodeIds), ['d', 'b', 'c', 'a', 'x']);
|
||||
assert.deepEqual({ ...upstream.depths }, { d: 0, b: 1, c: 1, a: 2, x: 3 });
|
||||
assert.equal(upstream.maxDepth, 3);
|
||||
assert.equal(compute('a', 'sideways', relationships), null);
|
||||
});
|
||||
|
||||
test('reachability stays explicit, deep-linkable, keyboard reachable, and export-clean', () => {
|
||||
assert.match(template, /Authored Reachability is a bounded graph query over the relationships/);
|
||||
assert.match(template, /direction !== 'upstream' && direction !== 'downstream'/);
|
||||
assert.match(template, /encodeURIComponent\(activeIds\[0\]\) \+ '&reach=' \+ direction/);
|
||||
assert.match(template, /applyReachability\(reach, \{ updateUrl: false, toggle: false, reveal: false \}\)/);
|
||||
assert.match(template, /upstreamBtn\.addEventListener\('click'/);
|
||||
assert.match(template, /downstreamBtn\.addEventListener\('click'/);
|
||||
assert.match(template, /clone\.removeAttribute\('data-reach-active'\)/);
|
||||
assert.match(template, /clone\.querySelectorAll\('\[data-reach-match\], \[data-reach-origin\], \[data-reach-depth\]'/);
|
||||
assert.match(template, /!clone\.hasAttribute\('data-reach-active'\)/);
|
||||
assert.match(template, /svg\[data-preset="blueprint"\]\[data-reach-active\]/);
|
||||
assert.match(template, /\.diagram-container svg\[data-reach-active\] \[data-node-id\]/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const skill = fs.readFileSync(path.join(skillRoot, 'SKILL.md'), 'utf8');
|
||||
const authoringContract = fs.readFileSync(
|
||||
path.join(skillRoot, 'references', 'authoring-contract.md'),
|
||||
'utf8',
|
||||
);
|
||||
const schemaReadme = fs.readFileSync(path.join(skillRoot, 'schemas', 'README.md'), 'utf8');
|
||||
|
||||
test('semantic relationship labels are preserved and deletion is not a geometry repair', () => {
|
||||
for (const [name, source] of [['SKILL.md', skill], ['authoring contract', authoringContract]]) {
|
||||
assert.match(source, /Relationship labels are semantic data/i, name);
|
||||
assert.match(source, /move the label[\s\S]*adjust the route or spacing[\s\S]*shorten/i, name);
|
||||
assert.match(source, /protocol[\s\S]*action[\s\S]*direction[\s\S]*synchronous[\s\S]*asynchronous[\s\S]*cross-boundary mechanism/i, name);
|
||||
assert.match(source, /Omit only wording[\s\S]*fully implied by both endpoints/i, name);
|
||||
assert.match(source, /Preserve every meaningful label/i, name);
|
||||
assert.match(source, /deleting it is not\s+a (?:geometry|spacing) repair/i, name);
|
||||
}
|
||||
});
|
||||
|
||||
test('schema policy documents the workflow v1/v2 compatibility boundary', () => {
|
||||
assert.match(schemaReadme, /Workflow[^\n]*schema versions? 1 and 2/i);
|
||||
assert.match(schemaReadme, /other four[^\n]*schema_version[^\n]*1/i);
|
||||
assert.doesNotMatch(schemaReadme, /schema_version` is `"const": 1`/);
|
||||
});
|
||||
|
||||
test('deployment ownership stays explicit, fact-backed, and cannot be removed to pass', () => {
|
||||
assert.match(skill, /Omit `meta\.engineering_profile` by default/);
|
||||
assert.match(skill, /Region.*cluster.*security boundar.*do not.*enable/i);
|
||||
assert.match(skill, /production deployment topology.*ownership.*fail-closed deployment review/i);
|
||||
assert.match(skill, /must not remove.*engineering profile.*pass validation/i);
|
||||
});
|
||||
|
||||
test('visual-check stays a pending sidecar receipt instead of a polish claim', () => {
|
||||
const deliveryContract = fs.readFileSync(
|
||||
path.join(skillRoot, 'references', 'delivery-contract.md'),
|
||||
'utf8',
|
||||
);
|
||||
for (const [name, source] of [['SKILL.md', skill], ['delivery contract', deliveryContract]]) {
|
||||
assert.match(source, /visual-check <output\.html> --json/, name);
|
||||
assert.match(source, /1440×900[\s\S]*1600×1000[\s\S]*1920×1080[\s\S]*2048×1320/, name);
|
||||
assert.match(source, /visualReview: "pending"/, name);
|
||||
assert.match(source, /never changes.*delivered|without (?:rerendering or )?modifying/i, name);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function render(mode, doc) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-port-spread-'));
|
||||
const input = path.join(tmp, 'input.json');
|
||||
const output = path.join(tmp, 'output.html');
|
||||
fs.writeFileSync(input, JSON.stringify(doc));
|
||||
try {
|
||||
execFileSync('node', [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
input,
|
||||
output,
|
||||
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function connectionPoints(html, id) {
|
||||
const pattern = new RegExp(`data-edge-id="${id}"[^>]+data-composition-points="([^"]+)"`);
|
||||
const match = html.match(pattern);
|
||||
assert.ok(match, `missing rendered connection ${id}`);
|
||||
return match[1].split(';').map((point) => point.split(',').map(Number));
|
||||
}
|
||||
|
||||
function fanOutArchitecture(connections) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
diagram_type: 'architecture',
|
||||
meta: { title: 'Automatic port spread' },
|
||||
components: [
|
||||
{ id: 'hub', type: 'backend', label: 'Hub', pos: [100, 280], size: [120, 60] },
|
||||
{ id: 'upper', type: 'external', label: 'Upper', pos: [500, 100], size: [120, 60] },
|
||||
{ id: 'middle', type: 'database', label: 'Middle', pos: [500, 280], size: [120, 60] },
|
||||
{ id: 'lower', type: 'cloud', label: 'Lower', pos: [500, 460], size: [120, 60] },
|
||||
],
|
||||
connections,
|
||||
};
|
||||
}
|
||||
|
||||
test('architecture: automatic fan-out uses distinct symmetric ports with corner clearance', () => {
|
||||
const html = render('architecture', fanOutArchitecture([
|
||||
{ id: 'to-upper', from: 'hub', to: 'upper' },
|
||||
{ id: 'to-middle', from: 'hub', to: 'middle' },
|
||||
{ id: 'to-lower', from: 'hub', to: 'lower' },
|
||||
]));
|
||||
|
||||
assert.deepEqual(connectionPoints(html, 'to-upper')[0], [220, 296]);
|
||||
assert.deepEqual(connectionPoints(html, 'to-middle')[0], [220, 310]);
|
||||
assert.deepEqual(connectionPoints(html, 'to-lower')[0], [220, 324]);
|
||||
});
|
||||
|
||||
test('architecture: automatic port assignment is stable when relationship input order changes', () => {
|
||||
const connections = [
|
||||
{ id: 'to-upper', from: 'hub', to: 'upper' },
|
||||
{ id: 'to-middle', from: 'hub', to: 'middle' },
|
||||
{ id: 'to-lower', from: 'hub', to: 'lower' },
|
||||
];
|
||||
const forward = render('architecture', fanOutArchitecture(connections));
|
||||
const reversed = render('architecture', fanOutArchitecture([...connections].reverse()));
|
||||
|
||||
for (const connection of connections) {
|
||||
assert.deepEqual(
|
||||
connectionPoints(forward, connection.id),
|
||||
connectionPoints(reversed, connection.id),
|
||||
`${connection.id} moved after input reordering`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('architecture: a singly spread near-aligned vertical relationship keeps one direct axis', () => {
|
||||
const html = render('architecture', {
|
||||
schema_version: 1,
|
||||
diagram_type: 'architecture',
|
||||
meta: { title: 'Near-aligned fan-out' },
|
||||
components: [
|
||||
{ id: 'api', type: 'backend', label: 'API', pos: [675, 300], size: [120, 60] },
|
||||
{ id: 'auth', type: 'security', label: 'Auth', pos: [570, 120], size: [100, 60] },
|
||||
{ id: 'cache', type: 'database', label: 'Cache', pos: [685, 120], size: [100, 60] },
|
||||
],
|
||||
connections: [
|
||||
{ id: 'verify', from: 'api', to: 'auth', fromSide: 'top', toSide: 'bottom' },
|
||||
{ id: 'read', from: 'api', to: 'cache', fromSide: 'top', toSide: 'bottom' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(connectionPoints(html, 'read'), [[742, 300], [742, 180]]);
|
||||
assert.notDeepEqual(connectionPoints(html, 'verify')[0], connectionPoints(html, 'read')[0]);
|
||||
});
|
||||
|
||||
test('architecture: a singly spread near-aligned horizontal relationship keeps one direct axis', () => {
|
||||
const html = render('architecture', {
|
||||
schema_version: 1,
|
||||
diagram_type: 'architecture',
|
||||
meta: { title: 'Near-aligned horizontal fan-out' },
|
||||
components: [
|
||||
{ id: 'hub', type: 'backend', label: 'Hub', pos: [100, 100], size: [120, 60] },
|
||||
{ id: 'direct', type: 'database', label: 'Direct', pos: [500, 107], size: [120, 60] },
|
||||
{ id: 'branch', type: 'cloud', label: 'Branch', pos: [500, 300], size: [120, 60] },
|
||||
],
|
||||
connections: [
|
||||
{ id: 'hub-direct', from: 'hub', to: 'direct', fromSide: 'right', toSide: 'left' },
|
||||
{ id: 'hub-branch', from: 'hub', to: 'branch', fromSide: 'right', toSide: 'left' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(connectionPoints(html, 'hub-direct'), [[220, 123], [500, 123]]);
|
||||
assert.notDeepEqual(connectionPoints(html, 'hub-branch')[0], connectionPoints(html, 'hub-direct')[0]);
|
||||
});
|
||||
|
||||
test('architecture: a shared bottom port keeps its aligned child relationship straight', () => {
|
||||
const doc = {
|
||||
schema_version: 1,
|
||||
diagram_type: 'architecture',
|
||||
meta: { title: 'Vertical child relationship' },
|
||||
components: [
|
||||
{ id: 'parent', type: 'backend', label: 'Parent Session', pos: [300, 100], size: [200, 60] },
|
||||
{ id: 'terminal', type: 'backend', label: 'Background terminal', pos: [300, 300], size: [200, 60] },
|
||||
{ id: 'workflow', type: 'backend', label: 'Workflow', pos: [560, 300], size: [180, 60] },
|
||||
],
|
||||
connections: [
|
||||
{ id: 'parent-terminal', from: 'parent', to: 'terminal', fromSide: 'bottom', toSide: 'top' },
|
||||
{ id: 'parent-workflow', from: 'parent', to: 'workflow', fromSide: 'bottom', toSide: 'top' },
|
||||
],
|
||||
};
|
||||
const forward = render('architecture', doc);
|
||||
const reversed = render('architecture', {
|
||||
...doc,
|
||||
connections: [...doc.connections].reverse(),
|
||||
});
|
||||
|
||||
assert.deepEqual(connectionPoints(forward, 'parent-terminal'), [[393, 160], [393, 300]]);
|
||||
assert.notDeepEqual(
|
||||
connectionPoints(forward, 'parent-workflow')[0],
|
||||
connectionPoints(forward, 'parent-terminal')[0],
|
||||
);
|
||||
for (const connection of doc.connections) {
|
||||
assert.deepEqual(
|
||||
connectionPoints(forward, connection.id),
|
||||
connectionPoints(reversed, connection.id),
|
||||
`${connection.id} moved after input reordering`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('architecture: incoming and outgoing relationships keep distinct bottom ports while the direct child stays straight', () => {
|
||||
const html = render('architecture', {
|
||||
schema_version: 1,
|
||||
diagram_type: 'architecture',
|
||||
meta: { title: 'Shared incoming and outgoing side' },
|
||||
components: [
|
||||
{ id: 'workflow', type: 'backend', label: 'Workflow', pos: [80, 320], size: [180, 60] },
|
||||
{ id: 'child', type: 'security', label: 'Child boundary', pos: [300, 100], size: [200, 60] },
|
||||
{ id: 'footer', type: 'frontend', label: 'Footer', pos: [300, 320], size: [200, 60] },
|
||||
],
|
||||
connections: [
|
||||
{ id: 'workflow-child', from: 'workflow', to: 'child', fromSide: 'right', toSide: 'bottom' },
|
||||
{ id: 'child-footer', from: 'child', to: 'footer', fromSide: 'bottom', toSide: 'top' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(connectionPoints(html, 'child-footer'), [[407, 160], [407, 320]]);
|
||||
assert.notDeepEqual(
|
||||
connectionPoints(html, 'workflow-child').at(-1),
|
||||
connectionPoints(html, 'child-footer')[0],
|
||||
);
|
||||
});
|
||||
|
||||
test('architecture: a near-aligned relationship keeps the outside bridge when both endpoints are spread', () => {
|
||||
const html = render('architecture', {
|
||||
schema_version: 1,
|
||||
diagram_type: 'architecture',
|
||||
meta: { title: 'Two-sided port competition' },
|
||||
components: [
|
||||
{ id: 'source', type: 'backend', label: 'Source', pos: [300, 320], size: [160, 60] },
|
||||
{ id: 'source-peer', type: 'backend', label: 'Source peer', pos: [80, 320], size: [160, 60] },
|
||||
{ id: 'target', type: 'database', label: 'Target', pos: [300, 100], size: [160, 60] },
|
||||
{ id: 'target-peer', type: 'database', label: 'Target peer', pos: [560, 100], size: [160, 60] },
|
||||
],
|
||||
connections: [
|
||||
{ id: 'source-target', from: 'source', to: 'target', fromSide: 'top', toSide: 'bottom' },
|
||||
{ id: 'source-peer-target', from: 'source-peer', to: 'target', fromSide: 'top', toSide: 'bottom' },
|
||||
{ id: 'source-target-peer', from: 'source', to: 'target-peer', fromSide: 'top', toSide: 'bottom' },
|
||||
],
|
||||
});
|
||||
|
||||
const points = connectionPoints(html, 'source-target');
|
||||
assert.ok(points.length > 2);
|
||||
assert.notEqual(points[0][0], points.at(-1)[0]);
|
||||
});
|
||||
|
||||
test('architecture: a singly spread near-aligned relationship keeps the bridge when its direct axis is blocked', () => {
|
||||
const html = render('architecture', {
|
||||
schema_version: 1,
|
||||
diagram_type: 'architecture',
|
||||
meta: { title: 'Blocked vertical axis' },
|
||||
components: [
|
||||
{ id: 'parent', type: 'backend', label: 'Parent', pos: [300, 100], size: [200, 60] },
|
||||
{ id: 'terminal', type: 'backend', label: 'Terminal', pos: [300, 400], size: [200, 60] },
|
||||
{ id: 'workflow', type: 'backend', label: 'Workflow', pos: [560, 400], size: [180, 60] },
|
||||
{ id: 'obstacle', type: 'external', label: 'X', pos: [382, 245], size: [26, 60] },
|
||||
],
|
||||
connections: [
|
||||
{ id: 'parent-terminal', from: 'parent', to: 'terminal', fromSide: 'bottom', toSide: 'top' },
|
||||
{ id: 'parent-workflow', from: 'parent', to: 'workflow', fromSide: 'bottom', toSide: 'top' },
|
||||
],
|
||||
});
|
||||
|
||||
const points = connectionPoints(html, 'parent-terminal');
|
||||
assert.ok(points.length > 2);
|
||||
assert.notEqual(points[0][0], points.at(-1)[0]);
|
||||
});
|
||||
|
||||
test('architecture: single and explicitly positioned relationships keep legacy anchors', () => {
|
||||
const doc = fanOutArchitecture([
|
||||
{ id: 'single', from: 'hub', to: 'middle' },
|
||||
{ id: 'via', from: 'hub', to: 'upper', via: [[300, 310], [300, 130]] },
|
||||
{ id: 'fixed-route', from: 'hub', to: 'lower', route: 'orthogonal-h' },
|
||||
{ id: 'fixed-label', from: 'hub', to: 'upper', label: 'contract', labelAt: [360, 200] },
|
||||
]);
|
||||
const html = render('architecture', doc);
|
||||
|
||||
assert.deepEqual(connectionPoints(html, 'single'), [[220, 310], [500, 310]]);
|
||||
assert.deepEqual(connectionPoints(html, 'via'), [[220, 310], [300, 310], [300, 130], [500, 130]]);
|
||||
assert.deepEqual(connectionPoints(html, 'fixed-route'), [[220, 310], [360, 310], [360, 490], [500, 490]]);
|
||||
assert.deepEqual(connectionPoints(html, 'fixed-label'), [[220, 310], [360, 310], [360, 130], [500, 130]]);
|
||||
});
|
||||
|
||||
test('architecture: an unspread near-aligned connection shares one horizontal axis', () => {
|
||||
const html = render('architecture', {
|
||||
schema_version: 1,
|
||||
diagram_type: 'architecture',
|
||||
meta: { title: 'Near-aligned single connection' },
|
||||
components: [
|
||||
{ id: 'console', type: 'frontend', label: 'Console', pos: [260, 300], size: [170, 64] },
|
||||
{ id: 'controlplane', type: 'backend', label: 'Control plane', pos: [500, 300], size: [190, 72] },
|
||||
],
|
||||
connections: [
|
||||
{ id: 'console-controlplane', from: 'console', to: 'controlplane', label: 'REST /api', variant: 'emphasis', labelDy: -36 },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(connectionPoints(html, 'console-controlplane'), [[430, 332], [500, 332]]);
|
||||
});
|
||||
|
||||
test('workflow: automatic cross-lane fan-out selects distinct perpendicular source sides', () => {
|
||||
const html = render('workflow', {
|
||||
schema_version: 1,
|
||||
diagram_type: 'workflow',
|
||||
meta: { title: 'Workflow port spread' },
|
||||
lanes: [
|
||||
{ id: 'upper-lane', label: 'Upper' },
|
||||
{ id: 'hub-lane', label: 'Hub' },
|
||||
{ id: 'lower-lane', label: 'Lower' },
|
||||
],
|
||||
nodes: [
|
||||
{ id: 'hub', lane: 'hub-lane', col: 0, type: 'backend', label: 'Hub' },
|
||||
{ id: 'upper', lane: 'upper-lane', col: 3, type: 'external', label: 'Upper' },
|
||||
{ id: 'middle', lane: 'hub-lane', col: 3, type: 'database', label: 'Middle' },
|
||||
{ id: 'lower', lane: 'lower-lane', col: 3, type: 'cloud', label: 'Lower' },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'to-upper', from: 'hub', to: 'upper' },
|
||||
{ id: 'to-middle', from: 'hub', to: 'middle' },
|
||||
{ id: 'to-lower', from: 'hub', to: 'lower' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(connectionPoints(html, 'to-upper')[0], [88, 217]);
|
||||
assert.deepEqual(connectionPoints(html, 'to-middle')[0], [134, 243]);
|
||||
assert.deepEqual(connectionPoints(html, 'to-lower')[0], [88, 269]);
|
||||
});
|
||||
|
||||
test('dataflow: automatic fan-out spreads flows without changing their authored topology', () => {
|
||||
const html = render('dataflow', {
|
||||
schema_version: 1,
|
||||
diagram_type: 'dataflow',
|
||||
meta: { title: 'Data-flow port spread' },
|
||||
stages: [{ label: 'Source' }, { label: 'Transform' }, { label: 'Sinks' }],
|
||||
nodes: [
|
||||
{ id: 'hub', type: 'backend', label: 'Hub', stage: 0, row: 2 },
|
||||
{ id: 'upper', type: 'external', label: 'Upper', stage: 2, row: 0 },
|
||||
{ id: 'middle', type: 'database', label: 'Middle', stage: 2, row: 2 },
|
||||
{ id: 'lower', type: 'cloud', label: 'Lower', stage: 2, row: 4 },
|
||||
],
|
||||
flows: [
|
||||
{ id: 'to-upper', from: 'hub', to: 'upper', label: 'upper feed' },
|
||||
{ id: 'to-middle', from: 'hub', to: 'middle', label: 'middle feed' },
|
||||
{ id: 'to-lower', from: 'hub', to: 'lower', label: 'lower feed' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(connectionPoints(html, 'to-upper')[0], [156, 372]);
|
||||
assert.deepEqual(connectionPoints(html, 'to-middle')[0], [156, 385]);
|
||||
assert.deepEqual(connectionPoints(html, 'to-lower')[0], [156, 398]);
|
||||
});
|
||||
|
||||
test('lifecycle: automatic fan-out spreads transitions across lifecycle bands', () => {
|
||||
const html = render('lifecycle', {
|
||||
schema_version: 1,
|
||||
diagram_type: 'lifecycle',
|
||||
meta: { title: 'Lifecycle port spread' },
|
||||
lanes: [
|
||||
{ id: 'main', label: 'Main' },
|
||||
{ id: 'event', label: 'Events' },
|
||||
{ id: 'terminal', label: 'Outcomes' },
|
||||
],
|
||||
states: [
|
||||
{ id: 'hub', type: 'active', label: 'Hub', lane: 'event', col: 0 },
|
||||
{ id: 'upper', type: 'waiting', label: 'Upper', lane: 'main', col: 4 },
|
||||
{ id: 'middle', type: 'success', label: 'Middle', lane: 'event', col: 2 },
|
||||
{ id: 'lower', type: 'failure', label: 'Lower', lane: 'terminal', col: 2 },
|
||||
],
|
||||
transitions: [
|
||||
{ id: 'to-upper', from: 'hub', to: 'upper' },
|
||||
{ id: 'to-middle', from: 'hub', to: 'middle' },
|
||||
{ id: 'to-lower', from: 'hub', to: 'lower' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(connectionPoints(html, 'to-upper')[0], [465, 294]);
|
||||
assert.deepEqual(connectionPoints(html, 'to-middle')[0], [465, 307]);
|
||||
assert.deepEqual(connectionPoints(html, 'to-lower')[0], [465, 320]);
|
||||
});
|
||||
|
||||
test('lifecycle: same-band port spread remains orthogonal', () => {
|
||||
const html = render('lifecycle', {
|
||||
schema_version: 1,
|
||||
diagram_type: 'lifecycle',
|
||||
meta: { title: 'Orthogonal same-band spread' },
|
||||
lanes: [{ id: 'main', label: 'Main' }],
|
||||
states: [
|
||||
{ id: 'hub', type: 'active', label: 'Hub', lane: 'main', col: 0 },
|
||||
{ id: 'upper', type: 'waiting', label: 'Upper', lane: 'main', col: 2, yOffset: -50 },
|
||||
{ id: 'lower', type: 'success', label: 'Lower', lane: 'main', col: 4, yOffset: 50 },
|
||||
],
|
||||
transitions: [
|
||||
{ id: 'to-upper', from: 'hub', to: 'upper' },
|
||||
{ id: 'to-lower', from: 'hub', to: 'lower' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(connectionPoints(html, 'to-upper'), [
|
||||
[153, 150], [248, 150], [248, 107], [343, 107],
|
||||
]);
|
||||
assert.deepEqual(connectionPoints(html, 'to-lower'), [
|
||||
[153, 164], [402, 164], [402, 207], [651, 207],
|
||||
]);
|
||||
});
|
||||
|
||||
test('skill and READMEs describe automatic port spread as bounded default behavior', () => {
|
||||
const skill = fs.readFileSync(path.join(skillRoot, 'SKILL.md'), 'utf8');
|
||||
assert.match(skill, /Automatic Port Spread is a default renderer behavior/);
|
||||
assert.match(skill, /single relationship|single relationships/);
|
||||
assert.match(skill, /explicit `via`.*`channelX`.*`channelY`.*`labelAt`/);
|
||||
assert.match(skill, /facing automatic ports \(`left`\/`right` or `top`\/`bottom`\).*one shared axis/);
|
||||
|
||||
const authoringContract = fs.readFileSync(path.join(skillRoot, 'references/authoring-contract.md'), 'utf8');
|
||||
assert.match(authoringContract, /unobstructed facing ports.*may share one horizontal or vertical axis/);
|
||||
|
||||
const repoRoot = path.resolve(skillRoot, '..');
|
||||
for (const file of ['README.md', 'README_EN.md']) {
|
||||
assert.match(fs.readFileSync(path.join(repoRoot, file), 'utf8'), /shared automatic endpoints spread deterministically/);
|
||||
}
|
||||
assert.match(fs.readFileSync(path.join(repoRoot, 'README_ZH.md'), 'utf8'), /共享的自动端点会确定性展开/);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(here, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-base-input-compatibility-'));
|
||||
|
||||
function renderBaseFixture(type, name) {
|
||||
const input = path.join(skillRoot, 'test', 'fixtures', 'v1-baseline', name);
|
||||
const output = path.join(tmp, `${name}.html`);
|
||||
return spawnSync(process.execPath, [
|
||||
path.join(skillRoot, 'bin', 'archify.mjs'),
|
||||
'render',
|
||||
type,
|
||||
input,
|
||||
output,
|
||||
], {
|
||||
cwd: skillRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
test('base named-route fixtures remain valid without redundant authored endpoint sides', () => {
|
||||
for (const [type, name] of [
|
||||
['dataflow', 'event-stream.dataflow.json'],
|
||||
['architecture', 'production-deployment.architecture.json'],
|
||||
]) {
|
||||
const result = renderBaseFixture(type, name);
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`${type} base input must remain valid:\n${result.stdout || result.stderr}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,654 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { BRAND_MARKS } from '../renderers/shared/generated-brand-marks.mjs';
|
||||
import { isPrivateBrandAddress, prepareDiagramBrandMarks } from '../renderers/shared/brand-marks.mjs';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(here, '..');
|
||||
const cli = path.join(skillRoot, 'bin', 'archify.mjs');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-brand-marks-'));
|
||||
const cases = {
|
||||
architecture: ['web-app.architecture.json', 'components'],
|
||||
workflow: ['agent-tool-call.workflow.json', 'nodes'],
|
||||
sequence: ['cache-miss-request.sequence.json', 'participants'],
|
||||
dataflow: ['product-analytics.dataflow.json', 'nodes'],
|
||||
lifecycle: ['agent-run.lifecycle.json', 'states'],
|
||||
};
|
||||
|
||||
function writeFixture(type, name, brand, customize) {
|
||||
const [example, collection] = cases[type];
|
||||
const value = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', example), 'utf8'));
|
||||
value[collection][0].brand = brand;
|
||||
customize?.(value, value[collection][0]);
|
||||
const file = path.join(tmp, `${name}.${type}.json`);
|
||||
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
||||
return file;
|
||||
}
|
||||
|
||||
function renderSync(type, input, name, env = {}) {
|
||||
const output = path.join(tmp, `${name}.html`);
|
||||
const result = spawnSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${type}/render-${type}.mjs`),
|
||||
input,
|
||||
output,
|
||||
], {
|
||||
cwd: skillRoot,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
return { result, output, html: fs.existsSync(output) ? fs.readFileSync(output, 'utf8') : '' };
|
||||
}
|
||||
|
||||
function renderAsync(type, input, name, env = {}) {
|
||||
const output = path.join(tmp, `${name}.html`);
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${type}/render-${type}.mjs`),
|
||||
input,
|
||||
output,
|
||||
], {
|
||||
cwd: skillRoot,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.on('close', (status) => resolve({
|
||||
status,
|
||||
stdout,
|
||||
stderr,
|
||||
output,
|
||||
html: fs.existsSync(output) ? fs.readFileSync(output, 'utf8') : '',
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function runCliAsync(args, env = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [cli, ...args], {
|
||||
cwd: skillRoot,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.on('close', (status) => resolve({ status, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
function nodeBlock(html, id) {
|
||||
const startToken = `<g id="node-${id}"`;
|
||||
const start = html.indexOf(startToken);
|
||||
if (start === -1) return '';
|
||||
const candidates = [
|
||||
html.indexOf('\n <g id="node-', start + startToken.length),
|
||||
html.indexOf('\n <!-- Connection labels', start + startToken.length),
|
||||
html.indexOf('\n <!-- Transition labels', start + startToken.length),
|
||||
html.indexOf('\n <!-- Message labels', start + startToken.length),
|
||||
].filter((value) => value !== -1);
|
||||
return html.slice(start, candidates.length ? Math.min(...candidates) : html.length);
|
||||
}
|
||||
|
||||
test('generated catalog exposes a substantial, unique, provenance-backed preset library', () => {
|
||||
assert.equal(BRAND_MARKS.length, 107);
|
||||
assert.equal(new Set(BRAND_MARKS.map((mark) => mark.id)).size, BRAND_MARKS.length);
|
||||
for (const mark of BRAND_MARKS) {
|
||||
assert.match(mark.id, /^[a-z0-9]+(?:-[a-z0-9]+)*$/);
|
||||
assert.ok(mark.title);
|
||||
assert.ok(mark.category);
|
||||
assert.match(mark.hex, /^[0-9A-F]{6}$/i);
|
||||
assert.match(mark.path, /^[Mm]/);
|
||||
assert.ok(mark.provenance?.source);
|
||||
}
|
||||
});
|
||||
|
||||
test('brand discovery resolves model names, aliases, domains, and Chinese channel aliases', () => {
|
||||
for (const [query, expected] of [
|
||||
['GPT', 'openai'],
|
||||
['Gemini', 'google-gemini'],
|
||||
['github.com', 'github'],
|
||||
['微信', 'wechat'],
|
||||
]) {
|
||||
const result = spawnSync(process.execPath, [cli, 'brands', query, '--json'], {
|
||||
cwd: skillRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.ok, true);
|
||||
assert.ok(receipt.marks.some((mark) => mark.id === expected), query);
|
||||
}
|
||||
});
|
||||
|
||||
test('all five renderers keep the semantic sigil and add one export-safe brand badge', () => {
|
||||
for (const type of Object.keys(cases)) {
|
||||
const input = writeFixture(type, `preset-${type}`, 'openai', (_diagram, node) => {
|
||||
if (type === 'lifecycle') node.step = node.step || '01';
|
||||
});
|
||||
const { result, html } = renderSync(type, input, `preset-${type}`);
|
||||
assert.equal(result.status, 0, `${type}: ${result.stderr || result.stdout}`);
|
||||
assert.match(html, /data-node-brand="OpenAI"/i, type);
|
||||
assert.match(html, /data-brand-mark="openai"[^>]+data-brand-status="preset"/i, type);
|
||||
assert.match(html, /class="semantic-sigil /, type);
|
||||
assert.match(html, /<title>[^<]*OpenAI<\/title>/i, type);
|
||||
|
||||
const [, collection] = cases[type];
|
||||
const diagram = JSON.parse(fs.readFileSync(input, 'utf8'));
|
||||
const block = nodeBlock(html, diagram[collection][0].id);
|
||||
const frame = block.match(/<rect x="([-\d.]+)" y="([-\d.]+)" width="([-\d.]+)" height="([-\d.]+)" rx="[^"]+" class="c-mask"\/>/);
|
||||
const semantic = block.match(/data-semantic-sigil[^>]+translate\(([-\d.]+) ([-\d.]+)\)/);
|
||||
const brand = block.match(/data-brand-mark="openai"[^>]+translate\(([-\d.]+) ([-\d.]+)\)">\s*<rect width="([-\d.]+)" height="([-\d.]+)" rx="([-\d.]+)" class="brand-mark-badge"\/>/);
|
||||
assert.ok(frame && semantic && brand, `${type}: expected node frame, semantic sigil, and brand badge`);
|
||||
|
||||
const [frameX, frameY, frameWidth] = frame.slice(1, 4).map(Number);
|
||||
const [, semanticY] = semantic.slice(1, 3).map(Number);
|
||||
const [brandX, brandY, brandWidth, brandHeight, brandRadius] = brand.slice(1, 6).map(Number);
|
||||
assert.equal(brandWidth, 16, `${type}: brand badge width`);
|
||||
assert.equal(brandHeight, 16, `${type}: brand badge height`);
|
||||
assert.equal(brandRadius, 4, `${type}: brand badge radius`);
|
||||
assert.equal(brandY - frameY, 6, `${type}: brand badge top inset`);
|
||||
assert.equal(frameX + frameWidth - (brandX + brandWidth), 6, `${type}: brand badge right inset`);
|
||||
assert.equal(brandY, semanticY, `${type}: brand and semantic marks share a top rail`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a branded node fails before its semantic sigil, label, and brand badge can overlap', () => {
|
||||
const input = writeFixture('workflow', 'narrow-brand-rail', 'openai', (_diagram, node) => {
|
||||
node.label = 'A';
|
||||
delete node.sublabel;
|
||||
node.width = 32;
|
||||
});
|
||||
const { result, html } = renderSync('workflow', input, 'narrow-brand-rail');
|
||||
|
||||
assert.equal(result.status, 1, result.stderr || result.stdout);
|
||||
assert.match(result.stderr, /brand top rail/i);
|
||||
assert.equal(html, '');
|
||||
});
|
||||
|
||||
test('every renderer enforces the same collision-free brand top rail', () => {
|
||||
for (const type of ['architecture', 'sequence', 'dataflow', 'lifecycle']) {
|
||||
const input = writeFixture(type, `narrow-brand-rail-${type}`, 'openai', (_diagram, node) => {
|
||||
node.label = type === 'sequence' ? 'ABCDEFGHI' : 'A';
|
||||
delete node.sublabel;
|
||||
delete node.tag;
|
||||
if (type === 'architecture') node.size = [32, 60];
|
||||
if (type === 'dataflow' || type === 'lifecycle') node.width = 48;
|
||||
});
|
||||
const { result, html } = renderSync(type, input, `narrow-brand-rail-${type}`);
|
||||
assert.equal(result.status, 1, `${type}: ${result.stderr || result.stdout}`);
|
||||
assert.match(result.stderr, /brand top rail/i, type);
|
||||
assert.equal(html, '', type);
|
||||
}
|
||||
});
|
||||
|
||||
test('branded lifecycle states move the semantic stamp left and keep the brand at upper right', () => {
|
||||
const input = writeFixture('lifecycle', 'lifecycle-placement', 'openai', (_diagram, node) => {
|
||||
node.step = '01';
|
||||
});
|
||||
const { result, html } = renderSync('lifecycle', input, 'lifecycle-placement');
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const id = JSON.parse(fs.readFileSync(input, 'utf8')).states[0].id;
|
||||
const block = nodeBlock(html, id);
|
||||
const semanticX = Number(block.match(/data-semantic-sigil[^>]+translate\(([-\d.]+)/)?.[1]);
|
||||
const brandX = Number(block.match(/data-brand-mark[^>]+translate\(([-\d.]+)/)?.[1]);
|
||||
assert.ok(Number.isFinite(semanticX) && Number.isFinite(brandX) && semanticX < brandX, block);
|
||||
assert.match(block, /data-detail="fine"[^>]+>01<\/text>/);
|
||||
});
|
||||
|
||||
test('known-brand URLs use the bundled vector instead of the network', () => {
|
||||
const input = writeFixture('architecture', 'known-domain', 'https://github.com/tt-a1i/archify');
|
||||
const { result, html } = renderSync('architecture', input, 'known-domain');
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.match(html, /data-brand-mark="github"[^>]+data-brand-status="preset"/);
|
||||
assert.doesNotMatch(html, /data-brand-status="captured"/);
|
||||
});
|
||||
|
||||
test('unknown URL strings fail closed until an exact captured digest is authored', () => {
|
||||
const input = writeFixture('architecture', 'unpinned-link', 'https://brand.example.invalid/');
|
||||
const result = spawnSync(process.execPath, [cli, 'validate', 'architecture', input, '--json'], {
|
||||
cwd: skillRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 1, result.stderr || result.stdout);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.ok, false);
|
||||
assert.ok(receipt.diagnostics.some((entry) => entry.code === 'brand/unpinned-url'));
|
||||
assert.ok(receipt.diagnostics.some((entry) => entry.supportedFixes.some((fix) => fix.includes('brands capture'))));
|
||||
});
|
||||
|
||||
test('capture command returns a digest-pinned brand object that renders reproducibly', async () => {
|
||||
let pageHits = 0;
|
||||
let iconHits = 0;
|
||||
const icon = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.url === '/mark.png') {
|
||||
iconHits += 1;
|
||||
response.writeHead(200, { 'content-type': 'image/png' });
|
||||
response.end(icon);
|
||||
return;
|
||||
}
|
||||
pageHits += 1;
|
||||
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
response.end('<!doctype html><title>Example Studio</title><link rel="icon" type="image/png" href="/mark.png"><h1>Example Studio</h1>');
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const url = `http://127.0.0.1:${address.port}/studio`;
|
||||
const capture = await runCliAsync(['brands', 'capture', url, '--json'], { ARCHIFY_BRAND_ALLOW_PRIVATE: '1' });
|
||||
assert.equal(capture.status, 0, capture.stderr || capture.stdout);
|
||||
const receipt = JSON.parse(capture.stdout);
|
||||
assert.equal(receipt.ok, true);
|
||||
assert.deepEqual(receipt.brand, {
|
||||
url,
|
||||
sha256: createHash('sha256').update(icon).digest('hex'),
|
||||
});
|
||||
|
||||
const input = writeFixture('architecture', 'captured-link', receipt.brand);
|
||||
const rendered = await renderAsync('architecture', input, 'captured-link', { ARCHIFY_BRAND_ALLOW_PRIVATE: '1' });
|
||||
assert.equal(rendered.status, 0, rendered.stderr || rendered.stdout);
|
||||
assert.equal(pageHits, 2);
|
||||
assert.equal(iconHits, 2);
|
||||
assert.match(rendered.html, /data-brand-status="captured"/);
|
||||
assert.match(rendered.html, /data:image\/png;base64,/);
|
||||
assert.match(rendered.html, new RegExp(`data-brand-sha256="${receipt.brand.sha256}"`));
|
||||
assert.ok(!rendered.html.includes('http://127.0.0.1') || rendered.html.includes('data-node-brand-source='));
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('a pinned brand fails closed when the remote icon digest changes', async () => {
|
||||
const firstIcon = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
|
||||
const changedIcon = Buffer.from(firstIcon);
|
||||
changedIcon[45] ^= 1;
|
||||
let iconHits = 0;
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.url === '/mark.png') {
|
||||
iconHits += 1;
|
||||
response.writeHead(200, { 'content-type': 'image/png' });
|
||||
response.end(iconHits === 1 ? firstIcon : changedIcon);
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
response.end('<!doctype html><title>Changing site</title><link rel="icon" type="image/png" href="/mark.png">');
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const url = `http://127.0.0.1:${address.port}/`;
|
||||
const capture = await runCliAsync(['brands', 'capture', url, '--json'], { ARCHIFY_BRAND_ALLOW_PRIVATE: '1' });
|
||||
assert.equal(capture.status, 0, capture.stderr || capture.stdout);
|
||||
const brand = JSON.parse(capture.stdout).brand;
|
||||
const input = writeFixture('architecture', 'changed-digest', brand);
|
||||
const result = await runCliAsync(['validate', 'architecture', input, '--json'], { ARCHIFY_BRAND_ALLOW_PRIVATE: '1' });
|
||||
assert.equal(result.status, 1, result.stderr || result.stdout);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.diagnostics.filter((entry) => entry.code === 'brand/digest-mismatch').length, 1);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('a pinned brand keeps identical artifact metadata when the remote page title changes', async () => {
|
||||
const icon = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
|
||||
let pageHits = 0;
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.url === '/mark.png') {
|
||||
response.writeHead(200, { 'content-type': 'image/png' });
|
||||
response.end(icon);
|
||||
return;
|
||||
}
|
||||
pageHits += 1;
|
||||
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
response.end(`<!doctype html><title>Title ${pageHits}</title><link rel="icon" type="image/png" href="/mark.png">`);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const url = `http://127.0.0.1:${address.port}/`;
|
||||
const capture = await runCliAsync(['brands', 'capture', url, '--json'], { ARCHIFY_BRAND_ALLOW_PRIVATE: '1' });
|
||||
assert.equal(capture.status, 0, capture.stderr || capture.stdout);
|
||||
const input = writeFixture('architecture', 'stable-title', JSON.parse(capture.stdout).brand);
|
||||
const first = await renderAsync('architecture', input, 'stable-title-first', { ARCHIFY_BRAND_ALLOW_PRIVATE: '1' });
|
||||
const second = await renderAsync('architecture', input, 'stable-title-second', { ARCHIFY_BRAND_ALLOW_PRIVATE: '1' });
|
||||
assert.equal(first.status, 0, first.stderr || first.stdout);
|
||||
assert.equal(second.status, 0, second.stderr || second.stdout);
|
||||
assert.equal(first.html, second.html);
|
||||
assert.match(first.html, /data-brand-title="127\.0\.0\.1"/);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('each prepare call rechecks pinned remote bytes instead of trusting a process-wide cache', async () => {
|
||||
const firstIcon = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
|
||||
const changedIcon = Buffer.from(firstIcon);
|
||||
changedIcon[45] ^= 1;
|
||||
let iconHits = 0;
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.url === '/mark.png') {
|
||||
iconHits += 1;
|
||||
response.writeHead(200, { 'content-type': 'image/png' });
|
||||
response.end(iconHits === 1 ? firstIcon : changedIcon);
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
response.end('<!doctype html><title>Changing site</title><link rel="icon" type="image/png" href="/mark.png">');
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const priorAllowPrivate = process.env.ARCHIFY_BRAND_ALLOW_PRIVATE;
|
||||
process.env.ARCHIFY_BRAND_ALLOW_PRIVATE = '1';
|
||||
try {
|
||||
const address = server.address();
|
||||
const diagram = {
|
||||
components: [{
|
||||
id: 'remote',
|
||||
label: 'Remote',
|
||||
brand: {
|
||||
url: `http://127.0.0.1:${address.port}/`,
|
||||
sha256: createHash('sha256').update(firstIcon).digest('hex'),
|
||||
},
|
||||
}],
|
||||
};
|
||||
await prepareDiagramBrandMarks('architecture', diagram);
|
||||
await assert.rejects(
|
||||
prepareDiagramBrandMarks('architecture', diagram),
|
||||
/brand digest changed/i,
|
||||
);
|
||||
assert.equal(iconHits, 2);
|
||||
} finally {
|
||||
if (priorAllowPrivate === undefined) delete process.env.ARCHIFY_BRAND_ALLOW_PRIVATE;
|
||||
else process.env.ARCHIFY_BRAND_ALLOW_PRIVATE = priorAllowPrivate;
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('capture blocks IPv4-mapped IPv6 loopback and metadata destinations before connecting', async () => {
|
||||
for (const url of [
|
||||
'http://[::ffff:127.0.0.1]/',
|
||||
'http://[::ffff:169.254.169.254]/',
|
||||
'http://[::192.168.1.1]/',
|
||||
'http://[64:ff9b::c0a8:101]/',
|
||||
'http://[2002:c0a8:0101::]/',
|
||||
'http://[ff02::1]/',
|
||||
'http://192.0.2.1/',
|
||||
'http://198.51.100.1/',
|
||||
'http://203.0.113.1/',
|
||||
]) {
|
||||
const capture = await runCliAsync(['brands', 'capture', url, '--json']);
|
||||
assert.notEqual(capture.status, 0, `${url}: ${capture.stderr || capture.stdout}`);
|
||||
assert.match(capture.stderr, /private brand links are not fetched/i, url);
|
||||
}
|
||||
});
|
||||
|
||||
test('address classification blocks exact reserved ranges without rejecting adjacent public IPv4 space', () => {
|
||||
for (const address of ['192.0.2.1', '192.88.99.1', '198.51.100.1', '203.0.113.1']) {
|
||||
assert.equal(isPrivateBrandAddress(address), true, address);
|
||||
}
|
||||
for (const address of ['192.2.1.1', '192.88.98.1', '198.51.99.1', '203.0.112.1']) {
|
||||
assert.equal(isPrivateBrandAddress(address), false, address);
|
||||
}
|
||||
});
|
||||
|
||||
test('capture requires the standard port for the selected web protocol', async () => {
|
||||
for (const url of [
|
||||
'http://brand.example.invalid:443/',
|
||||
'https://brand.example.invalid:80/',
|
||||
'https://github.com:80/',
|
||||
]) {
|
||||
const capture = await runCliAsync(['brands', 'capture', url, '--json']);
|
||||
assert.notEqual(capture.status, 0, capture.stdout);
|
||||
assert.match(capture.stderr, /standard web port/i, url);
|
||||
}
|
||||
});
|
||||
|
||||
test('capture rejects credentials even when the URL domain matches a bundled preset', async () => {
|
||||
const capture = await runCliAsync(['brands', 'capture', 'https://user:secret@github.com/', '--json']);
|
||||
assert.notEqual(capture.status, 0, capture.stdout);
|
||||
assert.match(capture.stderr, /cannot contain credentials/i);
|
||||
});
|
||||
|
||||
test('rendering many pinned brands limits concurrent remote capture work', async () => {
|
||||
const icon = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
|
||||
const sha256 = createHash('sha256').update(icon).digest('hex');
|
||||
let active = 0;
|
||||
let maximumActive = 0;
|
||||
const server = http.createServer((request, response) => {
|
||||
active += 1;
|
||||
maximumActive = Math.max(maximumActive, active);
|
||||
setTimeout(() => {
|
||||
if (request.url.endsWith('.png')) {
|
||||
response.writeHead(200, { 'content-type': 'image/png' });
|
||||
active -= 1;
|
||||
response.end(icon);
|
||||
} else {
|
||||
const suffix = request.url.replace(/^\/site-/, '');
|
||||
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
active -= 1;
|
||||
response.end(`<!doctype html><title>Site ${suffix}</title><link rel="icon" type="image/png" href="/mark-${suffix}.png">`);
|
||||
}
|
||||
}, 40);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const input = writeFixture('architecture', 'bounded-capture', 'openai', (diagram) => {
|
||||
diagram.components.forEach((node, index) => {
|
||||
node.brand = {
|
||||
url: `http://127.0.0.1:${address.port}/site-${index}`,
|
||||
sha256,
|
||||
};
|
||||
});
|
||||
});
|
||||
const rendered = await renderAsync('architecture', input, 'bounded-capture', { ARCHIFY_BRAND_ALLOW_PRIVATE: '1' });
|
||||
assert.equal(rendered.status, 0, rendered.stderr || rendered.stdout);
|
||||
assert.ok(maximumActive <= 3, `expected at most 3 concurrent requests, observed ${maximumActive}`);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('rendering many pinned brands shares one diagram capture deadline', async () => {
|
||||
const icon = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
|
||||
const sha256 = createHash('sha256').update(icon).digest('hex');
|
||||
const server = http.createServer((_request, response) => {
|
||||
setTimeout(() => {
|
||||
response.writeHead(200, { 'content-type': 'image/png' });
|
||||
response.end(icon);
|
||||
}, 80);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const input = writeFixture('architecture', 'diagram-deadline', 'openai', (diagram) => {
|
||||
diagram.components.forEach((node, index) => {
|
||||
node.brand = {
|
||||
url: `http://127.0.0.1:${address.port}/mark-${index}.png`,
|
||||
sha256,
|
||||
};
|
||||
});
|
||||
});
|
||||
const rendered = await renderAsync('architecture', input, 'diagram-deadline', {
|
||||
ARCHIFY_BRAND_ALLOW_PRIVATE: '1',
|
||||
ARCHIFY_BRAND_CAPTURE_TIMEOUT_MS: '100',
|
||||
});
|
||||
assert.notEqual(rendered.status, 0, rendered.stdout);
|
||||
assert.match(rendered.stderr, /abort|timed? ?out|timeout/i);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('capture applies one total deadline across the page and icon requests', async () => {
|
||||
const icon = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
|
||||
const server = http.createServer((request, response) => {
|
||||
setTimeout(() => {
|
||||
if (request.url === '/mark.png') {
|
||||
response.writeHead(200, { 'content-type': 'image/png' });
|
||||
response.end(icon);
|
||||
} else {
|
||||
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
response.end('<!doctype html><title>Slow site</title><link rel="icon" type="image/png" href="/mark.png">');
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const capture = await runCliAsync(
|
||||
['brands', 'capture', `http://127.0.0.1:${address.port}/`, '--json'],
|
||||
{
|
||||
ARCHIFY_BRAND_ALLOW_PRIVATE: '1',
|
||||
ARCHIFY_BRAND_CAPTURE_TIMEOUT_MS: '150',
|
||||
},
|
||||
);
|
||||
assert.notEqual(capture.status, 0, capture.stdout);
|
||||
assert.match(capture.stderr, /abort|timed? ?out|timeout/i);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('capture rejects remote SVG even when the document appears passive', async () => {
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.url === '/mark.svg') {
|
||||
response.writeHead(200, { 'content-type': 'image/svg+xml' });
|
||||
response.end('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="24" height="24"/></svg>');
|
||||
return;
|
||||
}
|
||||
if (request.url === '/favicon.ico') {
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
response.end('<!doctype html><title>SVG mark</title><link rel="icon" type="image/svg+xml" href="/mark.svg">');
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const capture = await runCliAsync(
|
||||
['brands', 'capture', `http://127.0.0.1:${address.port}/studio`, '--json'],
|
||||
{ ARCHIFY_BRAND_ALLOW_PRIVATE: '1' },
|
||||
);
|
||||
assert.notEqual(capture.status, 0, capture.stdout);
|
||||
assert.match(capture.stderr, /unsupported brand image type image\/svg\+xml/i);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('unsupported SVG declarations cannot crowd out the favicon fallback', async () => {
|
||||
const icon = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
|
||||
let fallbackHits = 0;
|
||||
const server = http.createServer((request, response) => {
|
||||
if (request.url === '/favicon.ico') {
|
||||
fallbackHits += 1;
|
||||
response.writeHead(200, { 'content-type': 'image/png' });
|
||||
response.end(icon);
|
||||
return;
|
||||
}
|
||||
if (request.url?.endsWith('.svg')) {
|
||||
response.writeHead(200, { 'content-type': 'image/svg+xml' });
|
||||
response.end('<svg xmlns="http://www.w3.org/2000/svg"/>');
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
response.end(`<!doctype html><title>Fallback mark</title>${Array.from(
|
||||
{ length: 6 },
|
||||
(_, index) => `<link rel="icon" type="image/svg+xml" href="/mark-${index}.svg">`,
|
||||
).join('')}`);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const capture = await runCliAsync(
|
||||
['brands', 'capture', `http://127.0.0.1:${address.port}/`, '--json'],
|
||||
{ ARCHIFY_BRAND_ALLOW_PRIVATE: '1' },
|
||||
);
|
||||
assert.equal(capture.status, 0, capture.stderr || capture.stdout);
|
||||
const receipt = JSON.parse(capture.stdout);
|
||||
assert.equal(receipt.evidence.contentType, 'image/png');
|
||||
assert.equal(fallbackHits, 1);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('capture rejects an image whose bytes do not match its declared media type', async () => {
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'image/png' });
|
||||
response.end('<html>not a png</html>');
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const capture = await runCliAsync(
|
||||
['brands', 'capture', `http://127.0.0.1:${address.port}/mark.png`, '--json'],
|
||||
{ ARCHIFY_BRAND_ALLOW_PRIVATE: '1' },
|
||||
);
|
||||
assert.notEqual(capture.status, 0, capture.stdout);
|
||||
assert.match(capture.stderr, /do(?:es)? not match image\/png/i);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('capture rejects a truncated PNG that contains only its signature', async () => {
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'image/png' });
|
||||
response.end(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const capture = await runCliAsync(
|
||||
['brands', 'capture', `http://127.0.0.1:${address.port}/mark.png`, '--json'],
|
||||
{ ARCHIFY_BRAND_ALLOW_PRIVATE: '1' },
|
||||
);
|
||||
assert.notEqual(capture.status, 0, capture.stdout);
|
||||
assert.match(capture.stderr, /do(?:es)? not match image\/png/i);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown preset names fail with a repairable public CLI diagnostic', () => {
|
||||
const input = writeFixture('architecture', 'unknown-preset', 'open-aii');
|
||||
const result = spawnSync(process.execPath, [cli, 'validate', 'architecture', input, '--json'], {
|
||||
cwd: skillRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 1, result.stderr || result.stdout);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.ok, false);
|
||||
assert.ok(receipt.diagnostics.some((entry) => entry.code === 'brand/unknown'));
|
||||
assert.ok(receipt.diagnostics.some((entry) => entry.supportedFixes.some((fix) => fix.includes('archify brands'))));
|
||||
});
|
||||
|
||||
test('viewer exposes brand identity to Passport and Finder while keeping source beacons clear', () => {
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets', 'template.html'), 'utf8');
|
||||
assert.match(template, /id="focus-brand" data-passport="brand" hidden/);
|
||||
assert.match(template, /node\.getAttribute\('data-node-brand'\)/);
|
||||
assert.match(template, /brandOffset = node\.hasAttribute\('data-node-brand'\) \? 24 : 0/);
|
||||
assert.match(template, /sourceSearch \+ ' ' \+ text\)\.toLowerCase\(\) \+ ' ' \+ brand\.toLowerCase\(\)/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,157 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-chapter-delta-preview-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
const PROOF_CASES = [
|
||||
'agent-tool-call.workflow.json',
|
||||
'production-deployment.architecture.json',
|
||||
'cache-miss-request.sequence.json',
|
||||
'release-delivery.workflow.json',
|
||||
'incident-response.workflow.json',
|
||||
'product-analytics.dataflow.json',
|
||||
'async-job-roundtrip.sequence.json',
|
||||
'event-stream.dataflow.json',
|
||||
'agent-run.lifecycle.json',
|
||||
'deployment-release.lifecycle.json',
|
||||
'web-app.architecture.json',
|
||||
];
|
||||
|
||||
function render(mode) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', CASES[mode]),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
function delta(previous, destination) {
|
||||
const previousIds = new Set(previous);
|
||||
const destinationIds = new Set(destination);
|
||||
return {
|
||||
stay: previous.filter((id) => destinationIds.has(id)),
|
||||
enter: destination.filter((id) => !previousIds.has(id)),
|
||||
leave: previous.filter((id) => !destinationIds.has(id)),
|
||||
};
|
||||
}
|
||||
|
||||
test('all five renderers inherit one viewer-only static Chapter Delta Preview', () => {
|
||||
for (const mode of Object.keys(CASES)) {
|
||||
const html = render(mode);
|
||||
assert.match(html, /function chapterDelta\(previous, destination\)/, mode);
|
||||
assert.match(html, /className = 'guided-view-chapter-delta'/, mode);
|
||||
assert.match(html, /svg\.setAttribute\('data-chapter-preview', views\[index\]\.id\)/, mode);
|
||||
assert.match(html, /data-chapter-preview-role/, mode);
|
||||
assert.match(html, /transition: none !important/, mode);
|
||||
assert.doesNotMatch(canonicalSvg(html), /data-chapter-preview|data-chapter-preview-role/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('exact stable-ID set math powers truthful counts and the existing handoff', () => {
|
||||
const html = render('workflow');
|
||||
assert.match(html, /stay: previousFocus\.filter\(function \(id\) \{ return destinationIds\[id\]; \}\)/);
|
||||
assert.match(html, /enter: destinationFocus\.filter\(function \(id\) \{ return !previousIds\[id\]; \}\)/);
|
||||
assert.match(html, /leave: previousFocus\.filter\(function \(id\) \{ return !destinationIds\[id\]; \}\)/);
|
||||
assert.match(html, /var delta = chapterDelta\(previous, destination\);[\s\S]*?chapterAnchor\(previous, destination, outgoingBeatIndex, delta\)/);
|
||||
assert.match(html, /var compact = '=' \+ delta\.stay\.length \+ ' \+' \+ delta\.enter\.length \+ ' \\u2212' \+ delta\.leave\.length/);
|
||||
assert.match(html, /viewerText\('viewer\.guided\.chapter\.delta\.aria'/);
|
||||
assert.doesNotMatch(html, /inferChapterDelta|matchChapterLabel|nearestKind/);
|
||||
|
||||
let adjacent = 0;
|
||||
let shared = 0;
|
||||
for (const file of PROOF_CASES) {
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', file), 'utf8'));
|
||||
const views = doc.meta.views;
|
||||
for (let index = 1; index < views.length; index += 1) {
|
||||
adjacent += 1;
|
||||
if (delta(views[index - 1].focus, views[index].focus).stay.length) shared += 1;
|
||||
}
|
||||
}
|
||||
assert.equal(adjacent, 22);
|
||||
assert.equal(shared, 19);
|
||||
|
||||
const workflow = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', CASES.workflow), 'utf8'));
|
||||
const [first, second, third] = workflow.meta.views;
|
||||
assert.deepEqual(delta(first.focus, second.focus), {
|
||||
stay: ['router', 'approval'],
|
||||
enter: ['blocked', 'retry'],
|
||||
leave: ['user', 'chat', 'planner', 'tool', 'external', 'final'],
|
||||
});
|
||||
assert.deepEqual(delta(second.focus, third.focus), {
|
||||
stay: [],
|
||||
enter: ['external', 'store', 'trace'],
|
||||
leave: ['router', 'approval', 'blocked', 'retry'],
|
||||
});
|
||||
});
|
||||
|
||||
test('pointer and keyboard inspect while touch and native activation still commit directly', () => {
|
||||
const html = render('architecture');
|
||||
assert.match(html, /chapterList\.addEventListener\('pointerover'/);
|
||||
assert.match(html, /!hoverCapable\(\) \|\| event\.pointerType === 'touch'/);
|
||||
assert.match(html, /setChapterPreviewIntent\('pointer', chapterButtons\.indexOf\(button\)\)/);
|
||||
assert.match(html, /chapterIndex\.addEventListener\('focusin',[\s\S]*?setChapterPreviewIntent\('focus'/);
|
||||
assert.match(html, /event\.key === 'Escape' && activePreviewIndex >= 0[\s\S]*?event\.stopImmediatePropagation\(\)[\s\S]*?clearChapterPreview\(\{ clearIntents: true \}\)/);
|
||||
assert.match(html, /chapterList\.addEventListener\('click',[\s\S]*?activateById/);
|
||||
assert.match(html, /button\.type = 'button'/);
|
||||
assert.doesNotMatch(html, /firstTap|secondTap|longpress|long-press/);
|
||||
|
||||
const previewRuntime = html.slice(
|
||||
html.indexOf('function chapterPreviewBlocked()'),
|
||||
html.indexOf('function sharePlaybackRequested()'),
|
||||
);
|
||||
assert.doesNotMatch(previewRuntime, /Archify\.view\.|updateUrl\(|Archify\.focus\.|renderStoryTrail\(/);
|
||||
});
|
||||
|
||||
test('latest intent, stronger owners, playback, and lifecycle cleanup remain bounded', () => {
|
||||
const html = render('lifecycle');
|
||||
assert.match(html, /var previewGeneration = 0/);
|
||||
assert.match(html, /right\.generation - left\.generation/);
|
||||
assert.match(html, /\[pointerPreviewIntent, focusPreviewIntent\]/);
|
||||
assert.match(html, /document\.hidden \|\| playing \|\| currentHandoff/);
|
||||
assert.match(html, /data-route-picking'[\s\S]*?data-route-active/);
|
||||
assert.match(html, /data-lens-active'[\s\S]*?data-legend-preview-active/);
|
||||
assert.match(html, /data-relationship-preview-active'[\s\S]*?data-intent-trace-active/);
|
||||
assert.match(html, /if \(playing\) pausePlayback\(\)/);
|
||||
assert.match(html, /Archify\.motionGovernor\.claim\('chapter-preview'/);
|
||||
assert.match(html, /Archify\.motionGovernor\.release\(token\)/);
|
||||
assert.match(html, /handoff\.resolve[\s\S]*?syncChapterPreview\(\)/);
|
||||
assert.match(html, /visibilitychange'[\s\S]*?clearChapterPreview/);
|
||||
assert.match(html, /beforeprint'[\s\S]*?clearChapterPreview[\s\S]*?settleHandoff/);
|
||||
});
|
||||
|
||||
test('mobile, Still, embed, print, and canonical exports keep the preview viewer-only', () => {
|
||||
const html = render('sequence');
|
||||
assert.match(html, /\.guided-view-chapter \{ min-height: 2\.75rem; \}/);
|
||||
assert.match(html, /flex: 0 0 min\(14rem, 78vw\)/);
|
||||
assert.match(html, /\.guided-view-chapter-delta\[hidden\] \{ display: none; \}/);
|
||||
assert.match(html, /document\.documentElement\.getAttribute\('data-embed'\) === 'true'/);
|
||||
assert.match(html, /prefers-reduced-motion: reduce[\s\S]*?svg\[data-chapter-preview\]/);
|
||||
assert.match(html, /svg\[data-chapter-preview\] \[data-node-id\],[\s\S]*?opacity: 1 !important; filter: none !important/);
|
||||
assert.match(html, /clone\.removeAttribute\('data-chapter-preview'\)/);
|
||||
assert.match(html, /clone\.querySelectorAll\('\[data-chapter-preview-role\]'\)/);
|
||||
assert.match(html, /!clone\.hasAttribute\('data-chapter-preview'\)/);
|
||||
assert.match(html, /canonicalStateClean/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,97 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-chapter-handoff-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', CASES[mode]),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all five renderers inherit one viewer-only Shared Anchor Chapter Handoff', () => {
|
||||
for (const mode of Object.keys(CASES)) {
|
||||
const html = render(mode);
|
||||
assert.match(html, /id="guided-view-handoff" hidden aria-hidden="true"/, mode);
|
||||
assert.match(html, /function beginHandoff\(previousIndex, nextIndex, previous, destination, outgoingBeatIndex, reason\)/, mode);
|
||||
assert.match(html, /data-chapter-handoff-overlay/, mode);
|
||||
assert.match(html, /ring\.setAttribute\('class', 'chapter-handoff-anchor'\)/, mode);
|
||||
assert.doesNotMatch(canonicalSvg(html), /data-chapter-handoff|data-chapter-role|chapter-handoff-anchor/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('anchor selection uses exact stable-id intersection with deterministic outgoing priority', () => {
|
||||
const html = render('workflow');
|
||||
assert.match(html, /function chapterDelta\(previous, destination\)/);
|
||||
assert.match(html, /function chapterAnchor\(previous, destination, outgoingBeatIndex, delta\)/);
|
||||
assert.match(html, /delta\.stay\.forEach\(function \(id\) \{ stayIds\[id\] = true; \}\)/);
|
||||
assert.match(html, /var activeBeat = outgoingBeatIndex >= 0 \? previous\.focus\[outgoingBeatIndex\] : ''/);
|
||||
assert.match(html, /activeBeat && stayIds\[activeBeat\]/);
|
||||
assert.match(html, /for \(var index = previous\.focus\.length - 1; index >= 0; index -= 1\)/);
|
||||
assert.match(html, /if \(stayIds\[previous\.focus\[index\]\]\) return previous\.focus\[index\]/);
|
||||
assert.doesNotMatch(html, /inferChapterAnchor|matchChapterLabel|nearestKind/);
|
||||
});
|
||||
|
||||
test('handoff holds one truthful anchor then settles through one finite camera transaction', () => {
|
||||
const html = render('architecture');
|
||||
assert.match(html, /handoff\.mode = 'settling'/);
|
||||
assert.match(html, /setTimeout\(startCamera, 110\)/);
|
||||
assert.match(html, /duration: 420/);
|
||||
assert.match(html, /requestAnimationFrame\(step\)/);
|
||||
assert.match(html, /var eased = 1 - Math\.pow\(1 - fraction, 3\)/);
|
||||
assert.match(html, /Archify\.motionGovernor\.claim\('handoff'/);
|
||||
assert.match(html, /Archify\.motionGovernor\.release\(handoff\.ownerToken\)/);
|
||||
assert.match(html, /handoffReceipt\.textContent = viewerText\('viewer\.guided\.handoff'/);
|
||||
assert.doesNotMatch(html, /chapter-handoff[^\n]+infinite/);
|
||||
});
|
||||
|
||||
test('latest intent, manual takeover, Still, reduced motion, and hidden pages cleanly settle', () => {
|
||||
const html = render('lifecycle');
|
||||
assert.match(html, /cancelHandoff\('replaced'\)/);
|
||||
assert.match(html, /cameraTransaction\.cancel\(reason \|\| 'cancelled', commitTarget === true\)/);
|
||||
assert.match(html, /transaction\.settled/);
|
||||
assert.match(html, /currentHandoff !== handoff/);
|
||||
assert.match(html, /Archify\.guidedViews\.cancelHandoff\(reason \|\| 'manual'\)/);
|
||||
assert.match(html, /settleHandoff\(systemPaused \? 'reduced-motion' : \(hasSuspension\(\) \? 'hidden' : 'still'\)\)/);
|
||||
assert.match(html, /settleHandoff\('hidden'\)/);
|
||||
assert.match(html, /settleHandoff\('reduced-motion'\)/);
|
||||
assert.match(html, /window\.addEventListener\('beforeprint',[\s\S]*?clearChapterPreview[\s\S]*?settleHandoff\('print'\)/);
|
||||
});
|
||||
|
||||
test('mobile, embed, print, and canonical exports keep strict static boundaries', () => {
|
||||
const html = render('sequence');
|
||||
assert.match(html, /document\.documentElement\.getAttribute\('data-embed'\) === 'true'/);
|
||||
assert.match(html, /cameraReceipt\(\{ scrollLeft: target \}/);
|
||||
assert.match(html, /behavior: instant \? 'auto' : 'smooth'/);
|
||||
assert.match(html, /\.chapter-handoff-overlay \{ display: none !important; \}/);
|
||||
assert.match(html, /clone\.removeAttribute\('data-chapter-handoff'\)/);
|
||||
assert.match(html, /clone\.removeAttribute\('data-chapter-anchor'\)/);
|
||||
assert.match(html, /clone\.querySelectorAll\('\[data-chapter-handoff-overlay\]'\)/);
|
||||
assert.match(html, /clone\.querySelectorAll\('\[data-chapter-role\]'\)/);
|
||||
assert.match(html, /!clone\.hasAttribute\('data-chapter-handoff'\)/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-chapter-rail-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', CASES[mode]),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all guided renderers expose one runtime-built named chapter rail', () => {
|
||||
for (const mode of Object.keys(CASES)) {
|
||||
const html = render(mode);
|
||||
assert.match(html, /<nav class="guided-view-index" id="guided-view-index" aria-label="Story chapters">/, mode);
|
||||
assert.match(html, /<ol class="guided-view-chapters" id="guided-view-chapters"><\/ol>/, mode);
|
||||
assert.match(html, /function buildChapterIndex\(\)/, mode);
|
||||
assert.match(html, /views\.forEach\(function \(view, index\)/, mode);
|
||||
assert.match(html, /position\.textContent = \(index \+ 1 < 10 \? '0' : ''\) \+ \(index \+ 1\)/, mode);
|
||||
assert.match(html, /title\.textContent = view\.label/, mode);
|
||||
assert.match(html, /stops\.textContent = viewerCount\('viewer\.guided\.chapter\.stop', view\.focus\.length\)/, mode);
|
||||
assert.doesNotMatch(canonicalSvg(html), /guided-view-chapter|data-chapter-position/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('chapter rail delegates selection and mirrors the existing activeIndex owner', () => {
|
||||
const html = render('architecture');
|
||||
assert.match(html, /activateById\(button\.getAttribute\('data-guided-view-id'\)\)/);
|
||||
assert.match(html, /var current = index === activeIndex/);
|
||||
assert.match(html, /activeIndex < 0 \? 'available' : \(current \? 'current' : \(index < activeIndex \? 'before' : 'after'\)\)/);
|
||||
assert.match(html, /button\.setAttribute\('aria-current', 'step'\)/);
|
||||
assert.match(html, /button\.removeAttribute\('aria-current'\)/);
|
||||
assert.match(html, /syncChapterIndex\(\);[\s\S]*renderShareCue\(\)/);
|
||||
assert.doesNotMatch(html, /selectedChapter|visitedChapters|completedChapters/);
|
||||
});
|
||||
|
||||
test('chapter rail is keyboard-first and pauses playback on reader takeover', () => {
|
||||
const html = render('workflow');
|
||||
assert.match(html, /chapterIndex\.addEventListener\('focusin',[\s\S]*if \(playing\) pausePlayback\(\)/);
|
||||
assert.match(html, /event\.key === 'ArrowRight'/);
|
||||
assert.match(html, /event\.key === 'ArrowLeft'/);
|
||||
assert.match(html, /event\.key === 'Home'/);
|
||||
assert.match(html, /event\.key === 'End'/);
|
||||
assert.match(html, /focusChapterButton\(target\)/);
|
||||
assert.match(html, /button\.type = 'button'/);
|
||||
assert.doesNotMatch(html, /role="tab"|role="tabpanel"/);
|
||||
});
|
||||
|
||||
test('chapter rail has positional, touch, mobile, motion, embed, and print boundaries', () => {
|
||||
const html = render('lifecycle');
|
||||
assert.match(html, /\.guided-view-chapter\[data-chapter-position="current"\][\s\S]*border: 2px solid/);
|
||||
assert.match(html, /data-chapter-position="before"[\s\S]*border-style: solid/);
|
||||
assert.match(html, /data-chapter-position="after"[\s\S]*border-style: dashed/);
|
||||
assert.match(html, /\.guided-view-chapter \{ min-height: 2\.75rem; \}/);
|
||||
assert.match(html, /scroll-snap-type: x proximity/);
|
||||
assert.match(html, /flex: 0 0 min\(14rem, 78vw\)/);
|
||||
assert.match(html, /behavior: 'auto'/);
|
||||
assert.match(html, /prefers-reduced-motion: reduce[\s\S]*\.guided-view-chapter \{ transition: none !important; \}/);
|
||||
assert.match(html, /html\[data-embed="true"\] \.guided-views \{ display: none !important; \}/);
|
||||
assert.match(html, /\.toolbar, \.diagram-nav, \.focus-chip, \.guided-views, \.archify-toast, \.no-print \{ display: none !important; \}/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,244 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { stageCleanSkill } from '../../scripts/stage-clean-skill.mjs';
|
||||
|
||||
const stagerPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../scripts/stage-clean-skill.mjs');
|
||||
|
||||
function git(root, args) {
|
||||
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
}
|
||||
|
||||
function write(root, relative, content, mode = null) {
|
||||
const target = path.join(root, relative);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, content);
|
||||
if (mode !== null) fs.chmodSync(target, mode);
|
||||
return target;
|
||||
}
|
||||
|
||||
function repositoryFixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-clean-stage-'));
|
||||
write(root, 'archify/package.json', JSON.stringify({
|
||||
name: 'archify-fixture',
|
||||
scripts: { test: 'node --test' },
|
||||
devDependencies: { ajv: '1.0.0' },
|
||||
}));
|
||||
write(root, 'archify/package-lock.json', '{}\n');
|
||||
write(root, 'archify/skill-release.json', '{}\n');
|
||||
write(root, 'archify/scripts/check-update.mjs', 'export {};\n');
|
||||
write(root, 'archify/scripts/update-contract.mjs', 'export {};\n');
|
||||
write(root, 'archify/renderers/shared/generated-validators.mjs', 'export {};\n');
|
||||
write(root, 'archify/test/repository-only.test.mjs', 'throw new Error();\n');
|
||||
git(root, ['init']);
|
||||
return root;
|
||||
}
|
||||
|
||||
test('clean staging preserves index modes and strips repository-only package metadata', () => {
|
||||
const root = repositoryFixture();
|
||||
const destination = path.join(root, 'staged-skill');
|
||||
try {
|
||||
write(root, 'archify/bin/executable.mjs', '#!/usr/bin/env node\n', 0o755);
|
||||
write(root, 'archify/runtime/test/required.dat', 'runtime fixture\n');
|
||||
git(root, ['add', 'archify']);
|
||||
|
||||
stageCleanSkill({ repoRoot: root, destination });
|
||||
|
||||
assert.equal(fs.statSync(path.join(destination, 'bin', 'executable.mjs')).mode & 0o777, 0o755);
|
||||
assert.equal(fs.existsSync(path.join(destination, 'test')), false);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(destination, 'runtime', 'test', 'required.dat'), 'utf8'),
|
||||
'runtime fixture\n',
|
||||
'only the repository-root test tree is excluded',
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(destination, 'package-lock.json')), false);
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(destination, 'package.json'), 'utf8'));
|
||||
assert.equal(Object.hasOwn(packageJson, 'scripts'), false);
|
||||
assert.equal(Object.hasOwn(packageJson, 'devDependencies'), false);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clean staging rejects a symlink in a tracked file ancestor before copying bytes', (t) => {
|
||||
const root = repositoryFixture();
|
||||
const destination = path.join(root, 'staged-skill');
|
||||
try {
|
||||
const runtime = path.join(root, 'archify', 'runtime');
|
||||
write(root, 'archify/runtime/payload.txt', 'tracked fixture\n');
|
||||
git(root, ['add', 'archify']);
|
||||
fs.rmSync(runtime, { recursive: true });
|
||||
const external = path.join(root, 'outside-runtime');
|
||||
write(root, 'outside-runtime/payload.txt', 'external secret\n');
|
||||
try {
|
||||
fs.symlinkSync(external, runtime, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
} catch (error) {
|
||||
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
||||
t.skip(`symlinks unavailable: ${error.code}`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
assert.throws(
|
||||
() => stageCleanSkill({ repoRoot: root, destination }),
|
||||
/refusing to package path through symlink: archify\/runtime/,
|
||||
);
|
||||
assert.equal(fs.existsSync(destination), false);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clean staging rejects tracked symlinks before reading through them', (t) => {
|
||||
const root = repositoryFixture();
|
||||
const destination = path.join(root, 'staged-skill');
|
||||
try {
|
||||
const external = write(root, 'outside.txt', 'private fixture\n');
|
||||
const linked = path.join(root, 'archify', 'linked.txt');
|
||||
try {
|
||||
fs.symlinkSync(external, linked);
|
||||
} catch (error) {
|
||||
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
||||
t.skip(`symlinks unavailable: ${error.code}`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
git(root, ['add', 'archify']);
|
||||
|
||||
assert.throws(
|
||||
() => stageCleanSkill({ repoRoot: root, destination }),
|
||||
/refusing to package tracked symlink: archify\/linked\.txt/,
|
||||
);
|
||||
assert.equal(fs.existsSync(destination), false);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clean staging snapshots unstaged tracked bytes before a source ancestor can be swapped', (t) => {
|
||||
const root = repositoryFixture();
|
||||
const destination = path.join(root, 'staged-skill');
|
||||
const runtime = path.join(root, 'archify', 'runtime');
|
||||
const external = path.join(root, 'outside-runtime');
|
||||
const originalMkdirSync = fs.mkdirSync;
|
||||
let swapped = false;
|
||||
try {
|
||||
const payload = write(root, 'archify/runtime/payload.txt', 'indexed fixture\n');
|
||||
write(root, 'outside-runtime/payload.txt', 'external secret\n');
|
||||
git(root, ['add', 'archify']);
|
||||
fs.writeFileSync(payload, 'unstaged working-tree fixture\n');
|
||||
|
||||
const probe = path.join(root, 'symlink-probe');
|
||||
try {
|
||||
fs.symlinkSync(external, probe, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
fs.rmSync(probe, { force: true });
|
||||
} catch (error) {
|
||||
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
||||
t.skip(`symlinks unavailable: ${error.code}`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
fs.mkdirSync = function swapSourceAfterSnapshot(target, ...args) {
|
||||
const result = originalMkdirSync.call(fs, target, ...args);
|
||||
if (!swapped && path.resolve(target) === path.resolve(destination)) {
|
||||
fs.rmSync(runtime, { recursive: true });
|
||||
fs.symlinkSync(external, runtime, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
swapped = true;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
stageCleanSkill({ repoRoot: root, destination });
|
||||
|
||||
assert.equal(swapped, true, 'the deterministic ancestor-swap attack must run');
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(destination, 'runtime', 'payload.txt'), 'utf8'),
|
||||
'unstaged working-tree fixture\n',
|
||||
'staging keeps the tracked working-tree snapshot and never follows the replacement ancestor',
|
||||
);
|
||||
} finally {
|
||||
fs.mkdirSync = originalMkdirSync;
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clean staging rejects a source ancestor swapped during preflight traversal', (t) => {
|
||||
const root = repositoryFixture();
|
||||
const destination = path.join(root, 'staged-skill');
|
||||
const runtime = path.join(root, 'archify', 'runtime');
|
||||
const external = path.join(root, 'outside-runtime');
|
||||
const originalLstatSync = fs.lstatSync;
|
||||
let swapped = false;
|
||||
try {
|
||||
write(root, 'archify/runtime/payload.txt', 'tracked fixture\n');
|
||||
write(root, 'outside-runtime/payload.txt', 'external secret\n');
|
||||
git(root, ['add', 'archify']);
|
||||
const canonicalRuntime = path.join(fs.realpathSync(root), 'archify', 'runtime');
|
||||
|
||||
const probe = path.join(root, 'symlink-probe');
|
||||
try {
|
||||
fs.symlinkSync(external, probe, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
fs.rmSync(probe, { force: true });
|
||||
} catch (error) {
|
||||
if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error?.code)) {
|
||||
t.skip(`symlinks unavailable: ${error.code}`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
fs.lstatSync = function swapSourceBetweenAncestorAndLeaf(target, ...args) {
|
||||
const metadata = originalLstatSync.call(fs, target, ...args);
|
||||
if (!swapped && path.resolve(target) === canonicalRuntime) {
|
||||
// Guard before mutation: recursive removal can re-enter the patched
|
||||
// lstatSync implementation on Linux.
|
||||
swapped = true;
|
||||
fs.rmSync(runtime, { recursive: true });
|
||||
fs.symlinkSync(external, runtime, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
}
|
||||
return metadata;
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => stageCleanSkill({ repoRoot: root, destination }),
|
||||
/(?:tracked package path changed before it could be read: archify\/|tracked package input is missing or unreadable: archify\/runtime\/payload\.txt)/,
|
||||
);
|
||||
assert.equal(swapped, true, 'the deterministic mid-preflight ancestor swap must run');
|
||||
assert.equal(fs.existsSync(destination), false);
|
||||
} finally {
|
||||
fs.lstatSync = originalLstatSync;
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clean staging reports the Git spawn error when Git cannot start', () => {
|
||||
const root = repositoryFixture();
|
||||
const destination = path.join(root, 'staged-skill');
|
||||
try {
|
||||
git(root, ['add', 'archify']);
|
||||
const result = spawnSync(process.execPath, [
|
||||
stagerPath,
|
||||
'--root', root,
|
||||
'--dest', destination,
|
||||
], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, PATH: '' },
|
||||
});
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /unable to enumerate tracked Archify files: .*ENOENT/);
|
||||
assert.doesNotMatch(result.stderr, /tracked Archify paths must be valid UTF-8/);
|
||||
assert.equal(fs.existsSync(destination), false);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,785 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { extractSvgs, parseXml } from './helpers/xml.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-cli-'));
|
||||
const cli = path.join(skillRoot, 'bin/archify.mjs');
|
||||
|
||||
function run(args, options = {}) {
|
||||
return spawnSync(process.execPath, [cli, ...args], {
|
||||
cwd: options.cwd || skillRoot,
|
||||
encoding: 'utf8',
|
||||
env: options.env || process.env,
|
||||
});
|
||||
}
|
||||
|
||||
function sha256(file) {
|
||||
return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
}
|
||||
|
||||
function makeFakeOpeners(name, { exitCode = 0 } = {}) {
|
||||
const bin = path.join(tmp, name);
|
||||
const log = path.join(bin, 'open-log.json');
|
||||
fs.mkdirSync(bin, { recursive: true });
|
||||
const source = `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const target = process.argv[process.argv.length - 1];
|
||||
fs.writeFileSync(process.env.ARCHIFY_TEST_OPEN_LOG, JSON.stringify({
|
||||
argv: process.argv.slice(2),
|
||||
target,
|
||||
existed: fs.existsSync(target),
|
||||
}));
|
||||
process.exit(${exitCode});
|
||||
`;
|
||||
for (const command of ['open', 'xdg-open']) {
|
||||
const executable = path.join(bin, command);
|
||||
fs.writeFileSync(executable, source);
|
||||
fs.chmodSync(executable, 0o755);
|
||||
}
|
||||
return {
|
||||
log,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}${path.delimiter}${process.env.PATH || ''}`,
|
||||
ARCHIFY_TEST_OPEN_LOG: log,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function copyInstalledSkill(target) {
|
||||
fs.cpSync(skillRoot, target, {
|
||||
recursive: true,
|
||||
filter(source) {
|
||||
const rel = path.relative(skillRoot, source);
|
||||
return rel !== 'node_modules' && !rel.startsWith(`node_modules${path.sep}`)
|
||||
&& rel !== 'test' && !rel.startsWith(`test${path.sep}`)
|
||||
// Another test creates this short-lived directory under skillRoot so
|
||||
// Ajv resolves from the checkout. Never copy a concurrently removed
|
||||
// test fixture into an installed-skill simulation.
|
||||
&& !rel.startsWith('.validator-check-');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('cli: help lists commands and diagram types', () => {
|
||||
const result = run(['--help']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /archify render <type>/);
|
||||
assert.match(result.stdout, /archify compare architecture <base\.json> <head\.json>/);
|
||||
assert.match(result.stdout, /archify deliver <type>/);
|
||||
assert.match(result.stdout, /archify preview <type>/);
|
||||
assert.match(result.stdout, /archify visual-check <output\.html>/);
|
||||
assert.match(result.stdout, /--open/);
|
||||
assert.match(result.stdout, /--repo-root path \(architecture only\)/);
|
||||
assert.match(result.stdout, /archify guide \[scenario or question\]/);
|
||||
assert.match(result.stdout, /archify doctor/);
|
||||
assert.match(result.stdout, /archify demo \[output-directory\]/);
|
||||
assert.match(result.stdout, /architecture, workflow, sequence, dataflow, lifecycle/);
|
||||
});
|
||||
|
||||
test('cli: doctor reports a complete installation is ready', () => {
|
||||
const result = run(['doctor']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /\[ok\] Node\.js v\d+/);
|
||||
assert.match(result.stdout, /\[ok\] Core template/);
|
||||
assert.match(result.stdout, /\[ok\] Example renderer/);
|
||||
assert.match(result.stdout, /\[ok\] Live preview runtime/);
|
||||
assert.match(result.stdout, /\[ok\] Scenario recipe guide/);
|
||||
assert.match(result.stdout, /\[ok\] Progressive authoring references/);
|
||||
assert.match(result.stdout, /\[ok\] Architecture compare runtime and proof fixtures/);
|
||||
assert.match(result.stdout, /\[ok\] Standalone schema validators/);
|
||||
assert.match(result.stdout, /\[ok\] architecture renderer, schema, and example/);
|
||||
assert.match(result.stdout, /\[ok\] lifecycle renderer, schema, and example/);
|
||||
assert.match(result.stdout, /Archify is ready\./);
|
||||
});
|
||||
|
||||
test('cli: doctor identifies an incomplete installation', () => {
|
||||
const incompleteRoot = path.join(tmp, 'incomplete-skill');
|
||||
const incompleteBin = path.join(incompleteRoot, 'bin');
|
||||
fs.mkdirSync(incompleteBin, { recursive: true });
|
||||
fs.copyFileSync(cli, path.join(incompleteBin, 'archify.mjs'));
|
||||
|
||||
const result = spawnSync(process.execPath, [path.join(incompleteBin, 'archify.mjs'), 'doctor'], {
|
||||
cwd: incompleteRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stdout, /\[missing\] Core template/);
|
||||
assert.match(result.stdout, /\[missing\] Scenario recipe guide/);
|
||||
assert.match(result.stdout, /\[missing\] workflow renderer, schema, and example/);
|
||||
assert.match(result.stderr, /Archify is not ready: \d+ required files? missing\./);
|
||||
});
|
||||
|
||||
test('cli: doctor rejects a corrupt standalone validator', () => {
|
||||
const corruptRoot = path.join(tmp, 'corrupt-skill');
|
||||
copyInstalledSkill(corruptRoot);
|
||||
fs.writeFileSync(path.join(corruptRoot, 'renderers/shared/generated-validators.mjs'), 'export const workflow = ;\n');
|
||||
|
||||
const result = spawnSync(process.execPath, [path.join(corruptRoot, 'bin/archify.mjs'), 'doctor'], {
|
||||
cwd: corruptRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stdout, /\[invalid\] Standalone schema validators/);
|
||||
assert.match(result.stderr, /Archify is not ready: 1 runtime check failed\./);
|
||||
});
|
||||
|
||||
test('cli: examples renders from an installed skill', () => {
|
||||
const installedRoot = path.join(tmp, 'installed-skill');
|
||||
copyInstalledSkill(installedRoot);
|
||||
|
||||
const result = spawnSync(process.execPath, [path.join(installedRoot, 'bin/archify.mjs'), 'examples'], {
|
||||
cwd: installedRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
for (const output of [
|
||||
'workflow-agent-tool-call-rendered.html',
|
||||
'sequence-cache-miss-request.html',
|
||||
'dataflow-product-analytics.html',
|
||||
'lifecycle-agent-run.html',
|
||||
'web-app-rendered.html',
|
||||
]) {
|
||||
assert.equal(fs.existsSync(path.join(installedRoot, 'examples', output)), true, output);
|
||||
}
|
||||
});
|
||||
|
||||
test('cli: guide lists all scenario recipes by diagram type', () => {
|
||||
const result = run(['guide']);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Archify scenario recipes \(11\)/);
|
||||
for (const type of ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']) {
|
||||
assert.match(result.stdout, new RegExp(`\\[${type}\\]`));
|
||||
}
|
||||
});
|
||||
|
||||
test('cli: guide recommends a scenario as structured json', () => {
|
||||
const result = run(['guide', 'Show an API request with Redis cache miss', '--json']);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.lang, 'en');
|
||||
assert.equal(parsed.confidence, 'high');
|
||||
assert.equal(parsed.recommendation.id, 'api-request');
|
||||
assert.equal(parsed.recommendation.type, 'sequence');
|
||||
});
|
||||
|
||||
test('cli: guide detects Chinese and explains the recommendation boundary', () => {
|
||||
const result = run(['guide', '展示 Kafka topic 消费者组和死信队列']);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /推荐: 事件流拓扑 \[dataflow\]/);
|
||||
assert.match(result.stdout, /不要这样用:/);
|
||||
assert.match(result.stdout, /必须包含:/);
|
||||
assert.match(result.stdout, /可直接复制的提示词:/);
|
||||
});
|
||||
|
||||
test('cli: guide works from an installed skill without node_modules', () => {
|
||||
const installedRoot = path.join(tmp, 'installed-guide-skill');
|
||||
copyInstalledSkill(installedRoot);
|
||||
const installedCli = path.join(installedRoot, 'bin/archify.mjs');
|
||||
|
||||
const result = spawnSync(process.execPath, [installedCli, 'guide', 'incident-runbook', '--json'], {
|
||||
cwd: installedRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(JSON.parse(result.stdout).recommendation.id, 'incident-runbook');
|
||||
});
|
||||
|
||||
test('cli: demo creates a ready-to-open diagram in a chosen directory', () => {
|
||||
const outputDirectory = path.join(tmp, 'my-demo');
|
||||
const output = path.join(outputDirectory, 'archify-demo.html');
|
||||
const result = run(['demo', outputDirectory]);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(fs.existsSync(output), true);
|
||||
assert.match(fs.readFileSync(output, 'utf8'), /Sample Web App Diagram/);
|
||||
assert.match(result.stdout, new RegExp(`Demo ready: ${output.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
|
||||
assert.match(result.stdout, /Next: open the HTML in your browser/);
|
||||
assert.match(result.stdout, /archify render architecture/);
|
||||
});
|
||||
|
||||
test('cli: demo defaults to the current directory', () => {
|
||||
const workingDirectory = path.join(tmp, 'default-demo');
|
||||
fs.mkdirSync(workingDirectory);
|
||||
const result = run(['demo'], { cwd: workingDirectory });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(fs.existsSync(path.join(workingDirectory, 'archify-demo.html')), true);
|
||||
});
|
||||
|
||||
test('cli: render writes a diagram html file', () => {
|
||||
const out = path.join(tmp, 'workflow.html');
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const result = run(['render', 'workflow', input, out]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(fs.existsSync(out), true);
|
||||
assert.match(fs.readFileSync(out, 'utf8'), /Agent Tool Call Workflow/);
|
||||
});
|
||||
|
||||
test('cli: visual-check returns a skipped receipt with exit 2 when Chrome is unavailable', () => {
|
||||
const out = path.join(tmp, 'visual-check-skipped.html');
|
||||
fs.writeFileSync(out, '<!doctype html><html><body>delivered</body></html>');
|
||||
const missingChrome = path.join(tmp, 'missing-chrome');
|
||||
const result = run(['visual-check', out, '--json'], {
|
||||
env: { ...process.env, ARCHIFY_CHROME: missingChrome },
|
||||
});
|
||||
|
||||
assert.equal(result.status, 2, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.status, 'skipped');
|
||||
assert.equal(receipt.visualReview, 'pending');
|
||||
assert.equal(receipt.chrome.status, 'unavailable');
|
||||
assert.equal(fs.existsSync(out.replace(/\.html$/, '.visual-check.json')), true);
|
||||
});
|
||||
|
||||
test('cli: deliver atomically writes a checked artifact and structured receipt', () => {
|
||||
const out = path.join(tmp, 'delivered-workflow.html');
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const result = run(['deliver', 'workflow', input, out, '--quality', 'showcase', '--json']);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(fs.existsSync(out), true);
|
||||
assert.match(fs.readFileSync(out, 'utf8'), /Agent Tool Call Workflow/);
|
||||
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.schemaVersion, 1);
|
||||
assert.equal(receipt.ok, true);
|
||||
assert.equal(receipt.command, 'deliver');
|
||||
assert.equal(receipt.type, 'workflow');
|
||||
assert.equal(receipt.input, input);
|
||||
assert.equal(receipt.output, out);
|
||||
assert.deepEqual(receipt.specification, {
|
||||
sha256: sha256(input),
|
||||
bytes: fs.statSync(input).size,
|
||||
});
|
||||
assert.match(receipt.artifact.sha256, /^[a-f0-9]{64}$/);
|
||||
assert.equal(receipt.artifact.sha256, sha256(out));
|
||||
assert.equal(receipt.artifact.bytes, fs.statSync(out).size);
|
||||
assert.deepEqual(receipt.validation, {
|
||||
checksPassed: 9,
|
||||
checkCount: 9,
|
||||
compositionProfile: 'showcase',
|
||||
compositionStatus: 'pass',
|
||||
errors: 0,
|
||||
warnings: 0,
|
||||
});
|
||||
assert.equal('open' in receipt, false);
|
||||
});
|
||||
|
||||
test('cli: deliver --open launches only the committed absolute artifact as one argument', {
|
||||
skip: process.platform === 'win32',
|
||||
}, () => {
|
||||
const fake = makeFakeOpeners('successful-open');
|
||||
const out = path.join(tmp, `-复杂 path 'quoted'`, 'verified diagram.html');
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const result = run(['deliver', 'workflow', input, out, '--open', '--json'], { env: fake.env });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.deepEqual(receipt.open, {
|
||||
requested: true,
|
||||
status: 'opened',
|
||||
target: out,
|
||||
method: process.platform === 'darwin' ? 'open' : 'xdg-open',
|
||||
});
|
||||
const invocation = JSON.parse(fs.readFileSync(fake.log, 'utf8'));
|
||||
assert.equal(invocation.existed, true, 'the opener must run after the atomic commit');
|
||||
assert.deepEqual(invocation.argv, [out]);
|
||||
assert.equal(invocation.target, out);
|
||||
assert.equal(fs.existsSync(out), true);
|
||||
});
|
||||
|
||||
test('cli: opener failure does not invalidate a verified delivery or pollute json stdout', {
|
||||
skip: process.platform === 'win32',
|
||||
}, () => {
|
||||
const fake = makeFakeOpeners('failed-open', { exitCode: 17 });
|
||||
const out = path.join(tmp, 'open-failure-preserves-delivery.html');
|
||||
const input = path.join(skillRoot, 'examples/web-app.architecture.json');
|
||||
const result = run(['deliver', 'architecture', input, out, '--open', '--json'], { env: fake.env });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.ok, true);
|
||||
assert.equal(receipt.open.status, 'failed');
|
||||
assert.equal(receipt.open.target, out);
|
||||
assert.match(result.stderr, /Could not open the verified artifact/);
|
||||
assert.match(result.stderr, new RegExp(out.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
assert.equal(fs.existsSync(out), true);
|
||||
assert.equal(receipt.artifact.sha256, sha256(out));
|
||||
});
|
||||
|
||||
test('cli: deliver failure never invokes the optional opener', {
|
||||
skip: process.platform === 'win32',
|
||||
}, () => {
|
||||
const fake = makeFakeOpeners('never-open');
|
||||
const input = path.join(tmp, 'invalid-open-delivery.json');
|
||||
fs.writeFileSync(input, '{broken json');
|
||||
const out = path.join(tmp, 'must-not-open.html');
|
||||
const result = run(['deliver', 'architecture', input, out, '--open', '--json'], { env: fake.env });
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(JSON.parse(result.stdout).stage, 'input');
|
||||
assert.equal(fs.existsSync(fake.log), false);
|
||||
assert.equal(fs.existsSync(out), false);
|
||||
});
|
||||
|
||||
test('cli: a missing optional opener module preserves verified delivery with a fallback receipt', () => {
|
||||
const installedRoot = path.join(tmp, 'missing-open-module-skill');
|
||||
copyInstalledSkill(installedRoot);
|
||||
const installedCli = path.join(installedRoot, 'bin/archify.mjs');
|
||||
fs.rmSync(path.join(installedRoot, 'bin/open-artifact.mjs'));
|
||||
const input = path.join(installedRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const out = path.join(tmp, 'missing-open-module-delivery.html');
|
||||
|
||||
const result = spawnSync(process.execPath, [installedCli, 'deliver', 'workflow', input, out, '--open', '--json'], {
|
||||
cwd: installedRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.ok, true);
|
||||
assert.deepEqual(receipt.open, {
|
||||
requested: true,
|
||||
status: 'unsupported',
|
||||
target: out,
|
||||
method: null,
|
||||
});
|
||||
assert.match(result.stderr, /Open it manually/);
|
||||
assert.equal(receipt.artifact.sha256, sha256(out));
|
||||
});
|
||||
|
||||
test('cli: deliver preserves the renderer default output contract', () => {
|
||||
const workingDirectory = path.join(tmp, 'delivery-default-output');
|
||||
fs.mkdirSync(workingDirectory, { recursive: true });
|
||||
const input = path.join(workingDirectory, 'source.architecture.json');
|
||||
const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/web-app.architecture.json'), 'utf8'));
|
||||
source.meta.output = 'verified-default.html';
|
||||
fs.writeFileSync(input, JSON.stringify(source));
|
||||
|
||||
const result = run(['deliver', 'architecture', input, '--json'], { cwd: workingDirectory });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.output, path.join(fs.realpathSync(workingDirectory), 'verified-default.html'));
|
||||
assert.equal(fs.existsSync(receipt.output), true);
|
||||
});
|
||||
|
||||
test('cli: deliver works from an installed skill without node_modules', () => {
|
||||
const installedRoot = path.join(tmp, 'installed-deliver-skill');
|
||||
copyInstalledSkill(installedRoot);
|
||||
const installedCli = path.join(installedRoot, 'bin/archify.mjs');
|
||||
const cases = [
|
||||
['architecture-boundaries', 'architecture', 'production-deployment.architecture.json'],
|
||||
['architecture-issue-110', 'architecture', 'brand-aware-delivery.architecture.json'],
|
||||
['workflow', 'workflow', 'agent-tool-call.workflow.json'],
|
||||
['sequence', 'sequence', 'cache-miss-request.sequence.json'],
|
||||
['dataflow', 'dataflow', 'product-analytics.dataflow.json'],
|
||||
['lifecycle', 'lifecycle', 'agent-run.lifecycle.json'],
|
||||
];
|
||||
|
||||
for (const [label, type, example] of cases) {
|
||||
const input = path.join(installedRoot, 'examples', example);
|
||||
const out = path.join(tmp, `installed-${label}-delivery.html`);
|
||||
const result = spawnSync(process.execPath, [installedCli, 'deliver', type, input, out, '--json'], {
|
||||
cwd: installedRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, `${label}: ${result.stderr}`);
|
||||
assert.equal(JSON.parse(result.stdout).validation.checkCount, 9, label);
|
||||
assert.equal(fs.existsSync(out), true, label);
|
||||
const extracted = extractSvgs(fs.readFileSync(out, 'utf8'));
|
||||
assert.equal(extracted.direct.length, 1, `${label}: expected one delivered SVG`);
|
||||
assert.doesNotThrow(
|
||||
() => parseXml(extracted.direct[0]),
|
||||
`${label}: delivered SVG must be well-formed XML`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('cli: deliver XML guard parses markup instead of scanning attribute-like text', () => {
|
||||
assert.doesNotThrow(() => parseXml(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" aria-label="mentions data-node-label safely"/>',
|
||||
));
|
||||
assert.throws(
|
||||
() => parseXml('<svg xmlns="http://www.w3.org/2000/svg" data-node-label></svg>'),
|
||||
/attribute without value/i,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseXml('<svg xmlns="http://www.w3.org/2000/svg"><g></svg>'),
|
||||
/unexpected close tag/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('cli: preview runs from an installed skill without node_modules and exits cleanly', { timeout: 30000 }, async () => {
|
||||
const installedRoot = path.join(tmp, 'installed-preview-skill');
|
||||
copyInstalledSkill(installedRoot);
|
||||
const installedCli = path.join(installedRoot, 'bin/archify.mjs');
|
||||
const input = path.join(installedRoot, 'examples/web-app.architecture.json');
|
||||
const output = path.join(tmp, 'installed-preview.html');
|
||||
const child = spawn(process.execPath, [installedCli, 'preview', 'architecture', input, output, '--quality', 'showcase', '--no-open'], {
|
||||
cwd: installedRoot,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
|
||||
let previewUrl;
|
||||
const started = Date.now();
|
||||
while (!previewUrl && Date.now() - started < 8000) {
|
||||
previewUrl = stdout.match(/preview (http:\/\/127\.0\.0\.1:\d+\/)/)?.[1];
|
||||
if (!previewUrl) await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
assert.ok(previewUrl, `preview URL missing; stdout=${stdout}; stderr=${stderr}`);
|
||||
|
||||
let state;
|
||||
while (Date.now() - started < 15000) {
|
||||
state = await fetch(new URL('/state', previewUrl)).then((response) => response.json());
|
||||
if (state.status === 'verified') break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
assert.equal(state?.status, 'verified', `preview did not verify; stdout=${stdout}; stderr=${stderr}`);
|
||||
assert.equal(state.revision, 1);
|
||||
assert.equal(fs.existsSync(output), true);
|
||||
|
||||
child.kill('SIGTERM');
|
||||
const exit = await new Promise((resolve) => child.once('close', (code, signal) => resolve({ code, signal })));
|
||||
assert.deepEqual(exit, { code: 0, signal: null });
|
||||
assert.match(stdout, /stopping preview/);
|
||||
await assert.rejects(fetch(previewUrl));
|
||||
assert.deepEqual(fs.readdirSync(path.dirname(output)).filter((name) => name.startsWith('.archify-preview-')), []);
|
||||
});
|
||||
|
||||
test('cli: deliver preserves the previous artifact when the final check fails', () => {
|
||||
const installedRoot = path.join(tmp, 'broken-deliver-skill');
|
||||
copyInstalledSkill(installedRoot);
|
||||
const installedCli = path.join(installedRoot, 'bin/archify.mjs');
|
||||
const templatePath = path.join(installedRoot, 'assets/template.html');
|
||||
const template = fs.readFileSync(templatePath, 'utf8');
|
||||
fs.writeFileSync(templatePath, template.replace('</body>', '<svg aria-label="accidental second svg"></svg>\n</body>'));
|
||||
|
||||
const input = path.join(installedRoot, 'examples/web-app.architecture.json');
|
||||
const out = path.join(tmp, 'preserved-delivery.html');
|
||||
const trustedPriorArtifact = '<!doctype html><title>trusted prior artifact</title>\n';
|
||||
fs.writeFileSync(out, trustedPriorArtifact);
|
||||
|
||||
const result = spawnSync(process.execPath, [installedCli, 'deliver', 'architecture', input, out, '--json'], {
|
||||
cwd: installedRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.stage, 'check');
|
||||
assert.equal(failure.diagnostics[0].code, 'artifact/single-svg');
|
||||
assert.equal(failure.diagnostics[0].subject.check, 'single_svg');
|
||||
assert.ok(failure.diagnostics[0].supportedFixes.some((fix) => fix.includes('exactly one diagram SVG')));
|
||||
assert.equal(failure.checker.checks.find((entry) => entry.name === 'single_svg').ok, false);
|
||||
assert.equal(fs.readFileSync(out, 'utf8'), trustedPriorArtifact);
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(path.dirname(out)).filter((name) => name.includes('.archify-delivery-')),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('cli: deliver reports renderer failure as json and preserves the previous artifact', () => {
|
||||
const input = path.join(tmp, 'invalid-delivery.workflow.json');
|
||||
const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/agent-tool-call.workflow.json'), 'utf8'));
|
||||
source.nodes[0].unexpected = true;
|
||||
fs.writeFileSync(input, JSON.stringify(source));
|
||||
|
||||
const out = path.join(tmp, 'renderer-failure-preserved.html');
|
||||
const trustedPriorArtifact = '<!doctype html><title>last known good</title>\n';
|
||||
fs.writeFileSync(out, trustedPriorArtifact);
|
||||
|
||||
const result = run(['deliver', 'workflow', input, out, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.stage, 'render');
|
||||
assert.match(failure.error, /schema validation failed/i);
|
||||
assert.equal(fs.readFileSync(out, 'utf8'), trustedPriorArtifact);
|
||||
});
|
||||
|
||||
test('cli: deliver reports unreadable input as json without touching the target', () => {
|
||||
const input = path.join(tmp, 'malformed-delivery.json');
|
||||
fs.writeFileSync(input, '{not valid json');
|
||||
const out = path.join(tmp, 'malformed-input-preserved.html');
|
||||
const trustedPriorArtifact = '<!doctype html><title>still trusted</title>\n';
|
||||
fs.writeFileSync(out, trustedPriorArtifact);
|
||||
|
||||
const result = run(['deliver', 'architecture', input, out, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.stage, 'input');
|
||||
assert.match(failure.error, /Could not read delivery input/);
|
||||
assert.equal(fs.readFileSync(out, 'utf8'), trustedPriorArtifact);
|
||||
});
|
||||
|
||||
test('cli: invalid source output metadata still fails inside the renderer', () => {
|
||||
const workingDirectory = path.join(tmp, 'invalid-output-metadata');
|
||||
fs.mkdirSync(workingDirectory, { recursive: true });
|
||||
const input = path.join(workingDirectory, 'source.architecture.json');
|
||||
const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/web-app.architecture.json'), 'utf8'));
|
||||
source.meta.output = 17;
|
||||
fs.writeFileSync(input, JSON.stringify(source));
|
||||
const out = path.join(workingDirectory, 'architecture.html');
|
||||
const trustedPriorArtifact = '<!doctype html><title>metadata did not replace me</title>\n';
|
||||
fs.writeFileSync(out, trustedPriorArtifact);
|
||||
|
||||
const result = run(['deliver', 'architecture', input, '--json'], { cwd: workingDirectory });
|
||||
assert.equal(result.status, 1);
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.stage, 'render');
|
||||
assert.match(failure.error, /schema validation failed/i);
|
||||
assert.equal(fs.readFileSync(out, 'utf8'), trustedPriorArtifact);
|
||||
});
|
||||
|
||||
test('cli: deliver reports commit failure without a false success receipt', () => {
|
||||
const input = path.join(skillRoot, 'examples/web-app.architecture.json');
|
||||
const outputDirectory = path.join(tmp, 'commit-target-is-a-directory');
|
||||
fs.mkdirSync(outputDirectory, { recursive: true });
|
||||
|
||||
const result = run(['deliver', 'architecture', input, outputDirectory, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.stage, 'commit');
|
||||
assert.match(failure.error, /Could not commit verified delivery/);
|
||||
assert.equal(fs.statSync(outputDirectory).isDirectory(), true);
|
||||
assert.equal(fs.readdirSync(outputDirectory).length, 0);
|
||||
});
|
||||
|
||||
test('cli: deliver reports preparation failure as json without touching the blocker', () => {
|
||||
const input = path.join(skillRoot, 'examples/web-app.architecture.json');
|
||||
const blockingFile = path.join(tmp, 'delivery-parent-is-a-file');
|
||||
fs.writeFileSync(blockingFile, 'do not replace me');
|
||||
const out = path.join(blockingFile, 'cannot-write.html');
|
||||
|
||||
const result = run(['deliver', 'architecture', input, out, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.stage, 'prepare');
|
||||
assert.match(failure.error, /Could not create delivery directory/);
|
||||
assert.equal(fs.readFileSync(blockingFile, 'utf8'), 'do not replace me');
|
||||
});
|
||||
|
||||
test('cli: check validates rendered html', () => {
|
||||
const out = path.join(tmp, 'workflow-check.html');
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
assert.equal(run(['render', 'workflow', input, out]).status, 0);
|
||||
|
||||
const result = run(['check', out]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /"ok": true/);
|
||||
});
|
||||
|
||||
test('cli: validate emits structured json without keeping html output', () => {
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const before = new Set(fs.readdirSync(tmp));
|
||||
const result = run(['validate', 'workflow', input, '--json']);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.type, 'workflow');
|
||||
assert.equal(parsed.checks.length, 9);
|
||||
assert.equal(parsed.composition.profile, 'showcase');
|
||||
assert.deepEqual(parsed.composition.summary, { errors: 0, warnings: 0 });
|
||||
assert.equal(parsed.composition.metrics.containerBorderRuns, 0);
|
||||
assert.equal(parsed.composition.metrics.ambiguousCorridors, 0);
|
||||
assert.deepEqual(new Set(fs.readdirSync(tmp)), before);
|
||||
});
|
||||
|
||||
test('cli: validate JSON exposes only the primary v1 column-capacity diagnostic', () => {
|
||||
const input = path.join(tmp, 'pinned-column-capacity.workflow.json');
|
||||
fs.writeFileSync(input, `${JSON.stringify({
|
||||
schema_version: 1,
|
||||
diagram_type: 'workflow',
|
||||
meta: {
|
||||
title: 'Pinned issue 126 diagnostic boundary',
|
||||
viewBox: [720, 400],
|
||||
legend: { mode: 'hidden' },
|
||||
},
|
||||
lanes: [{ id: 'main', label: 'Main' }],
|
||||
nodes: [
|
||||
{ id: 'a', lane: 'main', col: 1, type: 'backend', label: 'A' },
|
||||
{ id: 'b', lane: 'main', col: 2, type: 'backend', label: 'B' },
|
||||
],
|
||||
edges: [{
|
||||
id: 'ab',
|
||||
from: 'a',
|
||||
to: 'b',
|
||||
fromSide: 'top',
|
||||
toSide: 'top',
|
||||
via: [[220, 60], [300, 60]],
|
||||
}],
|
||||
}, null, 2)}\n`);
|
||||
|
||||
const result = run(['validate', 'workflow', input, '--json'], {
|
||||
env: { ...process.env, ARCHIFY_DIAGNOSTIC_FORMAT: 'json' },
|
||||
});
|
||||
|
||||
assert.equal(result.status, 1, result.stderr || result.stdout);
|
||||
assert.equal(result.stderr, '');
|
||||
const failure = JSON.parse(result.stdout);
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.command, 'validate');
|
||||
assert.equal(failure.stage, 'render');
|
||||
assert.equal(failure.type, 'workflow');
|
||||
assert.equal(failure.diagnostics.length, 1, JSON.stringify(failure.diagnostics, null, 2));
|
||||
const [primary] = failure.diagnostics;
|
||||
assert.equal(primary.code, 'workflow/column-capacity');
|
||||
assert.equal(primary.subject.edge, 'ab');
|
||||
assert.equal(primary.subject.fromCol, 1);
|
||||
assert.equal(primary.subject.toCol, 2);
|
||||
assert.ok(primary.supportedFixes.length > 0);
|
||||
assert.ok(failure.diagnostics.every(({ code }) => (
|
||||
code !== 'workflow/explicit-pin-conflict' && code !== 'workflow/viewbox-capacity'
|
||||
)));
|
||||
});
|
||||
|
||||
test('cli: --quality overrides the source profile for render, validate, and deliver', () => {
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const out = path.join(tmp, 'workflow-standard.html');
|
||||
const rendered = run(['render', 'workflow', input, out, '--quality', 'standard']);
|
||||
assert.equal(rendered.status, 0, rendered.stderr);
|
||||
assert.match(fs.readFileSync(out, 'utf8'), /data-quality-profile="standard"/);
|
||||
|
||||
const validated = run(['validate', 'workflow', input, '--quality=standard', '--json']);
|
||||
assert.equal(validated.status, 0, validated.stderr);
|
||||
assert.equal(JSON.parse(validated.stdout).composition.profile, 'standard');
|
||||
|
||||
const deliveredOut = path.join(tmp, 'workflow-delivered-standard.html');
|
||||
const delivered = run(['deliver', 'workflow', input, deliveredOut, '--quality=standard', '--json']);
|
||||
assert.equal(delivered.status, 0, delivered.stderr);
|
||||
assert.equal(JSON.parse(delivered.stdout).validation.compositionProfile, 'standard');
|
||||
});
|
||||
|
||||
test('cli: rejects an unknown quality profile', () => {
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const result = run(['validate', 'workflow', input, '--quality', 'hero']);
|
||||
assert.equal(result.status, 2);
|
||||
assert.match(result.stderr, /Expected standard or showcase/);
|
||||
});
|
||||
|
||||
test('cli: rejects a quality flag without a value', () => {
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
for (const args of [
|
||||
['validate', 'workflow', input, '--json', '--quality'],
|
||||
['validate', 'workflow', input, '--quality', '--json'],
|
||||
['validate', 'workflow', input, '--quality='],
|
||||
]) {
|
||||
const result = run(args);
|
||||
assert.equal(result.status, 2);
|
||||
assert.match(result.stderr, /--quality requires standard or showcase/);
|
||||
}
|
||||
});
|
||||
|
||||
test('cli: validate rejects unknown flags, layout-json assignment typos, and extra positionals', () => {
|
||||
const input = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const cases = [
|
||||
{
|
||||
args: ['validate', 'workflow', input, '--layout-json', '--bogus'],
|
||||
pattern: /Unknown validate option "--bogus"/,
|
||||
},
|
||||
{
|
||||
args: ['validate', 'workflow', input, '--layout-json=true'],
|
||||
pattern: /Unknown validate option "--layout-json=true"/,
|
||||
},
|
||||
{
|
||||
args: ['validate', 'workflow', input, '--layout-json=true', '--json'],
|
||||
pattern: /Unknown validate option "--layout-json=true"/,
|
||||
},
|
||||
{
|
||||
args: ['validate', 'workflow', input, 'unexpected-output.html', '--layout-json'],
|
||||
pattern: /Usage:/,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { args, pattern } of cases) {
|
||||
const result = run(args);
|
||||
assert.equal(result.status, 2, `${args.join(' ')}\n${result.stderr}\n${result.stdout}`);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.match(result.stderr, pattern);
|
||||
}
|
||||
});
|
||||
|
||||
test('cli: inspect emits architecture layout json', () => {
|
||||
const input = path.resolve(skillRoot, '../examples/archify-repo-grid.architecture.json');
|
||||
const result = run(['inspect', 'architecture', input]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.diagram_type, 'architecture');
|
||||
assert.equal(parsed.layout.mode, 'grid');
|
||||
assert.ok(parsed.components.length >= 5);
|
||||
assert.ok(parsed.connections.length >= 1);
|
||||
});
|
||||
|
||||
test('cli: inspect remains architecture-only while workflow uses validate --layout-json', () => {
|
||||
const input = path.join(skillRoot, 'examples', 'agent-tool-call.workflow.json');
|
||||
const result = run(['inspect', 'workflow', input]);
|
||||
assert.equal(result.status, 2);
|
||||
assert.match(result.stderr, /inspect is currently supported for architecture diagrams only/);
|
||||
assert.equal(result.stdout, '');
|
||||
});
|
||||
|
||||
test('cli: validate returns renderer errors for bad input', () => {
|
||||
const input = path.join(tmp, 'bad.workflow.json');
|
||||
const validateTmp = path.join(tmp, 'validate-failure-tmp');
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/agent-tool-call.workflow.json'), 'utf8'));
|
||||
doc.edges[0].to = 'ghost';
|
||||
fs.writeFileSync(input, JSON.stringify(doc));
|
||||
fs.mkdirSync(validateTmp);
|
||||
|
||||
const result = run(['validate', 'workflow', input], {
|
||||
env: { ...process.env, TMPDIR: validateTmp },
|
||||
});
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /unknown target "ghost"/);
|
||||
assert.deepEqual(fs.readdirSync(validateTmp), []);
|
||||
});
|
||||
|
||||
test('cli: validate rejects an unknown type without leaking a temp directory', () => {
|
||||
const validateTmp = path.join(tmp, 'validate-unknown-type-tmp');
|
||||
fs.mkdirSync(validateTmp);
|
||||
|
||||
const result = run(['validate', 'unknown', 'ignored.json'], {
|
||||
env: {
|
||||
...process.env,
|
||||
TMPDIR: validateTmp,
|
||||
TMP: validateTmp,
|
||||
TEMP: validateTmp,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, 2);
|
||||
assert.match(result.stderr, /Unknown diagram type "unknown"/);
|
||||
assert.deepEqual(fs.readdirSync(validateTmp), []);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(here, '../..');
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('showcase intake requires reproducible proof, redaction, and explicit publication permission', () => {
|
||||
const template = read('.github/ISSUE_TEMPLATE/showcase.yml');
|
||||
|
||||
for (const field of [
|
||||
'id: diagram_type',
|
||||
'id: archify_version',
|
||||
'id: agent',
|
||||
'id: model',
|
||||
'id: prompt',
|
||||
'id: source_json',
|
||||
'id: artifact',
|
||||
'id: validation_receipt',
|
||||
'id: visual_review',
|
||||
'id: sensitive_data',
|
||||
'id: sharing_rights',
|
||||
'id: public_permission',
|
||||
]) {
|
||||
assert.match(template, new RegExp(field), field);
|
||||
}
|
||||
assert.match(template, /access tokens/i);
|
||||
assert.match(template, /personal or customer data/i);
|
||||
assert.match(template, /repository, documentation, gallery, and project website/i);
|
||||
assert.match(template, /required:\s*true/g);
|
||||
|
||||
const submissionUrl = 'https://github.com/tt-a1i/archify/issues/new?template=showcase.yml';
|
||||
for (const readme of ['README.md', 'README_EN.md', 'README_ZH.md']) {
|
||||
assert.match(read(readme), new RegExp(submissionUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), `${readme}: direct showcase link`);
|
||||
}
|
||||
});
|
||||
|
||||
test('bug intake captures a minimal deterministic reproduction before visual diagnosis', () => {
|
||||
const template = read('.github/ISSUE_TEMPLATE/bug-report.yml');
|
||||
|
||||
for (const field of [
|
||||
'id: archify_version',
|
||||
'id: install_method',
|
||||
'id: diagram_type',
|
||||
'id: command',
|
||||
'id: minimal_json',
|
||||
'id: validation_receipt',
|
||||
'id: expected',
|
||||
'id: actual',
|
||||
'id: environment',
|
||||
'id: sensitive_data',
|
||||
]) {
|
||||
assert.match(template, new RegExp(field), field);
|
||||
}
|
||||
assert.ok(
|
||||
template.indexOf('id: validation_receipt') < template.indexOf('id: screenshot'),
|
||||
'deterministic evidence should be requested before optional visual evidence',
|
||||
);
|
||||
});
|
||||
|
||||
test('contributor and pull-request guides keep proof changes reproducible and stability-first', () => {
|
||||
const contributing = read('CONTRIBUTING.md');
|
||||
const pullRequest = read('.github/PULL_REQUEST_TEMPLATE.md');
|
||||
|
||||
for (const required of [
|
||||
'.github/ISSUE_TEMPLATE/showcase.yml',
|
||||
'.github/ISSUE_TEMPLATE/bug-report.yml',
|
||||
'npm test',
|
||||
'node scripts/build-gallery.mjs docs',
|
||||
'Do not include secrets',
|
||||
'Agent-first',
|
||||
'diagnostics[]',
|
||||
'Start from the latest `main`',
|
||||
'tracked-only, symlink-safe',
|
||||
'is **skipped**, not passed',
|
||||
]) {
|
||||
assert.match(contributing, new RegExp(required.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), required);
|
||||
}
|
||||
assert.match(
|
||||
contributing,
|
||||
/(?:^|\n)scripts\/build-zip\.sh \/tmp\/archify-contrib\.zip(?:\n|$)/,
|
||||
'the archive builder must be documented as an executable shell script',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
contributing,
|
||||
/\bnode\s+scripts\/build-zip\.sh\b/,
|
||||
'the shell archive builder must not be documented as a Node.js command',
|
||||
);
|
||||
for (const required of [
|
||||
'Stability impact',
|
||||
'Tests run',
|
||||
'Generated artifacts',
|
||||
'Visual evidence',
|
||||
'No unrelated changes',
|
||||
]) {
|
||||
assert.match(pullRequest, new RegExp(required), required);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(skillRoot, '..');
|
||||
const cursorCommand = 'npx -y skills add tt-a1i/archify --skill archify --agent cursor --global --copy --yes';
|
||||
|
||||
test('Cursor onboarding stays explicit, bilingual, and backed by the same Skill', () => {
|
||||
const english = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8');
|
||||
const englishMirror = fs.readFileSync(path.join(repoRoot, 'README_EN.md'), 'utf8');
|
||||
const chinese = fs.readFileSync(path.join(repoRoot, 'README_ZH.md'), 'utf8');
|
||||
const start = fs.readFileSync(path.join(repoRoot, 'docs', 'start.html'), 'utf8');
|
||||
const landing = fs.readFileSync(path.join(repoRoot, 'docs', 'index.html'), 'utf8');
|
||||
|
||||
assert.equal(english, englishMirror, 'English README mirrors must stay synchronized');
|
||||
assert.match(english, /Cursor, Claude Code, Codex CLI, and OpenCode/);
|
||||
assert.match(chinese, /Cursor、Claude Code、Codex CLI 和 OpenCode/);
|
||||
for (const surface of [english, chinese, landing]) assert.ok(surface.includes(cursorCommand));
|
||||
for (const surface of [english, chinese, start, landing]) {
|
||||
assert.doesNotMatch(surface, /skills use[^\n<]*--agent cursor/);
|
||||
assert.doesNotMatch(surface, /~\/\.cursor\/skills\/archify/);
|
||||
assert.doesNotMatch(surface, /all Cursor models|every Cursor model/i);
|
||||
}
|
||||
|
||||
assert.match(start, /data-agent="cursor">Cursor<\/button>/);
|
||||
assert.match(start, /data-agent="codex">Codex<\/button>/);
|
||||
assert.match(start, /data-agent="claude-code">Claude Code<\/button>/);
|
||||
assert.match(start, /data-agent="opencode">OpenCode<\/button>/);
|
||||
assert.match(start, /KNOWN_AGENTS\.has\(requestedAgent\)/);
|
||||
assert.match(start, /same Skill/);
|
||||
assert.match(start, /同一份 Skill/);
|
||||
assert.doesNotMatch(start, /vendor-specific (?:renderer|schema|skill)/i);
|
||||
});
|
||||
|
||||
test('the zero-dependency archive works from the canonical Cursor-visible agent path', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-cursor-package-'));
|
||||
const agentSkills = path.join(tmp, '.agents', 'skills');
|
||||
try {
|
||||
fs.mkdirSync(agentSkills, { recursive: true });
|
||||
execFileSync('unzip', ['-q', path.join(repoRoot, 'archify.zip'), '-d', agentSkills]);
|
||||
const installed = path.join(agentSkills, 'archify');
|
||||
const cli = path.join(installed, 'bin', 'archify.mjs');
|
||||
const doctor = execFileSync(process.execPath, [cli, 'doctor'], { encoding: 'utf8' });
|
||||
assert.match(doctor, /Archify is ready\./);
|
||||
|
||||
const fixtures = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
for (const [type, fixture] of Object.entries(fixtures)) {
|
||||
const output = execFileSync(process.execPath, [
|
||||
cli,
|
||||
'validate',
|
||||
type,
|
||||
path.join(installed, 'examples', fixture),
|
||||
'--json',
|
||||
], { encoding: 'utf8' });
|
||||
const receipt = JSON.parse(output);
|
||||
assert.equal(receipt.ok, true, `${type}: installed package validation failed`);
|
||||
assert.equal(receipt.type, type);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
// Installation contract: the shipped skill performs full JSON Schema
|
||||
// validation without node_modules. AJV is a build-time dependency only; its
|
||||
// standalone validators are committed and included in the distribution.
|
||||
//
|
||||
// A malformed-but-JSON-legal document must EXIT
|
||||
// NON-ZERO with a friendly message — never crash (TypeError / is not a
|
||||
// function) and never write NaN/undefined into the HTML. A random VALID
|
||||
// perturbation of an example must still render (exit 0, no NaN).
|
||||
//
|
||||
// node --test test/*.test.mjs
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-degraded-'));
|
||||
|
||||
const EXAMPLES = {
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
architecture: 'web-app.architecture.json',
|
||||
};
|
||||
|
||||
const installedRoot = path.join(tmp, 'installed-skill');
|
||||
fs.cpSync(skillRoot, installedRoot, {
|
||||
recursive: true,
|
||||
filter(source) {
|
||||
const rel = path.relative(skillRoot, source);
|
||||
return rel !== 'node_modules' && !rel.startsWith(`node_modules${path.sep}`)
|
||||
&& rel !== 'test' && !rel.startsWith(`test${path.sep}`)
|
||||
// The validator freshness test creates and removes this fixture inside
|
||||
// skillRoot while the test runner executes files concurrently. Exclude
|
||||
// it from the installed-skill copy to avoid a copy/remove race.
|
||||
&& !rel.startsWith('.validator-check-');
|
||||
},
|
||||
});
|
||||
|
||||
function render(mode, doc) {
|
||||
const input = path.join(tmp, `in-${Math.random().toString(36).slice(2)}.json`);
|
||||
const out = path.join(tmp, 'out.html');
|
||||
fs.writeFileSync(input, JSON.stringify(doc));
|
||||
if (fs.existsSync(out)) fs.rmSync(out);
|
||||
let code = 0;
|
||||
let stderr = '';
|
||||
try {
|
||||
execFileSync('node', [path.join(installedRoot, `renderers/${mode}/render-${mode}.mjs`), input, out],
|
||||
{ stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
} catch (err) {
|
||||
code = err.status ?? 1;
|
||||
stderr = String(err.stderr || '');
|
||||
}
|
||||
const html = fs.existsSync(out) ? fs.readFileSync(out, 'utf8') : '';
|
||||
return { code, stderr, html };
|
||||
}
|
||||
|
||||
function assertFriendlyFailure(mode, doc, label) {
|
||||
const { code, stderr, html } = render(mode, doc);
|
||||
assert.notEqual(code, 0, `${label}: expected non-zero exit`);
|
||||
assert.doesNotMatch(stderr, /TypeError|RangeError|is not a function|Cannot read/,
|
||||
`${label}: crashed instead of reporting friendly error:\n${stderr}`);
|
||||
assert.doesNotMatch(html, /NaN|undefined/, `${label}: wrote NaN/undefined into HTML`);
|
||||
}
|
||||
|
||||
// ---- type-wrong-but-JSON-legal documents per mode ----
|
||||
const ARRAY_FIELDS = {
|
||||
workflow: ['lanes', 'phases', 'groups', 'mainPath', 'nodes', 'edges', 'cards'],
|
||||
sequence: ['participants', 'messages', 'segments', 'activations', 'cards'],
|
||||
dataflow: ['stages', 'nodes', 'flows', 'cards'],
|
||||
lifecycle: ['lanes', 'states', 'transitions', 'cards'],
|
||||
architecture: ['components', 'boundaries', 'connections', 'cards'],
|
||||
};
|
||||
|
||||
for (const [mode, fields] of Object.entries(ARRAY_FIELDS)) {
|
||||
for (const field of fields) {
|
||||
test(`${mode}: ${field} as a string fails friendly`, () => {
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES[mode]), 'utf8'));
|
||||
if (!(field in doc)) return; // optional field absent in this example
|
||||
doc[field] = 'oops';
|
||||
assertFriendlyFailure(mode, doc, `${mode}.${field}`);
|
||||
});
|
||||
}
|
||||
test(`${mode}: scalar meta fails friendly`, () => {
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES[mode]), 'utf8'));
|
||||
doc.meta = 42;
|
||||
assertFriendlyFailure(mode, doc, `${mode}.meta`);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- missing-coordinate fields must not yield NaN coordinates ----
|
||||
test('workflow: node missing col never writes NaN', () => {
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES.workflow), 'utf8'));
|
||||
delete doc.nodes[0].col;
|
||||
assertFriendlyFailure('workflow', doc, 'workflow node no col');
|
||||
});
|
||||
test('lifecycle: state missing col never writes NaN', () => {
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES.lifecycle), 'utf8'));
|
||||
delete doc.states[0].col;
|
||||
assertFriendlyFailure('lifecycle', doc, 'lifecycle state no col');
|
||||
});
|
||||
|
||||
// ---- property test: deterministic VALID perturbations always render ----
|
||||
// Seeded PRNG (no Math.random — keeps the test reproducible across runs).
|
||||
function mulberry32(seed) {
|
||||
return function next() {
|
||||
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
test('property: shuffling node/state order still renders (order-independence)', () => {
|
||||
for (const mode of ['workflow', 'dataflow', 'lifecycle']) {
|
||||
const arrKey = mode === 'lifecycle' ? 'states' : 'nodes';
|
||||
for (let seed = 1; seed <= 8; seed += 1) {
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES[mode]), 'utf8'));
|
||||
const rng = mulberry32(seed);
|
||||
// Fisher–Yates with the seeded PRNG.
|
||||
const a = doc[arrKey];
|
||||
for (let i = a.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
const { code, html } = render(mode, doc);
|
||||
assert.equal(code, 0, `${mode} seed ${seed}: valid shuffle should render (exit 0)`);
|
||||
assert.doesNotMatch(html, /NaN|undefined>/, `${mode} seed ${seed}: NaN in output`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('installed skill rejects unknown fields without node_modules', () => {
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES.workflow), 'utf8'));
|
||||
doc.nodes[0].colour = 'cyan';
|
||||
const { code, stderr } = render('workflow', doc);
|
||||
assert.notEqual(code, 0);
|
||||
assert.match(stderr, /workflow schema validation failed/);
|
||||
assert.match(stderr, /\/nodes\/0 \(id\/label: "user"\) must NOT have additional properties/);
|
||||
assert.match(stderr, /"additionalProperty":"colour"/);
|
||||
assert.doesNotMatch(stderr, /ajv is not installed|skipping JSON-schema validation/);
|
||||
});
|
||||
|
||||
for (const mode of Object.keys(EXAMPLES)) {
|
||||
test(`installed skill retains full ${mode} schema without node_modules`, () => {
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES[mode]), 'utf8'));
|
||||
doc.unknownField = true;
|
||||
const { code, stderr } = render(mode, doc);
|
||||
assert.notEqual(code, 0);
|
||||
assert.match(stderr, new RegExp(`${mode} schema validation failed`));
|
||||
assert.match(stderr, /"additionalProperty":"unknownField"/);
|
||||
});
|
||||
}
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skill = readFileSync(path.join(here, '..', 'SKILL.md'), 'utf8');
|
||||
const delivery = readFileSync(path.join(here, '..', 'references', 'delivery-contract.md'), 'utf8');
|
||||
|
||||
test('skill requires a bounded and truthful perceptual delivery receipt', () => {
|
||||
assert.match(delivery, /visual_review: passed/);
|
||||
assert.match(delivery, /visual_review: skipped \(image reader unavailable\)/);
|
||||
assert.match(delivery, /correction_rounds: [0-2]/);
|
||||
assert.match(delivery, /maximum of two focused correction rounds/i);
|
||||
assert.match(delivery, /never report `visual_review: passed` without inspecting/i);
|
||||
});
|
||||
|
||||
test('skill uses atomic verified delivery for the final artifact', () => {
|
||||
assert.match(delivery, /archify\.mjs deliver <type>/);
|
||||
assert.match(delivery, /same-directory candidate/i);
|
||||
assert.match(delivery, /only replaces the target after.*artifact checks pass/i);
|
||||
assert.match(delivery, /never claim that the deterministic receipt includes visual review/i);
|
||||
});
|
||||
|
||||
test('skill keeps optional opening behind the verified commit and outside automation', () => {
|
||||
assert.match(delivery, /Add `--open` only when the user wants an immediate local preview/);
|
||||
assert.match(delivery, /runs after that atomic commit/);
|
||||
assert.match(delivery, /Keep it off for CI, unattended agents, and non-interactive environments/);
|
||||
assert.match(delivery, /never invokes an opener/);
|
||||
assert.match(delivery, /status proves only whether the local opener invocation succeeded/);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { findChrome, runVisualCheck } from '../bin/visual-check.mjs';
|
||||
import { DESKTOP_READABILITY_VIEWPORT, MIN_PROJECTED_NODE_TEXT_PX } from '../renderers/shared/desktop-readability.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const chromePath = process.env.ARCHIFY_CHROME ? findChrome() : null;
|
||||
|
||||
test('production showcase is readable in the real 1440 by 900 adaptive reader', {
|
||||
skip: chromePath ? false : 'Set ARCHIFY_CHROME to run the real browser regression.',
|
||||
}, async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-desktop-reader-'));
|
||||
const artifact = path.join(tmp, 'production-deployment.html');
|
||||
try {
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, 'bin', 'archify.mjs'),
|
||||
'render',
|
||||
'architecture',
|
||||
path.join(skillRoot, 'examples', 'production-deployment.architecture.json'),
|
||||
artifact,
|
||||
'--quality',
|
||||
'showcase',
|
||||
], { cwd: skillRoot, encoding: 'utf8' });
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
const result = await runVisualCheck({ artifactPath: artifact, chromePath });
|
||||
assert.equal(result.exitCode, 0, `attempt ${attempt}: ${JSON.stringify(result.receipt, null, 2)}`);
|
||||
assert.equal(result.receipt.readability.status, 'pass', `attempt ${attempt}: ${JSON.stringify(result.receipt, null, 2)}`);
|
||||
const desktop = result.receipt.readability.viewports.find(({ width, height }) => (
|
||||
width === DESKTOP_READABILITY_VIEWPORT.width && height === DESKTOP_READABILITY_VIEWPORT.height
|
||||
));
|
||||
const darkDesktop = result.receipt.captures.screenshots.find(({ width, height, theme }) => (
|
||||
width === DESKTOP_READABILITY_VIEWPORT.width
|
||||
&& height === DESKTOP_READABILITY_VIEWPORT.height
|
||||
&& theme === 'dark'
|
||||
));
|
||||
for (const observation of [desktop, darkDesktop]) {
|
||||
assert.ok(observation);
|
||||
assert.equal(observation.readerWidth, 960);
|
||||
assert.equal(observation.diagramWidth, 930);
|
||||
assert.ok(observation.minimumProjectedNodeTextPx >= MIN_PROJECTED_NODE_TEXT_PX);
|
||||
assert.equal(observation.minimumProjectedNodeTextDetail, 'boundary');
|
||||
assert.equal(observation.minimumProjectedNodeText, 'AWS eu-west-1 / disaster recovery');
|
||||
assert.equal(observation.readabilityOk, true);
|
||||
assert.equal(observation.scrollHeight, DESKTOP_READABILITY_VIEWPORT.height);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-diagram-guide-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all typed renderers inherit one viewer-only Diagram Guide', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /id="diagram-guide" hidden role="dialog" aria-modal="false" aria-labelledby="diagram-guide-title"/, mode);
|
||||
assert.match(html, /id="btn-diagram-guide"[^>]+aria-label="Open diagram guide"[^>]+aria-haspopup="dialog"[^>]+aria-expanded="false"/, mode);
|
||||
assert.match(html, /Archify\.guide = \(function \(\)/, mode);
|
||||
assert.match(html, /Diagram Guide — a factual command deck over existing interactions/, mode);
|
||||
assert.doesNotMatch(canonicalSvg(html), /diagram-guide|Archify\.guide|Explore this system/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('Diagram Guide reports compiled semantic facts and honest story availability', () => {
|
||||
const html = render('workflow', CASES.workflow);
|
||||
assert.match(html, /svg\.querySelectorAll\('\[data-node-id\]'\)\.length/);
|
||||
assert.match(html, /svg\.querySelectorAll\('\[data-edge-from\]\[data-edge-to\]'\)/);
|
||||
assert.match(html, /edge\.getAttribute\('data-edge-key'\)/);
|
||||
assert.match(html, /return Archify\.guidedViews && Number\(Archify\.guidedViews\.count\) \|\| 0/);
|
||||
assert.match(html, /storyBtn\.disabled = views === 0/);
|
||||
assert.match(html, /viewerCount\('viewer\.guide\.fact\.view', views\)/);
|
||||
assert.match(html, /viewerText\('viewer\.guide\.story\.unavailable'\)/);
|
||||
});
|
||||
|
||||
test('Diagram Guide delegates its task rows to existing production interactions', () => {
|
||||
const html = render('architecture', CASES.architecture);
|
||||
assert.match(html, /if \(action === 'find'\) return Archify\.finder\.open\(\)/);
|
||||
assert.match(html, /if \(action === 'route'\) return Archify\.routeProbe\.begin\(\{ focusNode: true \}\)/);
|
||||
assert.match(html, /if \(action === 'map'\) return Archify\.radar\.open\(\)/);
|
||||
assert.match(html, /if \(action === 'story'\) return Archify\.guidedViews\.play\(\)/);
|
||||
assert.match(html, /if \(action === 'present'\) return Archify\.presentation\.enter\(\)/);
|
||||
assert.match(html, /if \(action === 'export'\) return Archify\.exportMenu\.open\(\)/);
|
||||
assert.match(html, /if \(action === 'theme'\) return Archify\.theme\.toggle\(\)/);
|
||||
assert.match(html, /if \(action === 'reset'\) return Archify\.view\.reset\(\)/);
|
||||
assert.match(html, /Archify\.guidedViews\.pause\(\)/);
|
||||
assert.match(html, /Archify\.finder\.close\(\{ restoreFocus: false \}\)/);
|
||||
assert.match(html, /Archify\.radar\.close\(\{ restoreFocus: false \}\)/);
|
||||
assert.match(html, /event\.stopPropagation\(\);[\s\S]+execute\(button\.getAttribute\('data-guide-action'\)\)/);
|
||||
});
|
||||
|
||||
test('Diagram Guide is keyboard-first, mobile-contained, motion-safe, and embed-clean', () => {
|
||||
const html = render('sequence', CASES.sequence);
|
||||
assert.match(html, /e\.key === '\?'/);
|
||||
assert.match(html, /Archify\.guide\.toggle\(\)/);
|
||||
assert.match(html, /event\.key === 'ArrowRight'/);
|
||||
assert.match(html, /event\.key === 'ArrowDown'/);
|
||||
assert.match(html, /event\.key === 'Home'/);
|
||||
assert.match(html, /event\.key === 'End'/);
|
||||
assert.match(html, /event\.key === 'Escape' \|\| event\.key === '\?'/);
|
||||
assert.match(html, /html\.setAttribute\('data-guide-open', 'true'\)/);
|
||||
assert.match(html, /html\.getAttribute\('data-guide-open'\) === 'true'/);
|
||||
assert.match(html, /html\[data-embed="true"\] \.diagram-guide/);
|
||||
assert.match(html, /data-wide-diagram="true"\] \.diagram-guide/);
|
||||
assert.match(html, /\.route-probe\[data-guide-open="true"\]/);
|
||||
assert.match(html, /@media \(prefers-reduced-motion: reduce\)[\s\S]+\.diagram-guide/);
|
||||
assert.match(html, /class="diagram-guide no-print"/);
|
||||
assert.doesNotMatch(canonicalSvg(html), /data-guide-open|diagram-guide/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,209 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
deploymentOwnershipDiagnostics,
|
||||
validateEngineeringProfile,
|
||||
} from '../renderers/shared/engineering-profiles.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const cli = path.join(skillRoot, 'bin', 'archify.mjs');
|
||||
const examplePath = path.join(skillRoot, 'examples', 'production-deployment.architecture.json');
|
||||
const example = JSON.parse(fs.readFileSync(examplePath, 'utf8'));
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function validateJson(input, output) {
|
||||
return spawnSync(process.execPath, [cli, 'validate', 'architecture', input, '--json'], {
|
||||
cwd: path.dirname(output),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
test('deployment ownership profile passes the checked production example and stays opt-in', () => {
|
||||
assert.equal(example.meta.engineering_profile, 'deployment-ownership');
|
||||
assert.deepEqual(deploymentOwnershipDiagnostics(example), []);
|
||||
assert.doesNotThrow(() => validateEngineeringProfile('architecture', example));
|
||||
|
||||
const ordinary = clone(example);
|
||||
delete ordinary.meta.engineering_profile;
|
||||
ordinary.boundaries = [];
|
||||
ordinary.components.forEach((component) => { delete component.tag; });
|
||||
assert.doesNotThrow(() => validateEngineeringProfile('architecture', ordinary));
|
||||
});
|
||||
|
||||
test('deployment ownership profile reports exact owners, scopes, state, and crossing mechanisms', () => {
|
||||
const candidate = clone(example);
|
||||
delete candidate.components.find((component) => component.id === 'edge').tag;
|
||||
candidate.boundaries.find((boundary) => boundary.label.includes('us-east-1')).wraps =
|
||||
candidate.boundaries.find((boundary) => boundary.label.includes('us-east-1')).wraps.filter((id) => id !== 'edge');
|
||||
candidate.boundaries.find((boundary) => boundary.label === 'private application network').wraps =
|
||||
candidate.boundaries.find((boundary) => boundary.label === 'private application network').wraps.filter((id) => id !== 'redis');
|
||||
const crossing = candidate.connections.find((connection) => connection.from === 'gateway' && connection.to === 'api_a');
|
||||
crossing.label = '';
|
||||
|
||||
const diagnostics = deploymentOwnershipDiagnostics(candidate);
|
||||
const codes = new Set(diagnostics.map((entry) => entry.code));
|
||||
assert.ok(codes.has('engineering/deployment-owner-missing'));
|
||||
assert.ok(codes.has('engineering/deployment-region-scope'));
|
||||
assert.ok(codes.has('engineering/deployment-private-state'));
|
||||
assert.ok(codes.has('engineering/deployment-crossing-mechanism'));
|
||||
|
||||
const boundaryDiagnostic = diagnostics.find((entry) => entry.code === 'engineering/deployment-crossing-mechanism');
|
||||
assert.equal(boundaryDiagnostic.subject.collection, 'connections');
|
||||
assert.equal(boundaryDiagnostic.evidence.from, 'gateway');
|
||||
assert.equal(boundaryDiagnostic.evidence.to, 'api_a');
|
||||
assert.ok(boundaryDiagnostic.evidence.crossedBoundaries.some((boundary) => boundary.kind === 'security-group'));
|
||||
assert.deepEqual(boundaryDiagnostic.supportedFixes, [
|
||||
`set /connections/${boundaryDiagnostic.subject.index}/label to the real cross-boundary mechanism`,
|
||||
]);
|
||||
});
|
||||
|
||||
test('deployment ownership profile requires both region and private boundary kinds', () => {
|
||||
const candidate = clone(example);
|
||||
candidate.boundaries = candidate.boundaries.filter((boundary) => boundary.kind === 'region');
|
||||
const diagnostics = deploymentOwnershipDiagnostics(candidate);
|
||||
assert.ok(diagnostics.some((entry) => entry.code === 'engineering/deployment-boundary-kind'
|
||||
&& entry.evidence.requiredKind === 'security-group'));
|
||||
});
|
||||
|
||||
test('deployment ownership profile rejects ambiguous regions and cross-region private groups', () => {
|
||||
const candidate = clone(example);
|
||||
const secondRegion = candidate.boundaries.find((boundary) => boundary.label.includes('eu-west-1'));
|
||||
secondRegion.wraps.push('api_a');
|
||||
const diagnostics = deploymentOwnershipDiagnostics(candidate);
|
||||
assert.ok(diagnostics.some((entry) => entry.code === 'engineering/deployment-region-ambiguous'
|
||||
&& entry.subject.id === 'api_a'));
|
||||
assert.ok(diagnostics.some((entry) => entry.code === 'engineering/deployment-private-region-consistency'
|
||||
&& entry.subject.collection === 'boundaries'));
|
||||
});
|
||||
|
||||
test('deployment ownership crossing math follows authored membership instead of geometry or labels', () => {
|
||||
const cases = [
|
||||
['outside to region', 'clients', 'edge'],
|
||||
['region to region', 'postgres', 'replica'],
|
||||
['public to private', 'gateway', 'api_a'],
|
||||
['private to public', 'worker', 'audit'],
|
||||
];
|
||||
for (const [name, from, to] of cases) {
|
||||
const candidate = clone(example);
|
||||
candidate.connections.forEach((connection) => {
|
||||
connection.label ||= 'same-scope relation';
|
||||
});
|
||||
const connection = candidate.connections.find((entry) => entry.from === from && entry.to === to);
|
||||
connection.label = '';
|
||||
const crossings = deploymentOwnershipDiagnostics(candidate)
|
||||
.filter((diagnostic) => diagnostic.code === 'engineering/deployment-crossing-mechanism');
|
||||
assert.equal(crossings.length, 1, name);
|
||||
assert.equal(crossings[0].evidence.from, from, name);
|
||||
assert.equal(crossings[0].evidence.to, to, name);
|
||||
}
|
||||
|
||||
const sameScope = clone(example);
|
||||
sameScope.connections.forEach((connection) => {
|
||||
connection.label ||= 'same-scope relation';
|
||||
});
|
||||
sameScope.connections.find((connection) => connection.from === 'api_a' && connection.to === 'redis').label = '';
|
||||
sameScope.connections.push({ from: 'api_a', to: 'api_a', label: '' });
|
||||
assert.ok(!deploymentOwnershipDiagnostics(sameScope)
|
||||
.some((diagnostic) => diagnostic.code === 'engineering/deployment-crossing-mechanism'));
|
||||
});
|
||||
|
||||
test('other diagram modes reject the architecture-only engineering profile', () => {
|
||||
const fixtures = [
|
||||
['workflow', 'agent-tool-call.workflow.json'],
|
||||
['sequence', 'cache-miss-request.sequence.json'],
|
||||
['dataflow', 'product-analytics.dataflow.json'],
|
||||
['lifecycle', 'agent-run.lifecycle.json'],
|
||||
];
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-engineering-schema-'));
|
||||
try {
|
||||
for (const [mode, fixture] of fixtures) {
|
||||
const candidate = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', fixture), 'utf8'));
|
||||
candidate.meta.engineering_profile = 'deployment-ownership';
|
||||
const input = path.join(tmp, `${mode}.json`);
|
||||
fs.writeFileSync(input, JSON.stringify(candidate));
|
||||
const result = spawnSync(process.execPath, [cli, 'validate', mode, input, '--json'], {
|
||||
cwd: tmp,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.notEqual(result.status, 0, mode);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.ok(receipt.diagnostics.some((diagnostic) => diagnostic.code === 'schema/additionalProperties'), mode);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('validate and deliver expose one truthful engineering-profile receipt', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-engineering-profile-'));
|
||||
try {
|
||||
const invalidPath = path.join(tmp, 'invalid.architecture.json');
|
||||
const invalid = clone(example);
|
||||
const crossing = invalid.connections.find((connection) => connection.from === 'gateway' && connection.to === 'api_a');
|
||||
crossing.label = '';
|
||||
fs.writeFileSync(invalidPath, JSON.stringify(invalid, null, 2));
|
||||
|
||||
const preservedOutput = path.join(tmp, 'preserved.html');
|
||||
const preservedBytes = Buffer.from('last known good deployment');
|
||||
fs.writeFileSync(preservedOutput, preservedBytes);
|
||||
const failedDelivery = spawnSync(process.execPath, [
|
||||
cli, 'deliver', 'architecture', invalidPath, preservedOutput, '--json',
|
||||
], { cwd: tmp, encoding: 'utf8' });
|
||||
assert.notEqual(failedDelivery.status, 0);
|
||||
assert.equal(fs.readFileSync(preservedOutput).equals(preservedBytes), true);
|
||||
|
||||
const failed = validateJson(invalidPath, path.join(tmp, 'unused.html'));
|
||||
assert.notEqual(failed.status, 0);
|
||||
assert.equal(failed.stderr, '');
|
||||
const failure = JSON.parse(failed.stdout);
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.stage, 'render');
|
||||
assert.ok(failure.diagnostics.some((entry) => entry.code === 'engineering/deployment-crossing-mechanism'));
|
||||
|
||||
const validated = spawnSync(process.execPath, [cli, 'validate', 'architecture', examplePath, '--json'], {
|
||||
cwd: tmp,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(validated.status, 0, validated.stderr);
|
||||
assert.equal(JSON.parse(validated.stdout).engineeringProfile, 'deployment-ownership');
|
||||
|
||||
const output = path.join(tmp, 'deployment.html');
|
||||
const delivered = spawnSync(process.execPath, [cli, 'deliver', 'architecture', examplePath, output, '--json'], {
|
||||
cwd: tmp,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(delivered.status, 0, delivered.stderr);
|
||||
const receipt = JSON.parse(delivered.stdout);
|
||||
assert.equal(receipt.validation.engineeringProfile, 'deployment-ownership');
|
||||
assert.match(fs.readFileSync(output, 'utf8'), /data-engineering-profile="deployment-ownership"/);
|
||||
|
||||
const secondOutput = path.join(tmp, 'deployment-second.html');
|
||||
const repeated = spawnSync(process.execPath, [
|
||||
cli, 'deliver', 'architecture', examplePath, secondOutput, '--json',
|
||||
], { cwd: tmp, encoding: 'utf8' });
|
||||
assert.equal(repeated.status, 0, repeated.stderr);
|
||||
const digest = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
assert.equal(digest(output), digest(secondOutput));
|
||||
|
||||
const ordinaryInput = path.join(skillRoot, 'examples', 'web-app.architecture.json');
|
||||
const ordinaryOutput = path.join(tmp, 'ordinary.html');
|
||||
const ordinary = spawnSync(process.execPath, [
|
||||
cli, 'render', 'architecture', ordinaryInput, ordinaryOutput,
|
||||
], { cwd: tmp, encoding: 'utf8' });
|
||||
assert.equal(ordinary.status, 0, ordinary.stderr);
|
||||
assert.doesNotMatch(fs.readFileSync(ordinaryOutput, 'utf8'), /data-engineering-profile=/);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-finder-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function svg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all typed renderers ship the same geometry-neutral node finder', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /id="btn-node-finder"[^>]+aria-label="Find a node"[^>]+aria-haspopup="dialog"/, mode);
|
||||
assert.match(html, /id="node-finder" hidden role="dialog" aria-modal="false"/, mode);
|
||||
assert.match(html, /id="node-finder-input" type="search"/, mode);
|
||||
assert.match(html, /Archify\.finder = \(function \(\)/, mode);
|
||||
assert.match(html, /svg\.querySelectorAll\('\[data-node-id\]'\)/, mode);
|
||||
assert.doesNotMatch(svg(html), /node-finder|Archify\.finder|Find a node/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('finder searches semantic ids and labels, then delegates to focus and reveal', () => {
|
||||
const html = render('architecture', CASES.architecture);
|
||||
assert.match(html, /search: \(id \+ ' ' \+ label \+ ' ' \+ type \+ ' ' \+ sublabel \+ ' ' \+ context \+ ' ' \+ tag \+ ' ' \+ sourceSearch \+ ' ' \+ text\)\.toLowerCase\(\)/);
|
||||
assert.match(html, /item\.search\.indexOf\(query\) !== -1/);
|
||||
assert.match(html, /Archify\.guidedViews\.showAll\(\{ clearFocus: false, updateUrl: false \}\)/);
|
||||
assert.match(html, /Archify\.view\.reset\(\{ automatic: true \}\)/);
|
||||
assert.match(html, /Archify\.focus\.set\(id, \{ toggle: false \}\)/);
|
||||
assert.match(html, /Archify\.view\.reveal\(\[id\], \{ includeNeighbors: true, reason: 'finder' \}\)/);
|
||||
assert.match(html, /item\.node\.focus\(\{ preventScroll: true \}\)/);
|
||||
assert.match(html, /var key = from \+ '\\u0000' \+ to/);
|
||||
});
|
||||
|
||||
test('finder presents one focused search control and a structured result list', () => {
|
||||
const html = render('architecture', CASES.architecture);
|
||||
assert.match(html, /id="node-finder-input"[^>]+aria-label="Search diagram nodes"/);
|
||||
assert.match(html, /\.node-finder-search:focus-within\s*\{/);
|
||||
assert.match(html, /\.node-finder-input:focus-visible\s*\{\s*outline:\s*none;/);
|
||||
assert.match(html, /\.node-finder\s*\{[\s\S]*?display:\s*flex;[\s\S]*?max-height:\s*calc\(100% - 2rem\);/);
|
||||
assert.match(html, /\.node-finder-results\s*\{[\s\S]*?flex:\s*1 1 auto;[\s\S]*?min-height:\s*0;/);
|
||||
assert.match(html, /\.node-finder-result:not\(:last-child\)\s*\{/);
|
||||
assert.match(html, /context\.kind === 'focus'\s*\? viewerCount\('viewer\.finder\.link', item\.links\)/);
|
||||
assert.match(html, /\[viewerKindLabel\(item\.type\), item\.id, item\.sublabel, item\.tag\]/);
|
||||
assert.doesNotMatch(html, /\[item\.type, item\.context, item\.sublabel, item\.tag, item\.id\]/);
|
||||
assert.match(html, /viewerText\('viewer\.finder\.status\.filtered'/);
|
||||
});
|
||||
|
||||
test('finder becomes a contextual Route Probe endpoint picker without changing semantic focus', () => {
|
||||
const html = render('workflow', CASES.workflow);
|
||||
assert.match(html, /function resolveContext\(options\)/);
|
||||
assert.match(html, /Archify\.routeProbe\.finderContext\(\)/);
|
||||
assert.match(html, /context\.allowedIds\.indexOf\(item\.id\) !== -1/);
|
||||
assert.match(html, /context\.kind === 'route-source' \|\| context\.kind === 'route-target'/);
|
||||
assert.match(html, /Archify\.routeProbe\.choose\(id\)/);
|
||||
assert.match(html, /reason: 'route-pick'/);
|
||||
assert.match(html, /data-context="route-source"/);
|
||||
assert.match(html, /data-context="route-target"/);
|
||||
assert.match(html, /viewerText\('viewer\.finder\.result\.routeTarget'/);
|
||||
assert.match(html, /links: badge/);
|
||||
assert.match(html, /viewerText\('viewer\.finder\.status\.all'/);
|
||||
});
|
||||
|
||||
test('finder is keyboard accessible, mobile-pinned, and subordinate to embed mode', () => {
|
||||
const html = render('workflow', CASES.workflow);
|
||||
assert.match(html, /e\.key === '\/'/);
|
||||
assert.match(html, /Archify\.finder\.open\(\)/);
|
||||
assert.match(html, /event\.key === 'ArrowDown'/);
|
||||
assert.match(html, /event\.key === 'ArrowUp'/);
|
||||
assert.match(html, /event\.key === 'Escape'/);
|
||||
assert.match(html, /event\.stopPropagation\(\)/);
|
||||
assert.match(html, /Archify\.exportMenu\.isOpen\(\)\) Archify\.exportMenu\.close\(false\)/);
|
||||
assert.match(html, /data-wide-diagram="true"\] \.node-finder/);
|
||||
assert.match(html, /html\[data-embed="true"\] \.node-finder/);
|
||||
assert.match(html, /html\.getAttribute\('data-embed'\) === 'true'/);
|
||||
assert.match(html, /data-node-finder-trigger/);
|
||||
assert.match(html, /!event\.target\.closest\('\[data-node-finder-trigger\]'\)/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "workflow",
|
||||
"meta": {
|
||||
"title": "自动连线节点边框避让回归",
|
||||
"quality_profile": "showcase",
|
||||
"viewBox": [720, 360]
|
||||
},
|
||||
"lanes": [
|
||||
{ "id": "orchestrator", "label": "Orchestrator PTY" },
|
||||
{ "id": "runtime", "label": "Hive Runtime" }
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "team_send",
|
||||
"lane": "orchestrator",
|
||||
"col": 2,
|
||||
"type": "backend",
|
||||
"label": "team send 派单",
|
||||
"sublabel": "选择目标 Worker"
|
||||
},
|
||||
{
|
||||
"id": "inject",
|
||||
"lane": "runtime",
|
||||
"col": 1,
|
||||
"type": "security",
|
||||
"label": "校验并注入输入",
|
||||
"sublabel": "UI token → PTY stdin"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "stdin",
|
||||
"from": "inject",
|
||||
"to": "team_send",
|
||||
"label": "PTY stdin",
|
||||
"variant": "emphasis"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const originalRmSync = fs.rmSync.bind(fs);
|
||||
let injectedFailure = false;
|
||||
|
||||
fs.rmSync = function failMigrationCleanupOnce(target, options) {
|
||||
const isMigrationStagingDirectory = path.basename(String(target)).startsWith('.archify-migration-');
|
||||
if (!injectedFailure && isMigrationStagingDirectory) {
|
||||
injectedFailure = true;
|
||||
originalRmSync(target, options);
|
||||
const error = new Error('simulated migration cleanup failure');
|
||||
error.code = 'EPERM';
|
||||
throw error;
|
||||
}
|
||||
return originalRmSync(target, options);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"diagram_type": "workflow",
|
||||
"meta": {
|
||||
"title": "Sanitized custom widths",
|
||||
"legend": { "mode": "hidden" }
|
||||
},
|
||||
"lanes": [
|
||||
{ "id": "main", "label": "Main flow" }
|
||||
],
|
||||
"nodes": [
|
||||
{ "id": "compact", "lane": "main", "col": 1, "type": "frontend", "label": "In", "width": 32 },
|
||||
{ "id": "wide", "lane": "main", "col": 2, "type": "database", "label": "Sanitized downstream service", "width": 240 }
|
||||
],
|
||||
"edges": [
|
||||
{ "id": "compact-wide", "from": "compact", "to": "wide" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"diagram_type": "workflow",
|
||||
"meta": {
|
||||
"title": "Sanitized explicit route",
|
||||
"viewBox": [900, 420],
|
||||
"legend": { "mode": "hidden" }
|
||||
},
|
||||
"lanes": [
|
||||
{ "id": "source", "label": "Source" },
|
||||
{ "id": "target", "label": "Target" }
|
||||
],
|
||||
"nodes": [
|
||||
{ "id": "producer", "lane": "source", "col": 1, "type": "backend", "label": "Producer" },
|
||||
{ "id": "consumer", "lane": "target", "col": 1, "type": "backend", "label": "Consumer" }
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "producer-consumer",
|
||||
"from": "producer",
|
||||
"to": "consumer",
|
||||
"fromSide": "right",
|
||||
"toSide": "right",
|
||||
"route": "outside-right",
|
||||
"channelX": 720
|
||||
}
|
||||
]
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"diagram_type": "workflow",
|
||||
"meta": {
|
||||
"title": "Sanitized explicit viewBox",
|
||||
"viewBox": [1600, 520],
|
||||
"legend": { "mode": "hidden" }
|
||||
},
|
||||
"lanes": [
|
||||
{ "id": "main", "label": "Main flow" }
|
||||
],
|
||||
"nodes": [
|
||||
{ "id": "start", "lane": "main", "col": 0, "type": "frontend", "label": "Start" },
|
||||
{ "id": "finish", "lane": "main", "col": 5, "type": "backend", "label": "Finish" }
|
||||
],
|
||||
"edges": [
|
||||
{ "id": "start-finish", "from": "start", "to": "finish", "label": "complete" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"diagram_type": "workflow",
|
||||
"meta": {
|
||||
"title": "Sanitized semantic labels",
|
||||
"legend": { "mode": "hidden" }
|
||||
},
|
||||
"lanes": [
|
||||
{ "id": "main", "label": "Main flow" }
|
||||
],
|
||||
"nodes": [
|
||||
{ "id": "request", "lane": "main", "col": 3, "type": "frontend", "label": "Request" },
|
||||
{ "id": "result", "lane": "main", "col": 4, "type": "backend", "label": "Result" }
|
||||
],
|
||||
"edges": [
|
||||
{ "id": "request-result", "from": "request", "to": "result", "label": "同步 ✅ ready" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "lifecycle",
|
||||
"meta": {
|
||||
"title": "Agent Run Lifecycle",
|
||||
"subtitle": "State machine for planning, tool execution, human approval, retries, and terminal outcomes",
|
||||
"output": "examples/lifecycle-agent-run.html",
|
||||
"viewBox": [980, 660]
|
||||
},
|
||||
"lanes": [
|
||||
{ "id": "main", "label": "Lifecycle phases" },
|
||||
{ "id": "waiting", "label": "Interruptions" },
|
||||
{ "id": "exceptions", "label": "Recovery loop" },
|
||||
{ "id": "terminal", "label": "Terminal exits" }
|
||||
],
|
||||
"states": [
|
||||
{ "id": "queued", "type": "start", "label": "Queued", "sublabel": "request accepted", "lane": "main", "col": 0, "step": "01", "tag": "entry" },
|
||||
{ "id": "planning", "type": "active", "label": "Planning", "sublabel": "build task graph", "lane": "main", "col": 1, "step": "02", "tag": "model" },
|
||||
{ "id": "executing", "type": "active", "label": "Executing", "sublabel": "tool calls", "lane": "main", "col": 2, "step": "03", "tag": "work" },
|
||||
{ "id": "reviewing", "type": "decision", "label": "Reviewing", "sublabel": "quality gate", "lane": "main", "col": 3, "step": "04", "tag": "check" },
|
||||
{ "id": "completed", "type": "success", "label": "Completed", "sublabel": "final response", "lane": "main", "col": 4, "step": "05", "tag": "done" },
|
||||
{ "id": "approval", "type": "waiting", "label": "Needs Approval", "sublabel": "human gate", "lane": "waiting", "col": 0, "tag": "pause" },
|
||||
{ "id": "blocked", "type": "waiting", "label": "Blocked", "sublabel": "missing input", "lane": "waiting", "col": 1, "tag": "wait" },
|
||||
{ "id": "failed", "type": "failure", "label": "Failed", "sublabel": "recoverable error", "lane": "exceptions", "col": 0, "yOffset": 78, "tag": "retryable" },
|
||||
{ "id": "cancelled", "type": "failure", "label": "Cancelled", "sublabel": "user stopped", "lane": "terminal", "col": 0, "tag": "terminal" },
|
||||
{ "id": "expired", "type": "failure", "label": "Expired", "sublabel": "timeout", "lane": "terminal", "col": 1, "tag": "terminal" }
|
||||
],
|
||||
"transitions": [
|
||||
{ "from": "executing", "to": "approval", "variant": "security", "fromSide": "bottom", "toSide": "top", "route": "straight" },
|
||||
{ "from": "reviewing", "to": "blocked", "variant": "default", "route": "drop" },
|
||||
{ "from": "executing", "to": "failed", "variant": "security", "fromSide": "left", "toSide": "top", "via": [[320, 157], [320, 342], [402, 342]] },
|
||||
{ "from": "blocked", "to": "expired", "variant": "security", "fromSide": "bottom", "toSide": "top", "route": "straight" },
|
||||
{ "from": "approval", "to": "cancelled", "variant": "security", "fromSide": "bottom", "toSide": "top", "via": [[320, 336], [320, 430], [402, 430]] }
|
||||
],
|
||||
"cards": [
|
||||
{
|
||||
"dot": "emerald",
|
||||
"title": "Main Path",
|
||||
"items": [
|
||||
"The run has five ordered phases from queue to completion",
|
||||
"The primary lifecycle is carried by one horizontal rail",
|
||||
"Completion is a phase, not a detached side box"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "amber",
|
||||
"title": "Human + Input Gates",
|
||||
"items": [
|
||||
"Approval pauses execution without ending the run",
|
||||
"Blocked waits for missing user input",
|
||||
"Wait states can resume back into planning or execution"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "rose",
|
||||
"title": "Terminal + Recovery",
|
||||
"items": [
|
||||
"Failed loops back while retry budget remains",
|
||||
"Cancelled and Expired are exits from the lifecycle",
|
||||
"Terminal exits do not point back into active execution"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "workflow",
|
||||
"meta": {
|
||||
"title": "Agent Tool Call Workflow",
|
||||
"subtitle": "Renderer-driven workflow prototype with lanes, anchored nodes, and orthogonal edges",
|
||||
"output": "examples/workflow-agent-tool-call-rendered.html",
|
||||
"viewBox": [720, 900]
|
||||
},
|
||||
"lanes": [
|
||||
{ "id": "ui", "label": "User Interface" },
|
||||
{ "id": "agent", "label": "Agent Runtime" },
|
||||
{ "id": "policy", "label": "Policy Boundary" },
|
||||
{ "id": "exceptions", "label": "Exception Handling", "variant": "exception" },
|
||||
{ "id": "tools", "label": "Tool Execution" },
|
||||
{ "id": "trace", "label": "Observability" }
|
||||
],
|
||||
"phases": [
|
||||
{ "id": "intake", "label": "Intake", "fromCol": 0, "toCol": 1 },
|
||||
{ "id": "reasoning", "label": "Plan + route", "fromCol": 2, "toCol": 3, "variant": "emphasis" },
|
||||
{ "id": "execution", "label": "Execute + report", "fromCol": 4, "toCol": 5, "variant": "dashed" }
|
||||
],
|
||||
"groups": [
|
||||
{ "id": "agent_loop", "label": "Planning loop", "lane": "agent", "fromCol": 2, "toCol": 3, "variant": "emphasis" },
|
||||
{ "id": "tool_work", "label": "Tool work", "lane": "tools", "fromCol": 4, "toCol": 5, "variant": "dashed" },
|
||||
{ "id": "exception_path", "label": "Human or policy stop", "lane": "exceptions", "fromCol": 3, "toCol": 5, "variant": "security" }
|
||||
],
|
||||
"mainPath": ["user", "chat", "planner", "router", "approval", "tool", "external", "final"],
|
||||
"nodes": [
|
||||
{ "id": "user", "lane": "ui", "col": 0, "type": "external", "label": "User", "sublabel": "asks for work" },
|
||||
{ "id": "chat", "lane": "ui", "col": 1, "type": "frontend", "label": "Chat Surface", "sublabel": "thread + files" },
|
||||
{ "id": "final", "lane": "ui", "col": 5, "type": "backend", "label": "Final Reply", "sublabel": "answer + changes" },
|
||||
{ "id": "planner", "lane": "agent", "col": 2, "type": "backend", "label": "Agent Planner", "sublabel": "plan next step", "tag": "context aware" },
|
||||
{ "id": "router", "lane": "agent", "col": 3, "type": "backend", "label": "Tool Router", "sublabel": "choose capability" },
|
||||
{ "id": "approval", "lane": "policy", "col": 3, "type": "security", "label": "Approval Gate", "sublabel": "scope + consent", "tag": "block risky ops" },
|
||||
{ "id": "blocked", "lane": "exceptions", "col": 4, "type": "security", "label": "Blocked", "sublabel": "wait or reject" },
|
||||
{ "id": "retry", "lane": "exceptions", "col": 5, "type": "messagebus", "label": "Retry Path", "sublabel": "revise request" },
|
||||
{ "id": "tool", "lane": "tools", "col": 4, "type": "messagebus", "label": "Tool Call", "sublabel": "shell / browser / MCP", "tag": "structured result" },
|
||||
{ "id": "external", "lane": "tools", "col": 5, "type": "cloud", "label": "External API", "sublabel": "network service" },
|
||||
{ "id": "store", "lane": "trace", "col": 1, "type": "database", "label": "Context Store", "sublabel": "repo + memory" },
|
||||
{ "id": "trace", "lane": "trace", "col": 4, "type": "database", "label": "Trace Log", "sublabel": "events + output" }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "user", "to": "chat", "variant": "default" },
|
||||
{ "from": "chat", "to": "planner", "label": "plan", "variant": "emphasis", "fromSide": "bottom", "toSide": "top", "route": "drop", "labelSegment": 1 },
|
||||
{ "from": "planner", "to": "router", "variant": "default" },
|
||||
{ "from": "router", "to": "approval", "label": "needs approval?", "variant": "security", "fromSide": "bottom", "toSide": "top", "route": "drop", "labelSegment": 0, "labelDx": 34, "labelDy": 18 },
|
||||
{ "from": "approval", "to": "tool", "variant": "emphasis", "fromSide": "left", "toSide": "left", "route": "return-left" },
|
||||
{ "from": "approval", "to": "blocked", "label": "denied", "variant": "security", "role": "error", "fromSide": "bottom", "toSide": "top", "route": "drop", "labelSegment": 1, "labelDy": 12 },
|
||||
{ "from": "blocked", "to": "retry", "variant": "dashed", "role": "branch" },
|
||||
{ "from": "tool", "to": "external", "variant": "default" },
|
||||
{ "from": "external", "to": "final", "variant": "emphasis", "role": "return", "fromSide": "right", "toSide": "right", "route": "outside-right", "width": 1.2 },
|
||||
{ "from": "external", "to": "trace", "label": "record result", "variant": "dashed", "fromSide": "bottom", "toSide": "bottom", "route": "bottom-channel", "labelSegment": 1 },
|
||||
{ "from": "store", "to": "trace", "label": "trace + memory", "variant": "dashed", "labelAt": [365, 735] }
|
||||
],
|
||||
"cards": [
|
||||
{
|
||||
"dot": "cyan",
|
||||
"title": "Renderer Rules",
|
||||
"items": [
|
||||
"Lanes and columns determine node placement",
|
||||
"Edges attach to explicit node anchors",
|
||||
"Cross-lane paths use orthogonal routing",
|
||||
"Short adjacent links stay unlabeled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "rose",
|
||||
"title": "Workflow Semantics",
|
||||
"items": [
|
||||
"Approval is a first-class policy step",
|
||||
"Consent gates are visible in the main path",
|
||||
"External calls stay inside the tool lane",
|
||||
"Trace writes are separate from the hot path"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "emerald",
|
||||
"title": "Why It Matters",
|
||||
"items": [
|
||||
"This is closer to a diagram_type renderer",
|
||||
"The graph can be edited without SVG surgery",
|
||||
"Layout rules can be tested and improved",
|
||||
"A future IR can reuse this shape directly"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "sequence",
|
||||
"meta": {
|
||||
"title": "Cache Miss Request Sequence",
|
||||
"subtitle": "Frontend request path with auth, cache fallback, persistence, and async trace",
|
||||
"output": "examples/sequence-cache-miss-request.html",
|
||||
"viewBox": [820, 760]
|
||||
},
|
||||
"participants": [
|
||||
{ "id": "user", "type": "external", "label": "User", "sublabel": "browser session" },
|
||||
{ "id": "web", "type": "frontend", "label": "Web App", "sublabel": "React UI" },
|
||||
{ "id": "api", "type": "backend", "label": "API", "sublabel": "request handler" },
|
||||
{ "id": "auth", "type": "security", "label": "Auth", "sublabel": "JWT verify" },
|
||||
{ "id": "redis", "type": "database", "label": "Redis", "sublabel": "cache" },
|
||||
{ "id": "db", "type": "database", "label": "Postgres", "sublabel": "source of truth" },
|
||||
{ "id": "trace", "type": "messagebus", "label": "Trace", "sublabel": "async event" }
|
||||
],
|
||||
"segments": [
|
||||
{ "from": 150, "to": 295, "label": "Request" },
|
||||
{ "from": 315, "to": 505, "label": "Fallback" },
|
||||
{ "from": 525, "to": 665, "label": "Response + trace" }
|
||||
],
|
||||
"messages": [
|
||||
{ "from": "user", "to": "web", "y": 185, "label": "open page", "variant": "default" },
|
||||
{ "from": "web", "to": "api", "y": 228, "label": "GET /dashboard", "variant": "emphasis" },
|
||||
{ "from": "api", "to": "auth", "y": 270, "label": "verify JWT", "variant": "security" },
|
||||
{ "from": "auth", "to": "api", "y": 305, "label": "claims ok", "variant": "return" },
|
||||
{ "from": "api", "to": "redis", "y": 354, "label": "read cache", "variant": "default" },
|
||||
{ "from": "redis", "to": "api", "y": 391, "label": "miss", "variant": "return" },
|
||||
{ "from": "api", "to": "db", "y": 443, "label": "query profile + metrics", "variant": "emphasis" },
|
||||
{ "from": "db", "to": "api", "y": 489, "label": "rows", "variant": "return" },
|
||||
{ "from": "api", "to": "redis", "y": 536, "label": "set cache", "variant": "dashed" },
|
||||
{ "from": "api", "to": "trace", "y": 580, "label": "emit trace", "variant": "dashed" },
|
||||
{ "from": "api", "to": "web", "y": 625, "label": "200 JSON", "variant": "return" },
|
||||
{ "from": "web", "to": "user", "y": 662, "label": "render", "variant": "return" }
|
||||
],
|
||||
"activations": [
|
||||
{ "participant": "web", "from": 220, "to": 668, "type": "frontend" },
|
||||
{ "participant": "api", "from": 228, "to": 632, "type": "backend" },
|
||||
{ "participant": "auth", "from": 265, "to": 310, "type": "security" },
|
||||
{ "participant": "redis", "from": 349, "to": 398, "type": "database" },
|
||||
{ "participant": "db", "from": 438, "to": 496, "type": "database" },
|
||||
{ "participant": "trace", "from": 575, "to": 630, "type": "messagebus" }
|
||||
],
|
||||
"cards": [
|
||||
{
|
||||
"dot": "emerald",
|
||||
"title": "Happy Path",
|
||||
"items": [
|
||||
"The main request is Web App -> API -> data source -> response",
|
||||
"Return messages are quieter than forward calls",
|
||||
"Activation bars make ownership duration visible"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "rose",
|
||||
"title": "Policy + Fallback",
|
||||
"items": [
|
||||
"JWT verification is colored as a security interaction",
|
||||
"Cache miss is visible without overpowering the main path",
|
||||
"Database access only appears after cache fallback"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "orange",
|
||||
"title": "Async Trace",
|
||||
"items": [
|
||||
"Trace emission is dashed and secondary",
|
||||
"It does not block the response path",
|
||||
"The diagram separates user-facing latency from observability"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "dataflow",
|
||||
"meta": {
|
||||
"title": "Order Event-stream Topology",
|
||||
"output": "examples/event-stream.html",
|
||||
"viewBox": [1080, 780],
|
||||
"animation": "trace",
|
||||
"visual_preset": "signal-flow",
|
||||
"quality_profile": "showcase",
|
||||
"views": [
|
||||
{ "id": "order-transit", "label": "Order event transit", "focus": ["checkout", "orders", "validate", "state", "fulfillment"], "note": "Follow an order from producer through ordered processing to fulfillment." },
|
||||
{ "id": "payment-transit", "label": "Payment event transit", "focus": ["billing", "payments", "enrich", "state", "analytics"], "note": "Track payment facts into the shared materialized state and analytics." },
|
||||
{ "id": "failure-and-replay", "label": "Failure and replay", "focus": ["validate", "enrich", "dlq", "replay", "ops"], "note": "Isolate dead letters, operator review, and controlled replay ownership." }
|
||||
]
|
||||
},
|
||||
"stages": [
|
||||
{ "label": "Producers" },
|
||||
{ "label": "Transit" },
|
||||
{ "label": "Processors" },
|
||||
{ "label": "State + recovery" },
|
||||
{ "label": "Consumers" }
|
||||
],
|
||||
"nodes": [
|
||||
{ "id": "checkout", "type": "frontend", "label": "Checkout API", "sublabel": "order producer", "stage": 0, "row": 0, "tag": "team commerce" },
|
||||
{ "id": "billing", "type": "backend", "label": "Billing API", "sublabel": "payment producer", "stage": 0, "row": 2, "tag": "team money" },
|
||||
{ "id": "orders", "type": "messagebus", "label": "orders.v1", "sublabel": "12 partitions", "stage": 1, "row": 0, "tag": "key: order_id" },
|
||||
{ "id": "payments", "type": "messagebus", "label": "payments.v2", "sublabel": "8 partitions", "stage": 1, "row": 2, "tag": "key: order_id" },
|
||||
{ "id": "validate", "type": "backend", "label": "Order Validate", "sublabel": "group fulfillment", "stage": 2, "row": 0, "tag": "ordered" },
|
||||
{ "id": "enrich", "type": "backend", "label": "Payment Enrich", "sublabel": "group analytics", "stage": 2, "row": 2, "tag": "at-least-once" },
|
||||
{ "id": "state", "type": "database", "label": "Order State", "sublabel": "materialized view", "stage": 3, "row": 1, "tag": "idempotent" },
|
||||
{ "id": "dlq", "type": "messagebus", "label": "events.dlq", "sublabel": "poison events", "stage": 3, "row": 4, "tag": "7-day retention" },
|
||||
{ "id": "fulfillment", "type": "backend", "label": "Fulfillment", "sublabel": "shipping workflow", "stage": 4, "row": 0, "tag": "consumer" },
|
||||
{ "id": "analytics", "type": "database", "label": "Analytics", "sublabel": "streaming facts", "stage": 4, "row": 2, "tag": "consumer" },
|
||||
{ "id": "replay", "type": "security", "label": "Replay Tool", "sublabel": "approved batch", "stage": 4, "row": 4, "tag": "operator gate" },
|
||||
{ "id": "ops", "type": "external", "label": "On-call", "sublabel": "DLQ owner", "stage": 4, "row": 3, "yOffset": -18, "tag": "SRE" }
|
||||
],
|
||||
"flows": [
|
||||
{ "from": "checkout", "to": "orders", "label": "OrderPlaced", "classification": "schema v1", "variant": "emphasis", "route": "straight" },
|
||||
{ "from": "billing", "to": "payments", "label": "PaymentCaptured", "classification": "schema v2", "variant": "emphasis", "route": "straight" },
|
||||
{ "from": "orders", "to": "validate", "label": "ordered orders", "classification": "consumer group", "variant": "emphasis", "route": "straight" },
|
||||
{ "from": "payments", "to": "enrich", "label": "payment facts", "classification": "at-least-once", "variant": "emphasis", "route": "straight" },
|
||||
{ "from": "validate", "to": "state", "label": "valid order", "classification": "idempotent", "variant": "emphasis", "route": "vertical-channel" },
|
||||
{ "from": "enrich", "to": "state", "label": "enriched payment", "classification": "idempotent", "variant": "default", "route": "vertical-channel" },
|
||||
{ "from": "state", "to": "fulfillment", "label": "ready orders", "classification": "read model", "variant": "emphasis", "route": "vertical-channel" },
|
||||
{ "from": "state", "to": "analytics", "label": "order facts", "classification": "non-PII", "variant": "default", "route": "vertical-channel" },
|
||||
{ "from": "validate", "to": "dlq", "label": "invalid event", "classification": "dead letter", "variant": "security", "fromSide": "top", "toSide": "top", "via": [[530, 80], [20, 80], [20, 550], [745, 550]], "labelAt": [300, 550] },
|
||||
{ "from": "enrich", "to": "dlq", "label": "poison event", "classification": "dead letter", "variant": "security", "route": "bottom-channel", "labelDy": 30 },
|
||||
{ "from": "dlq", "to": "ops", "label": "failure sample", "classification": "restricted", "variant": "security", "route": "vertical-channel" },
|
||||
{ "from": "dlq", "to": "replay", "label": "approved replay", "classification": "audited batch", "variant": "dashed", "route": "straight", "labelDy": 30 }
|
||||
],
|
||||
"cards": [
|
||||
{ "dot": "amber", "title": "Transit Contract", "items": ["Every event and topic is named", "Partition keys preserve per-order ordering", "Consumer groups expose processing ownership"] },
|
||||
{ "dot": "emerald", "title": "State + Delivery", "items": ["Processors write an idempotent materialized view", "Fulfillment and analytics consume distinct assets", "At-least-once delivery never implies duplicate business effects"] },
|
||||
{ "dot": "rose", "title": "Failure Ownership", "items": ["Poison events land in a retained dead-letter topic", "On-call inspects samples before replay", "Replay is gated, batched, and auditable"] }
|
||||
]
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "dataflow",
|
||||
"meta": {
|
||||
"title": "Product Analytics Data Flow",
|
||||
"subtitle": "Events, consent, PII isolation, warehouse sync, and downstream analytics",
|
||||
"output": "examples/dataflow-product-analytics.html",
|
||||
"viewBox": [1080, 760]
|
||||
},
|
||||
"stages": [
|
||||
{ "label": "Sources" },
|
||||
{ "label": "Ingest" },
|
||||
{ "label": "Process" },
|
||||
{ "label": "Store" },
|
||||
{ "label": "Consume" }
|
||||
],
|
||||
"nodes": [
|
||||
{ "id": "web", "type": "frontend", "label": "Web App", "sublabel": "browser SDK", "stage": 0, "row": 0, "tag": "events" },
|
||||
{ "id": "mobile", "type": "frontend", "label": "Mobile", "sublabel": "iOS / Android", "stage": 0, "row": 2, "tag": "events" },
|
||||
{ "id": "edge", "type": "cloud", "label": "Edge API", "sublabel": "collector", "stage": 1, "row": 1, "tag": "TLS" },
|
||||
{ "id": "consent", "type": "security", "label": "Consent Gate", "sublabel": "policy filter", "stage": 2, "row": 0, "tag": "PII guard" },
|
||||
{ "id": "stream", "type": "messagebus", "label": "Event Stream", "sublabel": "Kafka topic", "stage": 2, "row": 2, "tag": "ordered" },
|
||||
{ "id": "pii", "type": "security", "label": "PII Vault", "sublabel": "encrypted", "stage": 3, "row": 0, "tag": "restricted" },
|
||||
{ "id": "warehouse", "type": "database", "label": "Warehouse", "sublabel": "analytics tables", "stage": 3, "row": 2, "tag": "curated" },
|
||||
{ "id": "features", "type": "database", "label": "Feature Store", "sublabel": "daily batch", "stage": 3, "row": 4, "tag": "derived" },
|
||||
{ "id": "dashboard", "type": "backend", "label": "Dashboards", "sublabel": "product metrics", "stage": 4, "row": 1, "tag": "SQL" },
|
||||
{ "id": "model", "type": "backend", "label": "ML Model", "sublabel": "ranking job", "stage": 4, "row": 4, "tag": "features" }
|
||||
],
|
||||
"flows": [
|
||||
{ "from": "web", "to": "edge", "label": "clickstream", "classification": "user events", "variant": "emphasis", "fromSide": "right", "toSide": "left", "via": [[184, 157], [184, 271]], "labelAt": [204, 190] },
|
||||
{ "from": "mobile", "to": "edge", "label": "app events", "classification": "device events", "variant": "default", "fromSide": "right", "toSide": "left", "via": [[222, 385], [222, 271]], "labelAt": [220, 342] },
|
||||
{ "from": "edge", "to": "consent", "label": "identity + consent", "classification": "PII touch", "variant": "security", "fromSide": "top", "toSide": "left", "via": [[315, 112], [450, 112], [450, 157]], "labelAt": [382, 100] },
|
||||
{ "from": "edge", "to": "stream", "label": "accepted events", "classification": "append-only", "variant": "emphasis", "fromSide": "right", "toSide": "left", "via": [[420, 271], [420, 385]], "labelAt": [438, 324] },
|
||||
{ "from": "consent", "to": "pii", "label": "identity map", "classification": "encrypted PII", "variant": "security", "route": "straight", "labelAt": [638, 144] },
|
||||
{ "from": "stream", "to": "warehouse", "label": "normalized facts", "classification": "non-PII", "variant": "emphasis", "route": "straight", "labelAt": [638, 372] },
|
||||
{ "from": "warehouse", "to": "features", "label": "daily aggregates", "classification": "batch", "variant": "dashed", "fromSide": "bottom", "toSide": "top", "route": "straight", "labelAt": [745, 496] },
|
||||
{ "from": "warehouse", "to": "dashboard", "label": "metrics SQL", "classification": "read-only", "variant": "default", "fromSide": "right", "toSide": "left", "via": [[852, 385], [852, 271]], "labelAt": [830, 326] },
|
||||
{ "from": "features", "to": "model", "label": "feature vectors", "classification": "derived", "variant": "dashed", "route": "straight", "labelAt": [852, 598] },
|
||||
{ "from": "pii", "to": "dashboard", "label": "restricted join", "classification": "approved only", "variant": "security", "fromSide": "right", "toSide": "top", "via": [[878, 157], [878, 212], [960, 212]], "labelAt": [880, 198] }
|
||||
],
|
||||
"cards": [
|
||||
{
|
||||
"dot": "emerald",
|
||||
"title": "Primary Data Path",
|
||||
"items": [
|
||||
"Events move left to right through source, ingest, process, store, and consume stages",
|
||||
"The hot path stays visually clear even with secondary batch flows",
|
||||
"Labels name data assets instead of generic API verbs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "rose",
|
||||
"title": "Sensitive Boundary",
|
||||
"items": [
|
||||
"Consent and PII paths are styled as security flows",
|
||||
"PII lands in a restricted vault, separate from the analytics warehouse",
|
||||
"Restricted joins are visible without implying default access"
|
||||
]
|
||||
},
|
||||
{
|
||||
"dot": "orange",
|
||||
"title": "Derived Consumers",
|
||||
"items": [
|
||||
"Dashboards read curated facts from the warehouse",
|
||||
"Feature vectors are derived by batch from analytics tables",
|
||||
"Consumption paths stay distinct from collection and consent handling"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "architecture",
|
||||
"meta": {
|
||||
"title": "Production Deployment Ownership",
|
||||
"output": "examples/production-deployment.html",
|
||||
"visual_preset": "blueprint",
|
||||
"animation": "trace",
|
||||
"quality_profile": "showcase",
|
||||
"engineering_profile": "deployment-ownership",
|
||||
"views": [
|
||||
{
|
||||
"id": "request-boundary",
|
||||
"label": "Request crosses the edge",
|
||||
"focus": ["clients", "edge", "gateway", "api_a", "api_b"],
|
||||
"note": "Follow public traffic into the private application network."
|
||||
},
|
||||
{
|
||||
"id": "state-ownership",
|
||||
"label": "State and ownership",
|
||||
"focus": ["api_a", "api_b", "redis", "postgres", "replica"],
|
||||
"note": "Separate stateless platform workloads from data-team-owned state."
|
||||
},
|
||||
{
|
||||
"id": "async-operations",
|
||||
"label": "Async and operations",
|
||||
"focus": ["api_b", "events", "worker", "audit", "observability"],
|
||||
"note": "See the asynchronous work and the evidence it emits."
|
||||
}
|
||||
]
|
||||
},
|
||||
"components": [
|
||||
{ "id": "clients", "type": "external", "label": "Customers", "sublabel": "web + mobile", "pos": [38, 300], "size": [122, 60] },
|
||||
{ "id": "edge", "type": "cloud", "label": "Global Edge", "sublabel": "CDN + WAF", "pos": [230, 300], "size": [126, 60], "tag": "edge team" },
|
||||
{ "id": "gateway", "type": "security", "label": "API Gateway", "sublabel": "public :443", "pos": [430, 300], "size": [128, 60], "tag": "platform" },
|
||||
{ "id": "api_a", "type": "backend", "label": "API Pods / AZ-a", "sublabel": "private subnet", "pos": [630, 195], "size": [136, 62], "tag": "app team" },
|
||||
{ "id": "api_b", "type": "backend", "label": "API Pods / AZ-b", "sublabel": "private subnet", "pos": [630, 405], "size": [136, 62], "tag": "app team" },
|
||||
{ "id": "redis", "type": "database", "label": "Redis", "sublabel": "multi-AZ cache", "pos": [840, 195], "size": [126, 62], "tag": "platform" },
|
||||
{ "id": "postgres", "type": "database", "label": "PostgreSQL", "sublabel": "primary / encrypted", "pos": [840, 405], "size": [126, 62], "tag": "data team" },
|
||||
{ "id": "events", "type": "messagebus", "label": "Event Bus", "sublabel": "orders.v1", "pos": [1040, 300], "size": [126, 60], "tag": "platform" },
|
||||
{ "id": "worker", "type": "backend", "label": "Workers", "sublabel": "private workload", "pos": [1240, 300], "size": [126, 60], "tag": "app team" },
|
||||
{ "id": "replica", "type": "database", "label": "DR Replica", "sublabel": "eu-west-1", "pos": [1040, 610], "size": [126, 62], "tag": "data team" },
|
||||
{ "id": "audit", "type": "cloud", "label": "Audit Archive", "sublabel": "immutable objects", "pos": [1240, 465], "size": [126, 62], "tag": "security" },
|
||||
{ "id": "observability", "type": "external", "label": "Observability", "sublabel": "metrics + traces", "pos": [1240, 85], "size": [126, 62], "tag": "SRE" }
|
||||
],
|
||||
"boundaries": [
|
||||
{ "kind": "region", "label": "AWS us-east-1 / production", "wraps": ["edge", "gateway", "api_a", "api_b", "redis", "postgres", "events", "worker", "audit"] },
|
||||
{ "kind": "security-group", "label": "private application network", "wraps": ["api_a", "api_b", "redis", "postgres", "events", "worker"] },
|
||||
{ "kind": "region", "label": "AWS eu-west-1 / disaster recovery", "wraps": ["replica"] },
|
||||
{ "kind": "security-group", "label": "DR private subnet", "wraps": ["replica"], "pad": 14 }
|
||||
],
|
||||
"connections": [
|
||||
{ "from": "clients", "to": "edge", "label": "HTTPS", "variant": "emphasis" },
|
||||
{ "from": "edge", "to": "gateway", "label": "mTLS", "variant": "security" },
|
||||
{ "from": "gateway", "to": "api_a", "label": "VPC route", "variant": "emphasis", "route": "orthogonal-h", "labelAt": [594, 275] },
|
||||
{ "from": "gateway", "to": "api_b", "label": "VPC route", "variant": "emphasis", "route": "orthogonal-h", "labelAt": [594, 385] },
|
||||
{ "from": "api_a", "to": "redis", "label": "cache", "route": "straight" },
|
||||
{ "from": "api_b", "to": "postgres", "label": "SQL", "route": "straight" },
|
||||
{ "from": "api_a", "to": "events", "label": "publish", "variant": "dashed", "fromSide": "top", "toSide": "top", "via": [[698, 170], [1103, 170]] },
|
||||
{ "from": "api_b", "to": "events", "variant": "dashed", "fromSide": "top", "toSide": "bottom", "via": [[698, 380], [1103, 380]] },
|
||||
{ "from": "events", "to": "worker", "variant": "emphasis" },
|
||||
{ "from": "postgres", "to": "replica", "label": "cross-region WAL", "variant": "security", "route": "orthogonal-v", "labelAt": [1003, 529] },
|
||||
{ "from": "worker", "to": "audit", "label": "evidence", "variant": "dashed", "fromSide": "bottom", "toSide": "top", "labelDy": 58 },
|
||||
{ "from": "worker", "to": "observability", "label": "OTLP", "variant": "dashed", "route": "orthogonal-v" }
|
||||
],
|
||||
"cards": [
|
||||
{ "dot": "cyan", "title": "Runtime Ownership", "items": ["Platform owns the edge, gateway, cache, and event bus", "Application teams own API pods and workers", "Data owns primary and disaster-recovery state"] },
|
||||
{ "dot": "rose", "title": "Named Crossings", "items": ["Public HTTPS terminates at the managed edge", "mTLS crosses into the application network", "Cross-region WAL is explicit and encrypted"] },
|
||||
{ "dot": "emerald", "title": "Operational Evidence", "items": ["Workers emit traces to SRE-owned observability", "Audit evidence lands in immutable storage", "Unknown placement should remain marked, never invented"] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "architecture",
|
||||
"meta": {
|
||||
"title": "Sample Web App",
|
||||
"subtitle": "Classic 3-tier SaaS on AWS — rendered by Archify",
|
||||
"output": "web-app-rendered.html"
|
||||
},
|
||||
"components": [
|
||||
{ "id": "users", "type": "external", "label": "Users", "sublabel": "Browser / Mobile", "pos": [40, 300], "size": [120, 60] },
|
||||
{ "id": "auth", "type": "security", "label": "Auth Provider", "sublabel": "OAuth 2.0", "pos": [40, 110], "size": [120, 64], "tag": "JWT + PKCE" },
|
||||
{ "id": "cdn", "type": "cloud", "label": "CloudFront", "sublabel": "CDN", "pos": [250, 300], "size": [130, 60] },
|
||||
{ "id": "lb", "type": "cloud", "label": "Load Balancer", "sublabel": "HTTPS :443", "pos": [460, 300], "size": [130, 60] },
|
||||
{ "id": "api", "type": "backend", "label": "API Server", "sublabel": "FastAPI :8000", "pos": [670, 300], "size": [130, 60] },
|
||||
{ "id": "cache", "type": "database", "label": "Redis", "sublabel": "cache :6379", "pos": [670, 150], "size": [130, 60] },
|
||||
{ "id": "db", "type": "database", "label": "PostgreSQL", "sublabel": "primary :5432", "pos": [880, 300], "size": [130, 60] },
|
||||
{ "id": "s3", "type": "cloud", "label": "S3", "sublabel": "static assets", "pos": [250, 440], "size": [130, 60], "tag": "OAI protected" },
|
||||
{ "id": "queue", "type": "messagebus", "label": "SQS", "sublabel": "job queue", "pos": [670, 440], "size": [130, 60] },
|
||||
{ "id": "worker", "type": "backend", "label": "Worker", "sublabel": "async jobs", "pos": [880, 440], "size": [130, 60] }
|
||||
],
|
||||
"boundaries": [
|
||||
{ "kind": "region", "label": "AWS Region: us-west-2", "wraps": ["cdn", "lb", "api", "cache", "db", "s3", "queue", "worker"] },
|
||||
{ "kind": "security-group", "label": "sg-api :443/:8000", "wraps": ["lb", "api"] }
|
||||
],
|
||||
"connections": [
|
||||
{ "from": "users", "to": "cdn", "label": "HTTPS", "variant": "emphasis" },
|
||||
{ "from": "auth", "to": "api", "label": "verify JWT", "variant": "security", "fromSide": "right", "toSide": "left", "via": [[620, 142], [620, 330]] },
|
||||
{ "from": "cdn", "to": "lb" },
|
||||
{ "from": "cdn", "to": "s3", "label": "static", "variant": "dashed", "fromSide": "bottom", "toSide": "top", "labelDy": 58 },
|
||||
{ "from": "lb", "to": "api" },
|
||||
{ "from": "api", "to": "cache", "label": "read-through", "fromSide": "top", "toSide": "bottom", "labelDy": -68 },
|
||||
{ "from": "api", "to": "db", "label": "SQL" },
|
||||
{ "from": "api", "to": "queue", "label": "enqueue", "variant": "dashed", "fromSide": "bottom", "toSide": "top", "labelDy": 58 },
|
||||
{ "from": "queue", "to": "worker" }
|
||||
],
|
||||
"cards": [
|
||||
{ "dot": "cyan", "title": "Edge", "items": ["CloudFront CDN fronts all traffic", "S3 serves static assets via OAI"] },
|
||||
{ "dot": "emerald", "title": "Application", "items": ["FastAPI behind an HTTPS load balancer", "Redis read-through cache", "Async work drained from SQS by a worker"] },
|
||||
{ "dot": "rose", "title": "Security", "items": ["OAuth 2.0 with JWT + PKCE", "API + LB isolated in a security group"] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "workflow",
|
||||
"meta": {
|
||||
"title": "Legacy narrow workflow",
|
||||
"viewBox": [700, 400]
|
||||
},
|
||||
"lanes": [
|
||||
{ "id": "first", "label": "First" },
|
||||
{ "id": "second", "label": "Second" }
|
||||
],
|
||||
"nodes": [
|
||||
{ "id": "node_0", "lane": "first", "col": 0, "type": "frontend", "label": "frontend" },
|
||||
{ "id": "node_1", "lane": "first", "col": 2, "type": "backend", "label": "backend" },
|
||||
{ "id": "node_2", "lane": "first", "col": 4, "type": "database", "label": "database" },
|
||||
{ "id": "node_3", "lane": "second", "col": 0, "type": "cloud", "label": "cloud" },
|
||||
{ "id": "node_4", "lane": "second", "col": 2, "type": "security", "label": "security" },
|
||||
{ "id": "node_5", "lane": "second", "col": 4, "type": "messagebus", "label": "messagebus" },
|
||||
{ "id": "node_6", "lane": "second", "col": 5, "type": "external", "label": "external" }
|
||||
],
|
||||
"edges": []
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "workflow",
|
||||
"meta": {
|
||||
"title": "Pinned coordinate migration",
|
||||
"viewBox": [720, 700],
|
||||
"legend": { "mode": "hidden" }
|
||||
},
|
||||
"lanes": [
|
||||
{ "id": "route", "label": "Explicit route" },
|
||||
{ "id": "label", "label": "Explicit label" },
|
||||
{ "id": "channel-source", "label": "Channel source" },
|
||||
{ "id": "channel-target", "label": "Channel target" }
|
||||
],
|
||||
"nodes": [
|
||||
{ "id": "route-a", "lane": "route", "col": 0, "type": "backend", "label": "A" },
|
||||
{ "id": "route-b", "lane": "route", "col": 2, "type": "backend", "label": "B" },
|
||||
{ "id": "label-a", "lane": "label", "col": 2, "type": "backend", "label": "C" },
|
||||
{ "id": "label-b", "lane": "label", "col": 3, "type": "backend", "label": "D" },
|
||||
{ "id": "channel-a", "lane": "channel-source", "col": 1, "type": "backend", "label": "E" },
|
||||
{ "id": "channel-b", "lane": "channel-target", "col": 1, "type": "backend", "label": "F" }
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"id": "pinned-via",
|
||||
"from": "route-a",
|
||||
"to": "route-b",
|
||||
"via": [[220, 119]]
|
||||
},
|
||||
{
|
||||
"id": "pinned-label",
|
||||
"from": "label-a",
|
||||
"to": "label-b",
|
||||
"label": "ok",
|
||||
"labelAt": [365, 203]
|
||||
},
|
||||
{
|
||||
"id": "pinned-channel",
|
||||
"from": "channel-a",
|
||||
"to": "channel-b",
|
||||
"fromSide": "right",
|
||||
"toSide": "right",
|
||||
"route": "outside-right",
|
||||
"channelX": 500
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { SCENARIO_RECIPES } from '../recipes/scenarios.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(skillRoot, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-gallery-'));
|
||||
const generatedRoot = path.join(tmp, 'docs');
|
||||
|
||||
function sha256(file) {
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
}
|
||||
|
||||
function normalize(text) {
|
||||
return text.replace(/\r\n?/g, '\n');
|
||||
}
|
||||
|
||||
test('generated proof gallery matches its sources, receipts, and checked-in artifacts', () => {
|
||||
const output = execFileSync(process.execPath, [
|
||||
path.join(repoRoot, 'scripts', 'build-gallery.mjs'),
|
||||
generatedRoot,
|
||||
], { encoding: 'utf8' });
|
||||
assert.match(output, /gallery 11 artifacts \/ 99 checks/);
|
||||
|
||||
const manifestPath = path.join(generatedRoot, 'gallery', 'manifest.json');
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
assert.equal(manifest.schemaVersion, 1);
|
||||
assert.equal(manifest.archifyVersion, JSON.parse(fs.readFileSync(path.join(skillRoot, 'package.json'))).version);
|
||||
assert.equal(manifest.entryCount, 11);
|
||||
assert.equal(manifest.checkCount, 99);
|
||||
assert.deepEqual(new Set(manifest.entries.map((entry) => entry.type)), new Set([
|
||||
'architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle',
|
||||
]));
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle'].map((type) => [
|
||||
type,
|
||||
manifest.entries.filter((entry) => entry.type === type).length,
|
||||
])),
|
||||
{ architecture: 2, workflow: 3, sequence: 2, dataflow: 2, lifecycle: 2 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
new Set(manifest.entries.map((entry) => entry.id)),
|
||||
new Set(SCENARIO_RECIPES.map((recipe) => recipe.proof)),
|
||||
);
|
||||
|
||||
const workflow = manifest.entries.find((entry) => entry.id === 'agent-tool-call');
|
||||
assert.equal(workflow.view, 'happy-path');
|
||||
assert.equal(workflow.viewCount, 3);
|
||||
assert.deepEqual(workflow.viewIds, ['happy-path', 'safety-gate', 'evidence-loop']);
|
||||
assert.equal(workflow.guidedPlayback, true);
|
||||
|
||||
const deployment = manifest.entries.find((entry) => entry.id === 'deployment-ownership');
|
||||
assert.equal(deployment.engineeringProfile, 'deployment-ownership');
|
||||
assert.ok(manifest.entries.filter((entry) => entry.id !== 'deployment-ownership')
|
||||
.every((entry) => entry.engineeringProfile === null));
|
||||
|
||||
for (const entry of manifest.entries) {
|
||||
const artifact = path.join(generatedRoot, entry.artifact.replace(/^gallery\//, 'gallery/'));
|
||||
const source = path.join(generatedRoot, entry.input.replace(/^gallery\//, 'gallery/'));
|
||||
assert.ok(fs.existsSync(artifact), `${entry.id}: artifact missing`);
|
||||
assert.ok(fs.existsSync(source), `${entry.id}: source missing`);
|
||||
assert.equal(sha256(artifact), entry.artifactSha256, `${entry.id}: artifact digest drift`);
|
||||
assert.equal(sha256(source), entry.sourceSha256, `${entry.id}: source digest drift`);
|
||||
assert.equal(entry.checks.length, 9);
|
||||
assert.ok(entry.checks.every((check) => check.ok), `${entry.id}: validation receipt not green`);
|
||||
assert.equal(entry.composition.profile, 'showcase', `${entry.id}: expected showcase composition profile`);
|
||||
assert.equal(entry.composition.status, 'pass', `${entry.id}: showcase composition is not green`);
|
||||
assert.equal(entry.composition.metrics.properCrossings, 0, `${entry.id}: proper crossing debt remains`);
|
||||
assert.equal(entry.composition.metrics.ambiguousCorridors, 0, `${entry.id}: ambiguous corridor debt remains`);
|
||||
assert.equal(entry.composition.metrics.containerBorderRuns, 0, `${entry.id}: container border-run debt remains`);
|
||||
assert.equal(entry.composition.metrics.labelRouteClearanceIssues, 0, `${entry.id}: label-route clearance debt remains`);
|
||||
assert.equal(entry.composition.metrics.shortInteriorSegmentCount, 0, `${entry.id}: cramped interior turn remains`);
|
||||
assert.equal(entry.composition.metrics.microSegmentCount, 0, `${entry.id}: micro segment remains`);
|
||||
assert.equal(entry.viewCount, 3, `${entry.id}: expected a three-step reader story`);
|
||||
assert.equal(entry.guidedPlayback, true, `${entry.id}: guided playback missing`);
|
||||
}
|
||||
|
||||
const html = fs.readFileSync(path.join(generatedRoot, 'gallery.html'), 'utf8');
|
||||
assert.equal((html.match(/class="showcase-card/g) || []).length, 11);
|
||||
assert.match(html, /id="gallery-manifest" type="application\/json"/);
|
||||
assert.match(html, /data-src-base="gallery\/artifacts\/agent-tool-call\.workflow\.html"/);
|
||||
assert.match(html, /agent-tool-call\.workflow\.html\?present=1&play=1#view=happy-path/);
|
||||
assert.match(html, /event-stream\.dataflow\.html\?present=1&play=1#view=order-transit/);
|
||||
assert.match(html, /id="proof-deployment-lifecycle"/);
|
||||
assert.match(html, /Play named chapter/);
|
||||
assert.match(html, /3 views · play/);
|
||||
assert.match(html, /Proof,<br><em>not promises\.<\/em>/);
|
||||
assert.match(html, /Five lenses\. Eleven real stories\./);
|
||||
assert.match(html, /Composition<\/span><span class="receipt-value ok" title="0 crossings · 0 border runs · 0 micro segments · 0 cramped turns">SHOWCASE · PASS/);
|
||||
assert.match(html, /Engineering profile/);
|
||||
assert.match(html, /DEPLOYMENT OWNERSHIP · PASS/);
|
||||
assert.match(html, /<link rel="stylesheet" href="assets\/site-navigation\.css">/);
|
||||
assert.match(
|
||||
fs.readFileSync(path.join(generatedRoot, 'assets/site-navigation.css'), 'utf8'),
|
||||
/\.site-nav \.nav-logo \{[^}]*min-height: 44px;/,
|
||||
);
|
||||
assert.match(html, /\.filter-button \{\s+min-height: 44px;/);
|
||||
assert.match(html, /\.card-link \{ min-height: 44px;/);
|
||||
assert.equal((html.match(/class="card-link create-link"/g) || []).length, 11);
|
||||
for (const type of ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']) {
|
||||
assert.match(html, new RegExp(`start\\.html\\?type=${type}&source=gallery`), `${type}: gallery-to-start link missing`);
|
||||
}
|
||||
assert.match(html, /class="community-callout"/);
|
||||
assert.match(html, /href="https:\/\/github\.com\/tt-a1i\/archify\/issues\/new\?template=showcase\.yml"[^>]+rel="noopener noreferrer"/);
|
||||
assert.match(html, /Share a verified diagram/);
|
||||
assert.match(html, /提交已验证成品/);
|
||||
|
||||
for (const relative of [
|
||||
'gallery.html',
|
||||
'assets/site-language.js',
|
||||
'assets/site-navigation.css',
|
||||
'gallery/manifest.json',
|
||||
...manifest.entries.flatMap((entry) => [entry.artifact, entry.input]),
|
||||
]) {
|
||||
const fresh = path.join(generatedRoot, relative);
|
||||
const checked = path.join(repoRoot, 'docs', relative);
|
||||
assert.ok(fs.existsSync(checked), `${relative}: checked-in gallery output missing`);
|
||||
assert.equal(normalize(fs.readFileSync(fresh, 'utf8')), normalize(fs.readFileSync(checked, 'utf8')),
|
||||
`${relative}: checked-in gallery output is stale; run node scripts/build-gallery.mjs`);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { workflow as validateWorkflow } from '../renderers/shared/generated-validators.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function workflowDocument(schemaVersion) {
|
||||
return {
|
||||
schema_version: schemaVersion,
|
||||
diagram_type: 'workflow',
|
||||
meta: { title: 'Schema compatibility' },
|
||||
lanes: [{ id: 'main', label: 'Main' }],
|
||||
nodes: [{ id: 'step', lane: 'main', col: 0, type: 'backend', label: 'Step' }],
|
||||
edges: [],
|
||||
};
|
||||
}
|
||||
|
||||
test('generated workflow validator accepts schema versions 1 and 2 only', () => {
|
||||
assert.equal(validateWorkflow(workflowDocument(1)), true, JSON.stringify(validateWorkflow.errors));
|
||||
assert.equal(validateWorkflow(workflowDocument(2)), true, JSON.stringify(validateWorkflow.errors));
|
||||
assert.equal(validateWorkflow(workflowDocument(3)), false);
|
||||
assert.deepEqual(validateWorkflow.errors?.[0]?.params.allowedValues, [1, 2]);
|
||||
});
|
||||
|
||||
test('validator freshness check accepts CRLF checkouts', () => {
|
||||
const scratch = fs.mkdtempSync(path.join(skillRoot, '.validator-check-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(scratch, 'scripts'));
|
||||
fs.mkdirSync(path.join(scratch, 'renderers', 'shared'), { recursive: true });
|
||||
fs.cpSync(path.join(skillRoot, 'schemas'), path.join(scratch, 'schemas'), { recursive: true });
|
||||
fs.copyFileSync(
|
||||
path.join(skillRoot, 'scripts', 'generate-validators.mjs'),
|
||||
path.join(scratch, 'scripts', 'generate-validators.mjs'),
|
||||
);
|
||||
|
||||
const validator = fs.readFileSync(
|
||||
path.join(skillRoot, 'renderers', 'shared', 'generated-validators.mjs'),
|
||||
'utf8',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(scratch, 'renderers', 'shared', 'generated-validators.mjs'),
|
||||
validator.replace(/\r\n?|\n/g, '\r\n'),
|
||||
);
|
||||
|
||||
const result = spawnSync(process.execPath, [
|
||||
path.join(scratch, 'scripts', 'generate-validators.mjs'),
|
||||
'--check',
|
||||
], { encoding: 'utf8' });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
} finally {
|
||||
fs.rmSync(scratch, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { extractSvgs, parseXml } from './helpers/xml.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(skillRoot, '..');
|
||||
const artifactRoots = [
|
||||
'archify/examples',
|
||||
'docs',
|
||||
'examples',
|
||||
'experiments',
|
||||
];
|
||||
|
||||
function trackedHtmlArtifacts() {
|
||||
const tracked = spawnSync('git', ['ls-files', '-z', '--', ...artifactRoots], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'buffer',
|
||||
});
|
||||
assert.equal(tracked.status, 0, tracked.stderr.toString());
|
||||
return tracked.stdout.toString()
|
||||
.split('\0')
|
||||
.filter((entry) => entry.endsWith('.html'))
|
||||
.sort();
|
||||
}
|
||||
|
||||
test('artifact SVG extraction follows HTML quoting and preserves SVG document boundaries', () => {
|
||||
const extracted = extractSvgs(`
|
||||
<script>const ignored = '<svg data-node-label></svg>';</script>
|
||||
<template><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"/></template>
|
||||
<iframe srcdoc='<svg xmlns="http://www.w3.org/2000/svg"><svg viewBox="0 0 1 1"/></svg>'></iframe>
|
||||
`);
|
||||
assert.equal(extracted.direct.length, 1, 'template SVG is markup while script text is not');
|
||||
assert.equal(extracted.embedded.length, 1, 'srcdoc contributes one top-level SVG document');
|
||||
for (const svg of [...extracted.direct, ...extracted.embedded]) assert.doesNotThrow(() => parseXml(svg));
|
||||
|
||||
const inheritedNamespace = extractSvgs(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<svg><use xlink:href="#icon"/></svg>
|
||||
</svg>
|
||||
`);
|
||||
assert.equal(inheritedNamespace.direct.length, 1, 'nested SVG remains inside its XML document');
|
||||
assert.doesNotThrow(() => parseXml(inheritedNamespace.direct[0]));
|
||||
});
|
||||
|
||||
test('tracked browsable HTML embeds well-formed XML SVG', () => {
|
||||
const artifacts = trackedHtmlArtifacts();
|
||||
const checkoutArtifact = 'examples/checkout-platform-delta.html';
|
||||
assert.ok(artifacts.includes(checkoutArtifact), 'expected the tracked Checkout compare artifact');
|
||||
let checkoutSvgs;
|
||||
|
||||
for (const relative of artifacts) {
|
||||
const html = fs.readFileSync(path.join(repoRoot, relative), 'utf8');
|
||||
const extracted = extractSvgs(html);
|
||||
if (relative === checkoutArtifact) checkoutSvgs = extracted;
|
||||
const svgs = [...extracted.direct, ...extracted.embedded];
|
||||
if (svgs.length === 0) continue;
|
||||
for (const [index, svg] of svgs.entries()) {
|
||||
assert.doesNotThrow(
|
||||
() => parseXml(svg),
|
||||
`${relative}: SVG ${index + 1} must be well-formed XML`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(checkoutSvgs?.direct.length, 1, 'Checkout must contain one comparison SVG');
|
||||
assert.equal(checkoutSvgs?.embedded.length, 2, 'Checkout must retain its base/head SVG snapshots');
|
||||
});
|
||||
|
||||
test('legacy example URLs redirect to the current canonical artifacts', () => {
|
||||
for (const [legacy, canonical] of [
|
||||
['examples/workflow-agent-tool-call.html', 'workflow-agent-tool-call-rendered.html'],
|
||||
['examples/sequence-cache-miss.html', 'sequence-cache-miss-request.html'],
|
||||
]) {
|
||||
const html = fs.readFileSync(path.join(repoRoot, legacy), 'utf8');
|
||||
assert.match(html, new RegExp(`<link rel="canonical" href="${canonical}">`));
|
||||
assert.match(
|
||||
html,
|
||||
new RegExp(`window\\.location\\.replace\\("${canonical}" \\+ window\\.location\\.search \\+ window\\.location\\.hash\\)`),
|
||||
`${legacy} must preserve query parameters and deep-link fragments`,
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(repoRoot, 'examples', canonical)), true);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,739 @@
|
||||
// Unit tests for the pure geometry/text helpers that every renderer leans on.
|
||||
// These are exercised only transitively by the golden byte-compares, which
|
||||
// can't distinguish a geometry regression from an intentional layout change —
|
||||
// so they get a direct oracle here. Zero deps: node:test + node:assert.
|
||||
//
|
||||
// node --test test/*.test.mjs (or: npm test)
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
rectsOverlap,
|
||||
segmentIntersectsRect,
|
||||
segmentRectClearance,
|
||||
segmentRectIntersectionLength,
|
||||
collectLabelRouteClearance,
|
||||
cleanEndpointSideProblems,
|
||||
cleanFlowProblems,
|
||||
cleanCrossingProblems,
|
||||
collectAmbiguousCorridors,
|
||||
cleanAmbiguousCorridorProblems,
|
||||
collectBorderRuns,
|
||||
cleanBorderRunProblems,
|
||||
collectRouteRhythmIssues,
|
||||
cleanRouteRhythmProblems,
|
||||
routeBudgetMetrics,
|
||||
asArray,
|
||||
isFinitePoint,
|
||||
anchor,
|
||||
automaticPortRhythmBridge,
|
||||
defaultFromSide,
|
||||
defaultToSide,
|
||||
chosenSide,
|
||||
routeHonorsEndpointSides,
|
||||
polylinePath,
|
||||
roundedPath,
|
||||
labelPoint,
|
||||
suggestLabelObstacleFix,
|
||||
suggestComponentSeparation,
|
||||
} from '../renderers/shared/geometry.mjs';
|
||||
import { textUnits, applyTemplate, renderSemanticSigil } from '../renderers/shared/utils.mjs';
|
||||
|
||||
const rect = (x, y, w, h) => ({ x, y, width: w, height: h, cx: x + w / 2, cy: y + h / 2 });
|
||||
|
||||
test('automaticPortRhythmBridge: near parallel ports use readable outside runs', () => {
|
||||
const points = automaticPortRhythmBridge(
|
||||
[742, 300],
|
||||
[735, 180],
|
||||
'top',
|
||||
'bottom',
|
||||
);
|
||||
|
||||
assert.deepEqual(points, [
|
||||
[742, 300],
|
||||
[742, 276],
|
||||
[758, 276],
|
||||
[758, 204],
|
||||
[735, 204],
|
||||
[735, 180],
|
||||
]);
|
||||
assert.deepEqual(collectRouteRhythmIssues({
|
||||
routedRelations: [{ relation: { id: 'read' }, points }],
|
||||
}), []);
|
||||
});
|
||||
|
||||
test('rectsOverlap: separated rects do not overlap', () => {
|
||||
assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(20, 0, 10, 10)), false);
|
||||
});
|
||||
|
||||
test('rectsOverlap: clearly overlapping rects overlap', () => {
|
||||
assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(5, 5, 10, 10)), true);
|
||||
});
|
||||
|
||||
test('rectsOverlap: edge-touching is NOT overlap at gap 0 (<= boundary)', () => {
|
||||
// a ends at x=10, b starts at x=10 — exactly touching.
|
||||
assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(10, 0, 10, 10), 0), false);
|
||||
});
|
||||
|
||||
test('rectsOverlap: positive gap flags rects within that gap as too close', () => {
|
||||
// 8px apart, required gap 8 → touching the threshold counts as too close.
|
||||
assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(18, 0, 10, 10), 8), false);
|
||||
assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(17, 0, 10, 10), 8), true);
|
||||
});
|
||||
|
||||
test('rectsOverlap: negative gap shrinks the hit box (label-collision convention)', () => {
|
||||
// gap -2 means rects must overlap by MORE than 2px to count — a 1px sliver
|
||||
// does not. This is the sign convention the label checks rely on.
|
||||
assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(9, 0, 10, 10), -2), false);
|
||||
assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(7, 0, 10, 10), -2), true);
|
||||
});
|
||||
|
||||
test('rectsOverlap: non-finite geometry is not an overlap', () => {
|
||||
// A component authored without pos lands here as NaN. Every comparison in the
|
||||
// negated form is false for NaN, so the unguarded version reported a collision
|
||||
// for every pair and buried the real "must include pos" diagnostic.
|
||||
const nan = rect(Number.NaN, Number.NaN, 120, 60);
|
||||
assert.equal(rectsOverlap(nan, nan, 8), false);
|
||||
assert.equal(rectsOverlap(nan, rect(0, 0, 10, 10), 8), false);
|
||||
assert.equal(rectsOverlap(rect(0, 0, 10, 10), nan, 8), false);
|
||||
assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(20, 0, Number.NaN, 10)), false);
|
||||
assert.equal(rectsOverlap(rect(0, 0, 10, 10), rect(5, 5, 10, Number.POSITIVE_INFINITY)), false);
|
||||
});
|
||||
|
||||
test('segmentIntersectsRect: detects an edge crossing a node box', () => {
|
||||
assert.equal(segmentIntersectsRect({ start: [0, 5], end: [20, 5] }, rect(8, 0, 4, 10)), true);
|
||||
assert.equal(segmentIntersectsRect({ start: [0, 20], end: [20, 20] }, rect(8, 0, 4, 10)), false);
|
||||
});
|
||||
|
||||
test('segmentRectClearance measures horizontal, vertical, and reversed diagonal segments', () => {
|
||||
const box = rect(10, 10, 10, 10);
|
||||
assert.equal(segmentRectClearance({ start: [0, 6], end: [30, 6] }, box), 4);
|
||||
assert.equal(segmentRectClearance({ start: [6, 0], end: [6, 30] }, box), 4);
|
||||
assert.equal(segmentRectClearance({ start: [0, 0], end: [8, 8] }, box), Math.sqrt(8));
|
||||
assert.equal(segmentRectClearance({ start: [8, 8], end: [0, 0] }, box), Math.sqrt(8));
|
||||
assert.equal(segmentRectClearance({ start: [0, 15], end: [30, 15] }, box), 0);
|
||||
});
|
||||
|
||||
test('label-route clearance locks tangent, sub-threshold, boundary, and reversed coordinates', () => {
|
||||
const box = rect(10, 10, 10, 10);
|
||||
const cases = [
|
||||
{ segment: { start: [0, 10], end: [30, 10] }, clearance: 0, intersection: 10 },
|
||||
{ segment: { start: [10, 0], end: [10, 30] }, clearance: 0, intersection: 10 },
|
||||
{ segment: { start: [0, 0], end: [30, 30] }, clearance: 0, intersection: Math.sqrt(200) },
|
||||
{ segment: { start: [0, 0], end: [10, 10] }, clearance: 0, intersection: 0 },
|
||||
{ segment: { start: [0, 8.1], end: [30, 8.1] }, clearance: 1.9, intersection: 0 },
|
||||
{ segment: { start: [0, 8], end: [30, 8] }, clearance: 2, intersection: 0 },
|
||||
{ segment: { start: [0, 6.1], end: [30, 6.1] }, clearance: 3.9, intersection: 0 },
|
||||
{ segment: { start: [0, 6], end: [30, 6] }, clearance: 4, intersection: 0 },
|
||||
{ segment: { start: [0, 0], end: [5, 0] }, clearance: Math.sqrt(125), intersection: 0 },
|
||||
];
|
||||
for (const { segment, clearance, intersection } of cases) {
|
||||
assert.ok(Math.abs(segmentRectClearance(segment, box) - clearance) < 0.000001);
|
||||
assert.ok(Math.abs(segmentRectIntersectionLength(segment, box) - intersection) < 0.000001);
|
||||
const reversed = { start: segment.end, end: segment.start };
|
||||
assert.ok(Math.abs(segmentRectClearance(reversed, box) - clearance) < 0.000001);
|
||||
assert.ok(Math.abs(segmentRectIntersectionLength(reversed, box) - intersection) < 0.000001);
|
||||
}
|
||||
});
|
||||
|
||||
test('collectLabelRouteClearance exempts only the owning relationship at an exact threshold', () => {
|
||||
const owner = { id: 'owner', from: 'a', to: 'b' };
|
||||
const sharedSource = { id: 'other', from: 'a', to: 'c' };
|
||||
const labels = [{ relation: owner, relationIndex: 0, label: 'handoff', ...rect(80, 48, 60, 14) }];
|
||||
const routedRelations = [
|
||||
{ relation: owner, relationIndex: 0, points: [[20, 60], [200, 60]] },
|
||||
{ relation: sharedSource, relationIndex: 1, points: [[70, 64], [150, 64]] },
|
||||
];
|
||||
assert.deepEqual(collectLabelRouteClearance({ labels, routedRelations, threshold: 2 }), []);
|
||||
const hits = collectLabelRouteClearance({ labels, routedRelations, threshold: 4 });
|
||||
assert.equal(hits.length, 1);
|
||||
assert.equal(hits[0].clearance, 2);
|
||||
assert.equal(hits[0].otherRelation, sharedSource);
|
||||
});
|
||||
|
||||
test('endpoint-side direction distinguishes perpendicular entry from a tangent border run', () => {
|
||||
const clean = [[350, 160], [350, 200], [150, 200], [150, 240]];
|
||||
const tangent = [[350, 160], [350, 200], [100, 200], [100, 240], [150, 240]];
|
||||
assert.equal(routeHonorsEndpointSides(clean, 'bottom', 'top'), true);
|
||||
assert.equal(routeHonorsEndpointSides(tangent, 'bottom', 'top'), false);
|
||||
|
||||
const relation = { id: 'tasks-file', from: 'cli-agents', to: 'tasks-watch', fromSide: 'bottom', toSide: 'top' };
|
||||
const problems = cleanEndpointSideProblems({
|
||||
relations: [relation],
|
||||
endpointIds: new Set(['cli-agents', 'tasks-watch']),
|
||||
pathFor: () => ({ points: tangent }),
|
||||
diagramType: 'architecture',
|
||||
relationCollection: 'connections',
|
||||
});
|
||||
assert.equal(problems.length, 1);
|
||||
assert.match(problems[0], /\[clean-flow\/endpoint-side-direction\] architecture connections\[0\] id "tasks-file"/);
|
||||
assert.match(problems[0], /final segment 3 \[100, 240\] -> \[150, 240\]/);
|
||||
assert.match(problems[0], /toSide "top".*vertical downward from above/);
|
||||
});
|
||||
|
||||
test('endpoint-side direction can fail closed on renderer-inferred automatic sides', () => {
|
||||
const relation = { id: 'terminal-return', from: 'stream-hub', to: 'workspace' };
|
||||
const problems = cleanEndpointSideProblems({
|
||||
relations: [relation],
|
||||
endpointIds: new Set(['stream-hub', 'workspace']),
|
||||
pathFor: () => ({ points: [[700, 130], [700, 230], [160, 230], [160, 330]] }),
|
||||
diagramType: 'architecture',
|
||||
relationCollection: 'connections',
|
||||
fromSideFor: () => 'left',
|
||||
toSideFor: () => 'right',
|
||||
});
|
||||
assert.equal(problems.length, 2);
|
||||
assert.match(problems[0], /inferred fromSide "left"/);
|
||||
assert.match(problems[1], /inferred toSide "right"/);
|
||||
});
|
||||
|
||||
test('cleanFlowProblems reports collection index, ids, segment, clearance, and fix', () => {
|
||||
const relations = [{ id: 'checkout', from: 'client', to: 'database' }];
|
||||
const obstacles = [
|
||||
{ id: 'client', ...rect(0, 0, 20, 20) },
|
||||
{ id: 'proxy', ...rect(40, 0, 20, 20) },
|
||||
{ id: 'database', ...rect(80, 0, 20, 20) },
|
||||
];
|
||||
const problems = cleanFlowProblems({
|
||||
relations,
|
||||
obstacles,
|
||||
pathFor: () => ({ points: [[20, 10], [80, 10]] }),
|
||||
diagramType: 'architecture',
|
||||
relationCollection: 'connections',
|
||||
obstacleKind: 'component',
|
||||
routeHint: 'set route/via'
|
||||
});
|
||||
assert.equal(problems.length, 1);
|
||||
assert.match(problems[0], /\[clean-flow\/edge-through-node\] architecture connections\[0\] id "checkout" "client" -> "database"/);
|
||||
assert.match(problems[0], /crosses component "proxy"/);
|
||||
assert.match(problems[0], /segment 0 \[20, 10\] -> \[80, 10\] \(2px clearance\)/);
|
||||
assert.match(problems[0], /set route\/via/);
|
||||
});
|
||||
|
||||
test('cleanFlowProblems exempts endpoints and ignores missing endpoint geometry', () => {
|
||||
const endpointOnly = cleanFlowProblems({
|
||||
relations: [{ from: 'a', to: 'b' }],
|
||||
obstacles: [{ id: 'a', ...rect(0, 0, 20, 20) }, { id: 'b', ...rect(80, 0, 20, 20) }],
|
||||
pathFor: () => ({ points: [[20, 10], [80, 10]] }),
|
||||
diagramType: 'workflow',
|
||||
relationCollection: 'edges',
|
||||
obstacleKind: 'node'
|
||||
});
|
||||
assert.deepEqual(endpointOnly, []);
|
||||
|
||||
let pathCalled = false;
|
||||
const missingEndpoint = cleanFlowProblems({
|
||||
relations: [{ from: 'a', to: 'ghost' }],
|
||||
obstacles: [{ id: 'a', ...rect(0, 0, 20, 20) }],
|
||||
pathFor: () => { pathCalled = true; return { points: [] }; },
|
||||
diagramType: 'workflow',
|
||||
relationCollection: 'edges',
|
||||
obstacleKind: 'node'
|
||||
});
|
||||
assert.deepEqual(missingEndpoint, []);
|
||||
assert.equal(pathCalled, false);
|
||||
});
|
||||
|
||||
test('cleanFlowProblems uses clearance, reports the first segment, and deduplicates an obstacle', () => {
|
||||
const problems = cleanFlowProblems({
|
||||
relations: [{ from: 'a', to: 'b' }],
|
||||
obstacles: [
|
||||
{ id: 'a', ...rect(-20, -10, 20, 20) },
|
||||
{ id: 'near', ...rect(8, 1, 4, 2) },
|
||||
{ id: 'b', ...rect(20, -10, 20, 20) },
|
||||
],
|
||||
// Both segment 0 (within the 2px halo) and segment 2 intersect `near`.
|
||||
pathFor: () => ({ points: [[0, -1], [20, -1], [0, 5], [20, 5]] }),
|
||||
diagramType: 'workflow',
|
||||
relationCollection: 'edges',
|
||||
obstacleKind: 'node'
|
||||
});
|
||||
assert.equal(problems.length, 1);
|
||||
assert.match(problems[0], /segment 0 \[0, -1\] -> \[20, -1\]/);
|
||||
});
|
||||
|
||||
test('cleanCrossingProblems reports one deterministic proper X in showcase', () => {
|
||||
const first = { id: 'first', from: 'a', to: 'b' };
|
||||
const second = { id: 'second', from: 'c', to: 'd' };
|
||||
const routes = new Map([
|
||||
[first, { points: [[0, 0], [100, 0], [100, 100]] }],
|
||||
[second, { points: [[50, -50], [50, 50], [150, 50], [150, -50], [50, -50]] }],
|
||||
]);
|
||||
const problems = cleanCrossingProblems({
|
||||
relations: [first, second],
|
||||
endpointIds: new Set(['a', 'b', 'c', 'd']),
|
||||
pathFor: (relation) => routes.get(relation),
|
||||
diagramType: 'architecture',
|
||||
relationCollection: 'connections',
|
||||
profile: 'showcase',
|
||||
routeHint: 'move a via point',
|
||||
});
|
||||
assert.equal(problems.length, 1);
|
||||
assert.match(problems[0], /\[composition\/proper-crossing\] showcase architecture/);
|
||||
assert.match(problems[0], /connections\[0\] id "first" "a" -> "b" crosses connections\[1\] id "second" "c" -> "d"/);
|
||||
assert.match(problems[0], /at \[50, 0\] \(segments 0 and 0\)/);
|
||||
assert.match(problems[0], /move a via point/);
|
||||
});
|
||||
|
||||
test('cleanCrossingProblems keeps proper X as non-blocking in standard', () => {
|
||||
const relations = [{ from: 'a', to: 'b' }, { from: 'c', to: 'd' }];
|
||||
const routes = [[[0, 50], [100, 50]], [[50, 0], [50, 100]]];
|
||||
const problems = cleanCrossingProblems({
|
||||
relations,
|
||||
endpointIds: new Set(['a', 'b', 'c', 'd']),
|
||||
pathFor: (relation) => ({ points: routes[relations.indexOf(relation)] }),
|
||||
diagramType: 'workflow',
|
||||
relationCollection: 'edges',
|
||||
profile: 'standard',
|
||||
});
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
|
||||
test('cleanCrossingProblems exempts shared endpoints', () => {
|
||||
const relations = [{ from: 'a', to: 'b' }, { from: 'a', to: 'c' }];
|
||||
const routes = [[[0, 50], [100, 50]], [[50, 0], [50, 100]]];
|
||||
const problems = cleanCrossingProblems({
|
||||
relations,
|
||||
endpointIds: new Set(['a', 'b', 'c']),
|
||||
pathFor: (relation) => ({ points: routes[relations.indexOf(relation)] }),
|
||||
diagramType: 'dataflow',
|
||||
relationCollection: 'flows',
|
||||
profile: 'showcase',
|
||||
});
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
|
||||
test('cleanCrossingProblems exempts endpoint touches and collinear corridors', () => {
|
||||
const relations = [
|
||||
{ from: 'a', to: 'b' },
|
||||
{ from: 'c', to: 'd' },
|
||||
{ from: 'e', to: 'f' },
|
||||
];
|
||||
const routes = [
|
||||
[[0, 0], [100, 0]],
|
||||
[[50, 0], [50, 50]],
|
||||
[[25, 0], [75, 0]],
|
||||
];
|
||||
const problems = cleanCrossingProblems({
|
||||
relations,
|
||||
endpointIds: new Set(['a', 'b', 'c', 'd', 'e', 'f']),
|
||||
pathFor: (relation) => ({ points: routes[relations.indexOf(relation)] }),
|
||||
diagramType: 'lifecycle',
|
||||
relationCollection: 'transitions',
|
||||
profile: 'showcase',
|
||||
});
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
|
||||
test('ambiguous corridor gate reports unrelated collinear overlap with exact identities', () => {
|
||||
const first = { id: 'first', from: 'a', to: 'b' };
|
||||
const second = { id: 'second', from: 'c', to: 'd' };
|
||||
const routes = new Map([
|
||||
[first, { points: [[0, 20], [100, 20], [100, 80]] }],
|
||||
[second, { points: [[40, 20], [140, 20], [140, 80]] }],
|
||||
]);
|
||||
const hits = collectAmbiguousCorridors({
|
||||
routedRelations: [first, second].map((relation, relationIndex) => ({
|
||||
relation,
|
||||
relationIndex,
|
||||
points: routes.get(relation).points,
|
||||
})),
|
||||
});
|
||||
assert.equal(hits.length, 1);
|
||||
assert.equal(hits[0].overlapLength, 60);
|
||||
assert.deepEqual(hits[0].overlapStart, [40, 20]);
|
||||
assert.deepEqual(hits[0].overlapEnd, [100, 20]);
|
||||
|
||||
const problems = cleanAmbiguousCorridorProblems({
|
||||
relations: [first, second],
|
||||
endpointIds: new Set(['a', 'b', 'c', 'd']),
|
||||
pathFor: (relation) => routes.get(relation),
|
||||
diagramType: 'workflow',
|
||||
relationCollection: 'edges',
|
||||
profile: 'showcase',
|
||||
routeHint: 'move a channel',
|
||||
});
|
||||
assert.equal(problems.length, 1);
|
||||
assert.match(problems[0], /\[composition\/ambiguous-corridor\] showcase workflow/);
|
||||
assert.match(problems[0], /edges\[0\] id "first" "a" -> "b" shares a 60px corridor with edges\[1\] id "second" "c" -> "d"/);
|
||||
assert.match(problems[0], /\[40, 20\] -> \[100, 20\].*move a channel/);
|
||||
});
|
||||
|
||||
test('ambiguous corridor gate exempts shared endpoints, point touches, and overlaps below 8px', () => {
|
||||
const routedRelations = [
|
||||
{ relation: { from: 'a', to: 'b' }, relationIndex: 0, points: [[0, 20], [100, 20]] },
|
||||
{ relation: { from: 'a', to: 'c' }, relationIndex: 1, points: [[40, 20], [140, 20]] },
|
||||
{ relation: { from: 'd', to: 'e' }, relationIndex: 2, points: [[100, 20], [100, 80]] },
|
||||
{ relation: { from: 'f', to: 'g' }, relationIndex: 3, points: [[94, 60], [101, 60]] },
|
||||
{ relation: { from: 'h', to: 'i' }, relationIndex: 4, points: [[98, 60], [110, 60]] },
|
||||
];
|
||||
assert.deepEqual(collectAmbiguousCorridors({ routedRelations }), []);
|
||||
});
|
||||
|
||||
test('ambiguous corridor gate keeps standard renderable', () => {
|
||||
const relations = [{ from: 'a', to: 'b' }, { from: 'c', to: 'd' }];
|
||||
const routes = [[[0, 20], [100, 20]], [[40, 20], [140, 20]]];
|
||||
assert.deepEqual(cleanAmbiguousCorridorProblems({
|
||||
relations,
|
||||
endpointIds: new Set(['a', 'b', 'c', 'd']),
|
||||
pathFor: (relation) => ({ points: routes[relations.indexOf(relation)] }),
|
||||
diagramType: 'architecture',
|
||||
relationCollection: 'connections',
|
||||
profile: 'standard',
|
||||
}), []);
|
||||
});
|
||||
|
||||
test('cleanBorderRunProblems reports a deterministic long run on a rounded frame side', () => {
|
||||
const relation = { id: 'jwt', from: 'auth', to: 'api' };
|
||||
const problems = cleanBorderRunProblems({
|
||||
relations: [relation],
|
||||
frames: [{ id: 'private', label: 'Private tier', kind: 'security-group', x: 100, y: 80, width: 180, height: 120, radius: 8 }],
|
||||
pathFor: () => ({ points: [[40, 80], [220, 80], [220, 140]] }),
|
||||
diagramType: 'architecture',
|
||||
relationCollection: 'connections',
|
||||
profile: 'standard',
|
||||
routeHint: 'move the via point',
|
||||
});
|
||||
assert.equal(problems.length, 1);
|
||||
assert.match(problems[0], /\[composition\/container-border-run\] architecture connections\[0\] id "jwt" "auth" -> "api"/);
|
||||
assert.match(problems[0], /follows security-group "Private tier" top border for 112px on segment 0 \[108, 80\] -> \[220, 80\]/);
|
||||
assert.match(problems[0], /move the via point/);
|
||||
});
|
||||
|
||||
test('border-run contract allows perpendicular crossings, point touches, and rounded corners', () => {
|
||||
const frame = { id: 'stage', kind: 'stage', x: 40, y: 40, width: 120, height: 100, radius: 10 };
|
||||
const routedRelations = [
|
||||
{ relation: { from: 'a', to: 'b' }, relationIndex: 0, points: [[100, 10], [100, 80]] },
|
||||
{ relation: { from: 'c', to: 'd' }, relationIndex: 1, points: [[20, 40], [40, 40], [40, 20]] },
|
||||
{ relation: { from: 'e', to: 'f' }, relationIndex: 2, points: [[40, 40], [49, 40]] },
|
||||
];
|
||||
assert.deepEqual(collectBorderRuns({ routedRelations, frames: [frame] }), []);
|
||||
});
|
||||
|
||||
test('border-run contract detects vertical frames and merges hits per relation side', () => {
|
||||
const hits = collectBorderRuns({
|
||||
routedRelations: [{
|
||||
relation: { from: 'a', to: 'b' },
|
||||
relationIndex: 3,
|
||||
points: [[160, 60], [160, 110], [150, 110], [160, 110], [160, 135]],
|
||||
}],
|
||||
frames: [{ kind: 'lane', id: 'lane-1', x: 40, y: 40, width: 120, height: 100, radius: 10 }],
|
||||
});
|
||||
assert.equal(hits.length, 1);
|
||||
assert.equal(hits[0].side, 'right');
|
||||
assert.equal(hits[0].segmentIndex, 0);
|
||||
assert.equal(hits[0].overlapLength, 70);
|
||||
});
|
||||
|
||||
test('border-run contract merges adjacent primitives and counts any positive straight overlap', () => {
|
||||
const hits = collectBorderRuns({
|
||||
routedRelations: [{
|
||||
relation: { from: 'a', to: 'b' },
|
||||
relationIndex: 0,
|
||||
points: [[52, 40], [70, 40], [90, 40], [90, 50]],
|
||||
}],
|
||||
frames: [{ kind: 'stage', id: 'source', x: 40, y: 40, width: 120, height: 100, radius: 10 }],
|
||||
});
|
||||
assert.equal(hits.length, 1);
|
||||
assert.equal(hits[0].overlapLength, 38);
|
||||
assert.deepEqual(hits[0].overlapStart, [52, 40]);
|
||||
assert.deepEqual(hits[0].overlapEnd, [90, 40]);
|
||||
});
|
||||
|
||||
test('routeBudgetMetrics normalizes collinear points and records neutral route evidence', () => {
|
||||
const metrics = routeBudgetMetrics({
|
||||
routedRelations: [
|
||||
{ points: [[0, 0], [10, 0], [30, 0], [30, 8], [50, 8], [50, 30]] },
|
||||
{ points: [[5, 5], [5, 5]] },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(metrics, {
|
||||
maxBends: 3,
|
||||
routesOverSuggestedBends: 1,
|
||||
maxStretch: 80 / 80,
|
||||
routesOverSuggestedStretch: 0,
|
||||
minSegmentPx: 8,
|
||||
minInteriorSegmentPx: 8,
|
||||
shortSegmentCount: 1,
|
||||
shortEndpointSegmentCount: 0,
|
||||
shortInteriorSegmentCount: 1,
|
||||
microSegmentCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('route rhythm separates ordinary endpoint stubs from cramped turns and micro segments', () => {
|
||||
const issues = collectRouteRhythmIssues({
|
||||
routedRelations: [
|
||||
{ relation: { id: 'lane-hop', from: 'a', to: 'b' }, points: [[0, 0], [13, 0], [13, 40], [80, 40], [80, 53]] },
|
||||
{ relation: { id: 'bad-turn', from: 'c', to: 'd' }, points: [[0, 80], [24, 80], [24, 89], [60, 89]] },
|
||||
{ relation: { id: 'micro-stub', from: 'e', to: 'f' }, points: [[0, 120], [5, 120], [5, 180]] },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(issues.map((issue) => [issue.relation.id, issue.code, issue.position, issue.length]), [
|
||||
['bad-turn', 'composition/short-interior-segment', 'interior', 9],
|
||||
['micro-stub', 'composition/micro-segment', 'source-stub', 5],
|
||||
]);
|
||||
});
|
||||
|
||||
test('route rhythm is a showcase-only generation gate with actionable relationship identity', () => {
|
||||
const relations = [{ id: 'events', from: 'api', to: 'bus' }];
|
||||
const args = {
|
||||
relations,
|
||||
endpointIds: new Set(['api', 'bus']),
|
||||
pathFor: () => ({ points: [[10, 20], [15, 20], [15, 80]] }),
|
||||
diagramType: 'architecture',
|
||||
relationCollection: 'connections',
|
||||
};
|
||||
assert.deepEqual(cleanRouteRhythmProblems({ ...args, profile: 'standard' }), []);
|
||||
const problems = cleanRouteRhythmProblems({ ...args, profile: 'showcase' });
|
||||
assert.equal(problems.length, 1);
|
||||
assert.match(problems[0], /\[composition\/micro-segment\] showcase architecture connections\[0\] id "events"/);
|
||||
assert.match(problems[0], /5px source-stub segment 0/);
|
||||
});
|
||||
|
||||
test('asArray coerces non-arrays to [] (degraded-mode guard)', () => {
|
||||
assert.deepEqual(asArray([1, 2]), [1, 2]);
|
||||
assert.deepEqual(asArray('oops'), []);
|
||||
assert.deepEqual(asArray(undefined), []);
|
||||
assert.deepEqual(asArray(null), []);
|
||||
assert.deepEqual(asArray({ length: 3 }), []);
|
||||
});
|
||||
|
||||
test('isFinitePoint rejects NaN/undefined/Infinity', () => {
|
||||
assert.equal(isFinitePoint(1, 2, 3, 4), true);
|
||||
assert.equal(isFinitePoint(1, NaN), false);
|
||||
assert.equal(isFinitePoint(1, undefined), false);
|
||||
assert.equal(isFinitePoint(1, Infinity), false);
|
||||
});
|
||||
|
||||
test('anchor returns the correct edge midpoint for each side', () => {
|
||||
const r = rect(100, 100, 40, 20); // cx=120 cy=110
|
||||
assert.deepEqual(anchor(r, 'left'), [100, 110]);
|
||||
assert.deepEqual(anchor(r, 'right'), [140, 110]);
|
||||
assert.deepEqual(anchor(r, 'top'), [120, 100]);
|
||||
assert.deepEqual(anchor(r, 'bottom'), [120, 120]);
|
||||
});
|
||||
|
||||
test('anchor falls back to the right edge for unknown/auto sides', () => {
|
||||
const r = rect(100, 100, 40, 20);
|
||||
assert.deepEqual(anchor(r, 'auto'), [140, 110]);
|
||||
assert.deepEqual(anchor(r, undefined), [140, 110]);
|
||||
});
|
||||
|
||||
test('defaultFromSide / defaultToSide are mirror pairs', () => {
|
||||
const a = { cx: 0, cy: 0 };
|
||||
const right = { cx: 100, cy: 0 };
|
||||
assert.equal(defaultFromSide(a, right), 'right');
|
||||
assert.equal(defaultToSide(a, right), 'left');
|
||||
const below = { cx: 0, cy: 100 };
|
||||
assert.equal(defaultFromSide(a, below), 'bottom');
|
||||
assert.equal(defaultToSide(a, below), 'top');
|
||||
});
|
||||
|
||||
test('chosenSide treats explicit "auto" as "use the geometric fallback"', () => {
|
||||
assert.equal(chosenSide('left', 'right'), 'left');
|
||||
assert.equal(chosenSide('auto', 'right'), 'right');
|
||||
assert.equal(chosenSide(undefined, 'right'), 'right');
|
||||
});
|
||||
|
||||
test('polylinePath emits M then L commands', () => {
|
||||
assert.equal(polylinePath([[0, 0], [10, 0], [10, 10]]), 'M 0 0 L 10 0 L 10 10');
|
||||
});
|
||||
|
||||
test('roundedPath degrades to a polyline for <3 points or radius<=0', () => {
|
||||
assert.equal(roundedPath([[0, 0], [10, 0]], 10), 'M 0 0 L 10 0');
|
||||
assert.equal(roundedPath([[0, 0], [10, 0], [10, 10]], 0), 'M 0 0 L 10 0 L 10 10');
|
||||
});
|
||||
|
||||
test('roundedPath inserts a quadratic corner and never emits NaN', () => {
|
||||
const d = roundedPath([[0, 0], [100, 0], [100, 100]], 10);
|
||||
assert.match(d, /Q 100 0/); // corner pivots on the bend point
|
||||
assert.doesNotMatch(d, /NaN/);
|
||||
});
|
||||
|
||||
test('roundedPath clamps radius to half the shorter adjacent segment', () => {
|
||||
// 6px segments with radius 10 → r clamps to 3; no overshoot / NaN.
|
||||
const d = roundedPath([[0, 0], [6, 0], [6, 6]], 10);
|
||||
assert.doesNotMatch(d, /NaN/);
|
||||
assert.match(d, /^M 0 0/);
|
||||
});
|
||||
|
||||
test('labelPoint: 2-point path is the midpoint lifted 10px, plus offsets', () => {
|
||||
assert.deepEqual(labelPoint({}, [[0, 100], [100, 100]]), [50, 90]);
|
||||
assert.deepEqual(labelPoint({ labelDx: 5, labelDy: -4 }, [[0, 100], [100, 100]]), [55, 86]);
|
||||
});
|
||||
|
||||
test('labelPoint: labelSegment selects a segment and clamps to range', () => {
|
||||
const pts = [[0, 0], [100, 0], [100, 100], [200, 100]];
|
||||
// segment 0 → midpoint of pts[0],pts[1] = (50,0) lifted 10
|
||||
assert.deepEqual(labelPoint({ labelSegment: 0 }, pts), [50, -10]);
|
||||
// segment 99 clamps to the last segment
|
||||
assert.deepEqual(labelPoint({ labelSegment: 99 }, pts), [150, 90]);
|
||||
});
|
||||
|
||||
test('labelPoint: explicit labelAt wins outright', () => {
|
||||
assert.deepEqual(labelPoint({ labelAt: [7, 8] }, [[0, 0], [100, 0]]), [7, 8]);
|
||||
});
|
||||
|
||||
test('textUnits: ASCII=1, CJK=2, mixed sums, fullwidth supplementary=2', () => {
|
||||
assert.equal(textUnits('abc'), 3);
|
||||
assert.equal(textUnits('中文'), 4);
|
||||
assert.equal(textUnits('a中'), 3);
|
||||
assert.equal(textUnits(''), 0);
|
||||
assert.equal(textUnits(null), 0);
|
||||
assert.equal(textUnits('𠀀'), 2); // CJK Ext-B (supplementary plane)
|
||||
assert.equal(textUnits('🚀'), 2); // emoji
|
||||
assert.equal(textUnits('注入提示词'), 10); // issue #14 original label
|
||||
assert.equal(textUnits('!@#012'), 12); // fullwidth punctuation + digits
|
||||
});
|
||||
|
||||
test('textUnits follows wide and halfwidth East Asian presentation boundaries', () => {
|
||||
assert.equal(textUnits('あカ'), 4); // Hiragana + Katakana are wide
|
||||
assert.equal(textUnits('ㄅㆠ'), 4); // Bopomofo + extended Bopomofo are wide
|
||||
assert.equal(textUnits('ㄱ'), 2); // Hangul compatibility letter is wide
|
||||
assert.equal(textUnits('︐︙'), 4); // vertical punctuation forms are wide
|
||||
assert.equal(textUnits('カタカナ'), 4); // halfwidth Katakana stays one unit per glyph
|
||||
assert.equal(textUnits('ꥠ'), 2); // Hangul Jamo Extended-A is wide
|
||||
});
|
||||
|
||||
test('textUnits counts emoji-presentation symbols in the BMP as wide', () => {
|
||||
// These render at the same square advance as the supplementary-plane emoji,
|
||||
// so counting them as one unit under-measures a label and lets it overflow
|
||||
// its node while the layout receipt still reads clean.
|
||||
assert.equal(textUnits('✅'), 2);
|
||||
assert.equal(textUnits('⭐'), 2);
|
||||
assert.equal(textUnits('⚡'), 2);
|
||||
assert.equal(textUnits('⌛'), 2);
|
||||
assert.equal(textUnits('⏰'), 2);
|
||||
assert.equal(textUnits('⛔'), 2);
|
||||
assert.equal(textUnits('❗'), 2);
|
||||
assert.equal(textUnits('⬛'), 2);
|
||||
assert.equal(textUnits('☕'), 2);
|
||||
assert.equal(textUnits('♿'), 2);
|
||||
assert.equal(textUnits('✅ Done'), 7);
|
||||
// Narrow and ambiguous neighbours in the same blocks stay one unit.
|
||||
assert.equal(textUnits('→'), 1); // rightwards arrow
|
||||
assert.equal(textUnits('☎'), 1); // black telephone
|
||||
assert.equal(textUnits('①'), 1); // circled digit one
|
||||
// Unicode 16.0 moved these from Neutral to Wide.
|
||||
assert.equal(textUnits('☰'), 2); // trigram for heaven
|
||||
assert.equal(textUnits('☷'), 2); // trigram for earth
|
||||
assert.equal(textUnits('⚊'), 2); // monogram for yang
|
||||
assert.equal(textUnits('⚏'), 2); // digram for greater yin
|
||||
// Hangul Jamo Extended-A stops at its last assigned jamo; the unassigned
|
||||
// tail of the block defaults to Neutral.
|
||||
assert.equal(textUnits('ꥼ'), 2);
|
||||
assert.equal(textUnits(''), 1);
|
||||
});
|
||||
|
||||
test('textUnits measures a variation-selector sequence from the selector', () => {
|
||||
// VS16 asks for emoji presentation: the pair renders as one square, so it
|
||||
// must stay two units even though the base is now counted wide on its own.
|
||||
assert.equal(textUnits('⭐️'), 2); // star
|
||||
assert.equal(textUnits('✅️'), 2); // check mark button
|
||||
assert.equal(textUnits('☕️'), 2); // hot beverage
|
||||
assert.equal(textUnits('⚡️'), 2); // high voltage
|
||||
// Same rule the other way: a narrow base forced to emoji presentation
|
||||
// renders as a square and is two units, not one.
|
||||
assert.equal(textUnits('✈️'), 2); // airplane
|
||||
assert.equal(textUnits('❤️'), 2); // red heart
|
||||
// VS15 asks for text presentation, which renders narrow.
|
||||
assert.equal(textUnits('⭐︎'), 1);
|
||||
assert.equal(textUnits('✈︎'), 1);
|
||||
// The selector never adds width of its own, alone or in a run.
|
||||
assert.equal(textUnits('️'), 0);
|
||||
assert.equal(textUnits('✅️ Done'), 7);
|
||||
assert.equal(textUnits('⭐️⭐️'), 4);
|
||||
});
|
||||
|
||||
test('semantic sigils cover every component and lifecycle kind without literal color', () => {
|
||||
const kinds = [
|
||||
'frontend', 'backend', 'database', 'cloud', 'security', 'messagebus', 'external',
|
||||
'start', 'active', 'waiting', 'success', 'failure', 'neutral',
|
||||
];
|
||||
for (const kind of kinds) {
|
||||
const sigil = renderSemanticSigil(kind, { x: 12, y: 18 });
|
||||
assert.match(sigil, new RegExp(`data-semantic-sigil="${kind}"`), kind);
|
||||
assert.match(sigil, /aria-hidden="true"/, kind);
|
||||
assert.match(sigil, /class="semantic-sigil s-[a-z]+"/, kind);
|
||||
assert.match(sigil, /transform="translate\(12 18\) scale\(0\.6875\)"/, kind);
|
||||
assert.doesNotMatch(sigil, /#[0-9a-f]{3,8}|rgba?\(/i, kind);
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown semantic sigils fail closed to a neutral role stamp', () => {
|
||||
const sigil = renderSemanticSigil('vendor-logo', { x: 0, y: 0, size: 16 });
|
||||
assert.match(sigil, /data-semantic-sigil="neutral"/);
|
||||
assert.match(sigil, /class="semantic-sigil s-external"/);
|
||||
assert.match(sigil, /scale\(1\)/);
|
||||
});
|
||||
|
||||
test('suggestLabelObstacleFix includes rects and labelAt/labelDy hints', () => {
|
||||
const labelRect = { x: 100, y: 180, width: 48, height: 14, label: '写入' };
|
||||
const obstacle = { id: 'memtool', x: 30, y: 130, width: 230, height: 58 };
|
||||
const hint = suggestLabelObstacleFix(labelRect, 124, 188, obstacle);
|
||||
assert.match(hint, /label rect: \[100, 180, 48, 14\]/);
|
||||
assert.match(hint, /component "memtool"/);
|
||||
assert.match(hint, /Suggested fix: labelAt/);
|
||||
assert.match(hint, /labelDy \+\d+/);
|
||||
});
|
||||
|
||||
test('suggestComponentSeparation proposes nudged pos', () => {
|
||||
const a = { id: 'api', x: 100, y: 200, width: 120, height: 60 };
|
||||
const b = { id: 'db', x: 150, y: 200, width: 120, height: 60 };
|
||||
const hint = suggestComponentSeparation(a, b, 8);
|
||||
assert.match(hint, /move "db" pos to \[228, 200\]/);
|
||||
});
|
||||
|
||||
test('applyTemplate preserves dollar sequences in titles', () => {
|
||||
const template = `<html lang="en" data-theme="dark" data-preset="[VISUAL PRESET]">
|
||||
<title>[PROJECT NAME] Architecture Diagram</title>
|
||||
<h1>[PROJECT NAME] Architecture</h1>
|
||||
<p class="subtitle">[Subtitle description]</p>
|
||||
<!-- ARCHIFY:GUIDED_VIEWS_DATA -->
|
||||
<!-- ARCHIFY:SVG_SLOT_START --><svg></svg> <!-- ARCHIFY:SVG_SLOT_END -->
|
||||
<!-- ARCHIFY:CARDS_SLOT_START --><div></div> <!-- ARCHIFY:CARDS_SLOT_END -->`;
|
||||
const html = applyTemplate(template, {
|
||||
title: 'Plan $$50 tier',
|
||||
subtitle: 'test',
|
||||
svg: '<svg/>',
|
||||
cards: '',
|
||||
});
|
||||
assert.match(html, /Plan \$\$50 tier/);
|
||||
assert.match(html, /<p class="subtitle">test<\/p>/);
|
||||
});
|
||||
|
||||
test('applyTemplate omits the subtitle row when no subtitle is authored', () => {
|
||||
const template = `<html lang="en" data-theme="dark" data-preset="[VISUAL PRESET]">
|
||||
<title>[PROJECT NAME] Architecture Diagram</title>
|
||||
<h1>[PROJECT NAME] Architecture</h1>
|
||||
<p class="subtitle">[Subtitle description]</p>
|
||||
<!-- ARCHIFY:GUIDED_VIEWS_DATA -->
|
||||
<!-- ARCHIFY:SVG_SLOT_START --><svg></svg> <!-- ARCHIFY:SVG_SLOT_END -->
|
||||
<!-- ARCHIFY:CARDS_SLOT_START --><div></div> <!-- ARCHIFY:CARDS_SLOT_END -->`;
|
||||
const html = applyTemplate(template, {
|
||||
title: 'Focused title',
|
||||
subtitle: ' ',
|
||||
svg: '<svg/>',
|
||||
cards: '',
|
||||
});
|
||||
assert.doesNotMatch(html, /class="subtitle"/);
|
||||
assert.doesNotMatch(html, /Subtitle description/);
|
||||
});
|
||||
|
||||
test('applyTemplate requires the new evidence slot only when evidence is present', () => {
|
||||
const legacyTemplate = `<html lang="en" data-theme="dark" data-preset="[VISUAL PRESET]">
|
||||
<title>[PROJECT NAME] Architecture Diagram</title>
|
||||
<h1>[PROJECT NAME] Architecture</h1>
|
||||
<p class="subtitle">[Subtitle description]</p>
|
||||
<!-- ARCHIFY:GUIDED_VIEWS_DATA -->
|
||||
<!-- ARCHIFY:SVG_SLOT_START --><svg></svg> <!-- ARCHIFY:SVG_SLOT_END -->
|
||||
<!-- ARCHIFY:CARDS_SLOT_START --><div></div> <!-- ARCHIFY:CARDS_SLOT_END -->`;
|
||||
assert.doesNotThrow(() => applyTemplate(legacyTemplate, {
|
||||
title: 'Legacy', subtitle: '', svg: '<svg/>', cards: '',
|
||||
}));
|
||||
assert.throws(() => applyTemplate(legacyTemplate, {
|
||||
title: 'Evidence', subtitle: '', svg: '<svg/>', cards: '',
|
||||
sourceEvidence: { verified: true },
|
||||
}), /repository evidence requires placeholder/);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
// Golden-file harness for the archify renderers. No test framework needed:
|
||||
// renderers are deterministic, so fresh renders must match both checked-in
|
||||
// development and packaged example HTML aside from platform checkout line endings. Also covers schema enforcement (negative cases),
|
||||
// template freshness of the architecture-mode example, and version sync.
|
||||
//
|
||||
// Run from the skill folder: npm test
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(skillRoot, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-test-'));
|
||||
|
||||
let failures = 0;
|
||||
|
||||
function check(name, ok, detail) {
|
||||
if (ok) {
|
||||
console.log(` ok ${name}`);
|
||||
} else {
|
||||
failures += 1;
|
||||
console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
function render(mode, inputPath, outPath) {
|
||||
execFileSync('node', [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
inputPath,
|
||||
outPath,
|
||||
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
}
|
||||
|
||||
function normalizeNewlines(text) {
|
||||
return text.replace(/\r\n?/g, '\n');
|
||||
}
|
||||
|
||||
function shieldsBadgeMessages(source, label) {
|
||||
const marker = `/badge/${label}-`;
|
||||
const messages = [];
|
||||
let searchFrom = 0;
|
||||
while (searchFrom < source.length) {
|
||||
const start = source.indexOf(marker, searchFrom);
|
||||
if (start === -1) break;
|
||||
let cursor = start + marker.length;
|
||||
let message = '';
|
||||
while (cursor < source.length) {
|
||||
const character = source[cursor];
|
||||
const next = source[cursor + 1];
|
||||
if (character === '-' && next === '-') {
|
||||
message += '-';
|
||||
cursor += 2;
|
||||
} else if (character === '_' && next === '_') {
|
||||
message += '_';
|
||||
cursor += 2;
|
||||
} else if (character === '-') {
|
||||
break;
|
||||
} else {
|
||||
message += character;
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
try { messages.push(decodeURIComponent(message)); } catch { messages.push(message); }
|
||||
searchFrom = cursor + 1;
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log('golden renders (renderer output must match checked-in examples)');
|
||||
|
||||
const GOLDEN = [
|
||||
['workflow', 'agent-tool-call.workflow.json', 'workflow-agent-tool-call-rendered.html'],
|
||||
['sequence', 'cache-miss-request.sequence.json', 'sequence-cache-miss-request.html'],
|
||||
['dataflow', 'product-analytics.dataflow.json', 'dataflow-product-analytics.html'],
|
||||
['lifecycle', 'agent-run.lifecycle.json', 'lifecycle-agent-run.html'],
|
||||
['architecture', 'web-app.architecture.json', 'web-app-rendered.html'],
|
||||
];
|
||||
|
||||
for (const [mode, input, golden] of GOLDEN) {
|
||||
const out = path.join(tmp, golden);
|
||||
try {
|
||||
render(mode, path.join(skillRoot, 'examples', input), out);
|
||||
const fresh = fs.readFileSync(out, 'utf8');
|
||||
const checked = fs.readFileSync(path.join(repoRoot, 'examples', golden), 'utf8');
|
||||
const packaged = fs.readFileSync(path.join(skillRoot, 'examples', golden), 'utf8');
|
||||
check(`${mode}: ${golden}`, normalizeNewlines(fresh) === normalizeNewlines(checked),
|
||||
`fresh render differs from examples/${golden}; if the change is intentional, re-render the examples and commit them`);
|
||||
check(`${mode}: packaged ${golden}`, normalizeNewlines(fresh) === normalizeNewlines(packaged),
|
||||
`fresh render differs from archify/examples/${golden}; re-render the packaged examples and rebuild archify.zip`);
|
||||
} catch (err) {
|
||||
check(`${mode}: ${golden}`, false, String(err.stderr || err.message).slice(0, 300));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log('schema enforcement (invalid JSON must fail with a path-prefixed message)');
|
||||
|
||||
function expectFailure(name, mode, mutate, expectInMessage) {
|
||||
const base = JSON.parse(fs.readFileSync(
|
||||
path.join(skillRoot, 'examples', GOLDEN.find(([m]) => m === mode)[1]), 'utf8'));
|
||||
mutate(base);
|
||||
const input = path.join(tmp, `neg-${name.replace(/[^a-z0-9]+/gi, '-')}.json`);
|
||||
fs.writeFileSync(input, JSON.stringify(base));
|
||||
try {
|
||||
render(mode, input, path.join(tmp, 'neg-out.html'));
|
||||
check(name, false, 'renderer exited 0 on invalid input');
|
||||
} catch (err) {
|
||||
const message = String(err.stderr || err.message);
|
||||
check(name, message.includes(expectInMessage),
|
||||
`expected "${expectInMessage}" in:\n${message.slice(0, 300)}`);
|
||||
}
|
||||
}
|
||||
|
||||
expectFailure('card dot outside enum', 'workflow',
|
||||
(d) => { d.cards[0].dot = 'pink'; }, '/cards/0/dot');
|
||||
expectFailure('node id starting with a digit', 'workflow',
|
||||
(d) => { d.nodes[0].id = '1user'; }, 'pattern');
|
||||
expectFailure('extra property rejected', 'workflow',
|
||||
(d) => { d.nodes[0].colour = 'red'; }, 'additional properties');
|
||||
expectFailure('column beyond layout maximum', 'workflow',
|
||||
(d) => { d.nodes[0].col = 7; }, '<= 5');
|
||||
expectFailure('missing schema_version', 'sequence',
|
||||
(d) => { delete d.schema_version; }, 'schema_version');
|
||||
expectFailure('cross-lane state overlap', 'lifecycle',
|
||||
(d) => {
|
||||
const approval = d.states.find((s) => s.id === 'approval');
|
||||
const failed = d.states.find((s) => s.id === 'failed');
|
||||
delete failed.yOffset;
|
||||
failed.col = approval.col;
|
||||
}, 'less than 10px apart');
|
||||
expectFailure('zero component width rejected by schema', 'architecture',
|
||||
(d) => { d.components[0].size = [0, 60]; }, '/components/0/size/0');
|
||||
expectFailure('zero component height rejected by schema', 'architecture',
|
||||
(d) => { d.components[0].size = [120, 0]; }, '/components/0/size/1');
|
||||
expectFailure('negative component width rejected by schema', 'architecture',
|
||||
(d) => { d.components[0].size = [-1, 60]; }, '/components/0/size/0');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log('template freshness (architecture example must carry the current template)');
|
||||
|
||||
function blocks(html, tag) {
|
||||
const re = new RegExp(`<${tag}[^>]*>[\\s\\S]*?<\\/${tag}>`, 'g');
|
||||
return html.match(re) || [];
|
||||
}
|
||||
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets/template.html'), 'utf8');
|
||||
const webApp = fs.readFileSync(path.join(repoRoot, 'examples/web-app.html'), 'utf8');
|
||||
// <style> and <script> blocks pass through applyTemplate untouched, so the
|
||||
// architecture-mode example must contain them verbatim or it has drifted.
|
||||
for (const tag of ['style', 'script']) {
|
||||
// The guided-view JSON script is generated from meta.views; compare only
|
||||
// template-owned executable scripts, not per-diagram data payloads.
|
||||
const isTemplateOwned = (block) => !block.includes('type="application/json"');
|
||||
const t = blocks(template, tag).filter((b) => !b.includes('[PROJECT NAME]') && isTemplateOwned(b));
|
||||
const w = blocks(webApp, tag).filter((b) => !b.includes('Sample Web App') && isTemplateOwned(b));
|
||||
check(`web-app.html ${tag} blocks match template`,
|
||||
JSON.stringify(t) === JSON.stringify(w),
|
||||
'examples/web-app.html was generated from a stale template — re-derive it');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log('version sync');
|
||||
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(skillRoot, 'package.json'), 'utf8'));
|
||||
check('template generator meta matches package.json version',
|
||||
template.includes(`<meta name="generator" content="archify ${pkg.version}">`),
|
||||
`package.json says ${pkg.version}`);
|
||||
|
||||
const lock = JSON.parse(fs.readFileSync(path.join(skillRoot, 'package-lock.json'), 'utf8'));
|
||||
check('package-lock.json version matches package.json',
|
||||
lock.version === pkg.version && lock.packages?.['']?.version === pkg.version,
|
||||
`lockfile says ${lock.version} — run npm install and rebuild the zip`);
|
||||
|
||||
const skillMd = fs.readFileSync(path.join(skillRoot, 'SKILL.md'), 'utf8');
|
||||
const skillVersion = (skillMd.match(/^\s*version:\s*"([^"]+)"/m) || [])[1];
|
||||
const packageMajorMinor = pkg.version.match(/^(\d+\.\d+)\./)?.[1];
|
||||
check('SKILL.md metadata version matches package.json major.minor',
|
||||
!!packageMajorMinor && skillVersion === packageMajorMinor,
|
||||
`SKILL.md says ${skillVersion}, package.json says ${pkg.version}`);
|
||||
|
||||
for (const readmeName of ['README.md', 'README_EN.md', 'README_ZH.md']) {
|
||||
const readme = fs.readFileSync(path.join(repoRoot, readmeName), 'utf8');
|
||||
const badgeVersions = shieldsBadgeMessages(readme, 'version');
|
||||
check(`${readmeName} badge matches package.json version`,
|
||||
badgeVersions.length > 0 && badgeVersions.every((version) => version === pkg.version),
|
||||
`${readmeName} badge says ${[...new Set(badgeVersions)].join(', ') || '(missing)'} instead of ${pkg.version}`);
|
||||
}
|
||||
|
||||
const landingPage = fs.readFileSync(path.join(repoRoot, 'docs/index.html'), 'utf8');
|
||||
const landingVersions = [...landingPage.matchAll(/\bv\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\b/g)]
|
||||
.map((match) => match[0]);
|
||||
check('GitHub Pages version labels match package.json',
|
||||
landingVersions.length > 0 && landingVersions.every((v) => v === `v${pkg.version}`),
|
||||
`landing page says ${[...new Set(landingVersions)].join(', ') || '(no version)'}`);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
if (failures) {
|
||||
console.error(`\n${failures} check(s) failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\nall checks passed');
|
||||
@@ -0,0 +1,47 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { gridLayout, resolveComponentPos, validateGridPlacement } from '../renderers/architecture/grid.mjs';
|
||||
|
||||
test('gridLayout returns null for free placement', () => {
|
||||
assert.equal(gridLayout({}), null);
|
||||
assert.equal(gridLayout({ layout: undefined }), null);
|
||||
});
|
||||
|
||||
test('resolveComponentPos prefers explicit pos over row/col', () => {
|
||||
const grid = gridLayout({ layout: { mode: 'grid' } });
|
||||
assert.deepEqual(resolveComponentPos({ pos: [9, 8], row: 0, col: 0 }, grid), [9, 8]);
|
||||
});
|
||||
|
||||
test('resolveComponentPos maps row/col to pixel origin', () => {
|
||||
const grid = gridLayout({
|
||||
layout: { mode: 'grid', origin: [40, 80], gapX: 30, gapY: 40, cellW: 130, cellH: 64 },
|
||||
});
|
||||
assert.deepEqual(resolveComponentPos({ row: 0, col: 0 }, grid), [40, 80]);
|
||||
assert.deepEqual(resolveComponentPos({ row: 1, col: 2 }, grid), [40 + 2 * 160, 80 + 104]);
|
||||
});
|
||||
|
||||
test('validateGridPlacement rejects duplicate cells and missing row/col', () => {
|
||||
const grid = gridLayout({ layout: { mode: 'grid', cols: 4 } });
|
||||
const problems = [];
|
||||
validateGridPlacement({
|
||||
components: [
|
||||
{ id: 'a', row: 0, col: 0 },
|
||||
{ id: 'b', row: 0, col: 0 },
|
||||
{ id: 'c' },
|
||||
],
|
||||
}, grid, problems);
|
||||
assert.ok(problems.some((p) => p.includes('share grid cell')));
|
||||
assert.ok(problems.some((p) => p.includes('"c" needs pos')));
|
||||
});
|
||||
|
||||
test('validateGridPlacement ignores explicit pos overrides without row/col', () => {
|
||||
const grid = gridLayout({ layout: { mode: 'grid', cols: 4 } });
|
||||
const problems = [];
|
||||
validateGridPlacement({
|
||||
components: [
|
||||
{ id: 'a', pos: [40, 80] },
|
||||
{ id: 'b', pos: [200, 80] },
|
||||
],
|
||||
}, grid, problems);
|
||||
assert.deepEqual(problems, []);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(skillRoot, '..');
|
||||
|
||||
test('guide page: checked-in HTML is reproducible from the shared recipe source', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-guide-page-'));
|
||||
const generated = path.join(tmp, 'guide.html');
|
||||
try {
|
||||
execFileSync(process.execPath, [path.join(repoRoot, 'scripts/build-guide.mjs'), generated]);
|
||||
assert.equal(
|
||||
fs.readFileSync(generated, 'utf8'),
|
||||
fs.readFileSync(path.join(repoRoot, 'docs/guide.html'), 'utf8'),
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('guide page: ships bilingual recipes and syntactically valid interaction code', () => {
|
||||
const html = fs.readFileSync(path.join(repoRoot, 'docs/guide.html'), 'utf8');
|
||||
const packageVersion = JSON.parse(
|
||||
fs.readFileSync(path.join(skillRoot, 'package.json'), 'utf8'),
|
||||
).version;
|
||||
const releaseIdentity = packageVersion.includes('-') ? 'development' : 'stable';
|
||||
const staticVersionLabel = html.match(
|
||||
/<span data-i18n="versionLabel">([^<]+)<\/span>/,
|
||||
);
|
||||
assert.doesNotMatch(html, /\[\[[A-Z0-9_]+\]\]/);
|
||||
assert.equal(
|
||||
staticVersionLabel?.[1],
|
||||
`Scenario guide / ${releaseIdentity} / v${packageVersion}`,
|
||||
);
|
||||
assert.match(html, /Question-first diagramming/);
|
||||
assert.match(html, /先问题,后图表/);
|
||||
assert.match(html, /archify guide "your scenario"|archify guide "your scenario"/);
|
||||
|
||||
const dataMatch = html.match(/<script id="guide-data" type="application\/json">([\s\S]*?)<\/script>/);
|
||||
assert.ok(dataMatch);
|
||||
const data = JSON.parse(dataMatch[1]);
|
||||
assert.equal(data.length, 11);
|
||||
assert.equal(data.filter((recipe) => recipe.type === 'workflow').length, 3);
|
||||
assert.ok(data.every((recipe) => recipe.en.prompt && recipe.zh.prompt && recipe.proof));
|
||||
assert.match(html, /gallery\.html#proof-/);
|
||||
assert.match(html, /Open verified example/);
|
||||
assert.match(html, /打开验证成品/);
|
||||
|
||||
const scriptMatch = html.match(/<script>\n([\s\S]*?)\n <\/script>\n<\/body>/);
|
||||
assert.ok(scriptMatch);
|
||||
assert.doesNotThrow(() => new vm.Script(scriptMatch[1]));
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
SCENARIO_RECIPES,
|
||||
detectGuideLanguage,
|
||||
listScenarioRecipes,
|
||||
publicGuideData,
|
||||
recommendScenario,
|
||||
} from '../recipes/scenarios.mjs';
|
||||
|
||||
test('guide: exposes 11 unique recipes across every diagram type', () => {
|
||||
assert.equal(SCENARIO_RECIPES.length, 11);
|
||||
assert.equal(new Set(SCENARIO_RECIPES.map((recipe) => recipe.id)).size, 11);
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle'].map((type) => [
|
||||
type,
|
||||
SCENARIO_RECIPES.filter((recipe) => recipe.type === type).length,
|
||||
])),
|
||||
{ architecture: 2, workflow: 3, sequence: 2, dataflow: 2, lifecycle: 2 },
|
||||
);
|
||||
});
|
||||
|
||||
test('guide: every recipe has complete English and Chinese decision copy', () => {
|
||||
for (const recipe of SCENARIO_RECIPES) {
|
||||
assert.match(recipe.id, /^[a-z0-9]+(?:-[a-z0-9]+)*$/);
|
||||
assert.ok(recipe.signals.length >= 8, recipe.id);
|
||||
assert.ok(['classic', 'signal-flow', 'blueprint', 'editorial'].includes(recipe.presentation.preset), recipe.id);
|
||||
for (const lang of ['en', 'zh']) {
|
||||
const copy = recipe[lang];
|
||||
assert.ok(copy.title.length >= 4, `${recipe.id}.${lang}.title`);
|
||||
for (const field of ['question', 'summary', 'useWhen', 'avoidWhen', 'prompt']) {
|
||||
assert.ok(copy[field].length > 10, `${recipe.id}.${lang}.${field}`);
|
||||
}
|
||||
assert.equal(copy.include.length, 4, `${recipe.id}.${lang}.include`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('guide: language detection and localization are deterministic', () => {
|
||||
assert.equal(detectGuideLanguage('show an API request'), 'en');
|
||||
assert.equal(detectGuideLanguage('展示 API 请求'), 'zh');
|
||||
assert.equal(listScenarioRecipes('zh')[0].title, '系统总览');
|
||||
assert.equal(listScenarioRecipes('en')[0].title, 'System overview');
|
||||
});
|
||||
|
||||
test('guide: representative scenarios map to specialized recipes', () => {
|
||||
const cases = [
|
||||
['Show an API request with Redis cache miss', 'api-request'],
|
||||
['Show CI/CD build deploy rollback', 'delivery-workflow'],
|
||||
['展示 Kafka topic 消费者组和死信队列', 'event-stream'],
|
||||
['梳理 ETL 数仓 PII 数据血缘', 'data-lineage'],
|
||||
['deployment lifecycle approval rollback state', 'deployment-lifecycle'],
|
||||
['agent tool call approval gate MCP', 'agent-tool-call'],
|
||||
];
|
||||
|
||||
for (const [query, expected] of cases) {
|
||||
assert.equal(recommendScenario(query).recommendation.id, expected, query);
|
||||
}
|
||||
});
|
||||
|
||||
test('guide: exact ids win and unknown questions fall back honestly', () => {
|
||||
const exact = recommendScenario('incident-runbook');
|
||||
assert.equal(exact.recommendation.id, 'incident-runbook');
|
||||
assert.equal(exact.confidence, 'high');
|
||||
|
||||
const unknown = recommendScenario('make it delightful');
|
||||
assert.equal(unknown.recommendation.id, 'system-overview');
|
||||
assert.equal(unknown.confidence, 'low');
|
||||
assert.deepEqual(unknown.matchedSignals, []);
|
||||
});
|
||||
|
||||
test('guide: public data includes both languages and weighted signals', () => {
|
||||
const data = publicGuideData();
|
||||
assert.equal(data.length, 11);
|
||||
for (const recipe of data) {
|
||||
assert.ok(recipe.en.title);
|
||||
assert.ok(recipe.zh.title);
|
||||
assert.ok(recipe.proof, `${recipe.id}: verified proof is required`);
|
||||
assert.ok(recipe.signals.every(([signal, weight]) => typeof signal === 'string' && weight > 0));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-guided-views-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: { example: 'web-app.architecture.json', collection: 'components' },
|
||||
workflow: { example: 'agent-tool-call.workflow.json', collection: 'nodes' },
|
||||
sequence: { example: 'cache-miss-request.sequence.json', collection: 'participants' },
|
||||
dataflow: { example: 'product-analytics.dataflow.json', collection: 'nodes' },
|
||||
lifecycle: { example: 'agent-run.lifecycle.json', collection: 'states' },
|
||||
};
|
||||
|
||||
function run(mode, doc, suffix) {
|
||||
const input = path.join(tmp, `${mode}-${suffix}.json`);
|
||||
const output = path.join(tmp, `${mode}-${suffix}.html`);
|
||||
fs.writeFileSync(input, JSON.stringify(doc));
|
||||
const result = spawnSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`), input, output,
|
||||
], { encoding: 'utf8' });
|
||||
return { result, output, html: fs.existsSync(output) ? fs.readFileSync(output, 'utf8') : '' };
|
||||
}
|
||||
|
||||
function fixture(mode) {
|
||||
return JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', CASES[mode].example), 'utf8'));
|
||||
}
|
||||
|
||||
function svg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
for (const [mode, config] of Object.entries(CASES)) {
|
||||
test(`${mode}: guided views preserve base SVG geometry`, () => {
|
||||
const withViews = fixture(mode);
|
||||
const ids = withViews[config.collection].slice(0, 2).map((item) => item.id);
|
||||
withViews.meta.views = [{
|
||||
id: 'reader-path',
|
||||
label: 'Reader path',
|
||||
focus: ids,
|
||||
note: 'A safe note with </script><script> text.',
|
||||
}];
|
||||
const withoutViews = structuredClone(withViews);
|
||||
delete withoutViews.meta.views;
|
||||
|
||||
const guided = run(mode, withViews, 'guided');
|
||||
const plain = run(mode, withoutViews, 'plain');
|
||||
assert.equal(guided.result.status, 0, guided.result.stderr);
|
||||
assert.equal(plain.result.status, 0, plain.result.stderr);
|
||||
assert.equal(svg(guided.html), svg(plain.html));
|
||||
assert.match(guided.html, /id="guided-views" hidden/);
|
||||
assert.match(guided.html, /Archify\.guidedViews = \(function \(\)/);
|
||||
assert.match(guided.html, /#view=/);
|
||||
assert.match(guided.html, /addEventListener\('hashchange', syncViewFromHash\)/);
|
||||
assert.match(guided.html, /id="guided-view-play"/);
|
||||
assert.match(guided.html, /VIEW_INTERVAL_MS = 3200/);
|
||||
assert.match(guided.html, /visibilitychange/);
|
||||
assert.match(guided.html, /play: startPlayback/);
|
||||
assert.match(guided.html, /playCurrent: startCurrentViewPlayback/);
|
||||
assert.match(guided.html, /URLSearchParams\(location\.search\)\.get\('play'\) === '1'/);
|
||||
assert.match(guided.html, /data-autoplay/);
|
||||
assert.match(guided.html, /prefers-reduced-motion: reduce/);
|
||||
assert.match(guided.html, /pausePlayback\(\{ complete: true \}\)/);
|
||||
assert.match(guided.html, /html\[data-embed="true"\] svg\[data-animation="trace"\] \[data-animate\],[\s\S]*?html\[data-share-playback="true"\][\s\S]*?animation: none !important;[\s\S]*?stroke-dashoffset: 0/);
|
||||
assert.match(guided.html, /document\.documentElement\.setAttribute\('data-share-playback', 'true'\)/);
|
||||
assert.match(guided.html, /document\.documentElement\.removeAttribute\('data-share-playback'\)/);
|
||||
const oneShot = guided.html.match(/function startCurrentViewPlayback\(\) \{([\s\S]*?)\n function maybeStartSharePlayback/);
|
||||
assert.ok(oneShot, 'one-shot share playback implementation missing');
|
||||
assert.match(oneShot[1], /storyPlaybackScope = 'chapter'/);
|
||||
assert.match(oneShot[1], /scheduleStoryPlayback\(\)/);
|
||||
assert.doesNotMatch(oneShot[1], /scheduleNextView/);
|
||||
assert.match(guided.html, /id="share-chapter-cue" hidden role="status" aria-live="polite"/);
|
||||
assert.match(guided.html, /data-share-playback="true"\] \.share-chapter-cue:not\(\[hidden\]\)/);
|
||||
assert.match(guided.html, /data-share-playback="true"\] \.diagram-container \{\s*padding-top: 4\.25rem/);
|
||||
assert.match(guided.html, /function renderShareCue\(\)/);
|
||||
assert.match(guided.html, /function shareCueBeatCopy\(state, view, stops\)/);
|
||||
assert.match(guided.html, /viewerText\('viewer\.guided\.share\.step'/);
|
||||
assert.match(guided.html, /shareCue\.setAttribute\('aria-live', state === 'playing' \? 'off' : 'polite'\)/);
|
||||
assert.match(guided.html, /function scheduleStoryPlayback\(\)/);
|
||||
assert.match(guided.html, /storyBeatTimer = setTimeout/);
|
||||
assert.match(guided.html, /generation !== storyPlaybackGeneration/);
|
||||
assert.match(guided.html, /settleStoryBeats\(\);/);
|
||||
assert.match(guided.html, /kind === 'multiple' \? '\\u21c4' : '\\u00b7'/);
|
||||
assert.match(guided.html, /currentStoryProgress\(\)/);
|
||||
assert.match(guided.html, /setShareCueProgress\(progress\)/);
|
||||
assert.match(guided.html, /startShareCueProgress\(progress, remainingChapter\)/);
|
||||
assert.match(guided.html, /data-wide-diagram/);
|
||||
assert.match(guided.html, /min-width: 720px/);
|
||||
assert.match(guided.html, /reveal: reveal/);
|
||||
assert.match(guided.html, /container\.addEventListener\('scroll', onScroll, \{ passive: true \}\)/);
|
||||
assert.match(guided.html, /--archify-scroll-x/);
|
||||
assert.match(guided.html, /focus: function \(\) \{ return activeIndex < 0 \? \[\] : views\[activeIndex\]\.focus\.slice\(\); \}/);
|
||||
assert.doesNotMatch(guided.html, /<p class="footer">/);
|
||||
assert.doesNotMatch(guided.html, /<kbd>P<\/kbd> play story/);
|
||||
assert.doesNotMatch(plain.html, /<kbd>P<\/kbd> play story/);
|
||||
assert.match(guided.html, /\\u003c\/script\\u003e\\u003cscript\\u003e/);
|
||||
assert.doesNotMatch(guided.html, /A safe note with <\/script><script>/);
|
||||
});
|
||||
}
|
||||
|
||||
test('guided views reject duplicate view ids', () => {
|
||||
const doc = fixture('workflow');
|
||||
doc.meta.views = [
|
||||
{ id: 'same', label: 'First', focus: ['user'] },
|
||||
{ id: 'same', label: 'Second', focus: ['chat'] },
|
||||
];
|
||||
const { result } = run('workflow', doc, 'duplicate-view-id');
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /duplicates view id "same"/);
|
||||
});
|
||||
|
||||
test('guided views reject dangling semantic ids', () => {
|
||||
const doc = fixture('sequence');
|
||||
doc.meta.views = [{ id: 'broken', label: 'Broken', focus: ['ghost'] }];
|
||||
const { result } = run('sequence', doc, 'dangling-id');
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /references unknown semantic id "ghost"/);
|
||||
});
|
||||
|
||||
test('guided views schema enforces collection and focus bounds', () => {
|
||||
const tooMany = fixture('architecture');
|
||||
tooMany.meta.views = Array.from({ length: 6 }, (_, index) => ({
|
||||
id: `view-${index}`,
|
||||
label: `View ${index}`,
|
||||
focus: [tooMany.components[0].id],
|
||||
}));
|
||||
const overLimit = run('architecture', tooMany, 'too-many');
|
||||
assert.notEqual(overLimit.result.status, 0);
|
||||
assert.match(overLimit.result.stderr, /must NOT have more than 5 items/);
|
||||
|
||||
const duplicateFocus = fixture('dataflow');
|
||||
duplicateFocus.meta.views = [{ id: 'duplicate', label: 'Duplicate', focus: ['web', 'web'] }];
|
||||
const duplicate = run('dataflow', duplicateFocus, 'duplicate-focus');
|
||||
assert.notEqual(duplicate.result.status, 0);
|
||||
assert.match(duplicate.result.stderr, /duplicates semantic id "web"/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,37 @@
|
||||
import { parse, parseFragment } from 'parse5';
|
||||
import { SaxesParser } from 'saxes';
|
||||
|
||||
function visit(node, callback, insideSvg = false) {
|
||||
callback(node, insideSvg);
|
||||
const childInsideSvg = insideSvg || node.tagName === 'svg';
|
||||
for (const child of node.childNodes || []) visit(child, callback, childInsideSvg);
|
||||
if (node.content) visit(node.content, callback, childInsideSvg);
|
||||
}
|
||||
|
||||
export function parseXml(source) {
|
||||
return new SaxesParser({ xmlns: true }).write(source).close();
|
||||
}
|
||||
|
||||
export function extractSvgs(markup, fragment = false) {
|
||||
const document = fragment
|
||||
? parseFragment(markup, { sourceCodeLocationInfo: true })
|
||||
: parse(markup, { sourceCodeLocationInfo: true });
|
||||
const direct = [];
|
||||
const srcdocs = [];
|
||||
|
||||
visit(document, (node, insideSvg) => {
|
||||
if (node.tagName === 'svg' && !insideSvg && node.sourceCodeLocation) {
|
||||
direct.push(markup.slice(node.sourceCodeLocation.startOffset, node.sourceCodeLocation.endOffset));
|
||||
}
|
||||
const srcdoc = node.attrs?.find((attribute) => attribute.name === 'srcdoc');
|
||||
if (srcdoc) srcdocs.push(srcdoc.value);
|
||||
});
|
||||
|
||||
return {
|
||||
direct,
|
||||
embedded: srcdocs.flatMap((srcdoc) => {
|
||||
const nested = extractSvgs(srcdoc, true);
|
||||
return [...nested.direct, ...nested.embedded];
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import { ChromeVisualBrowser, findChrome } from '../bin/visual-check.mjs';
|
||||
|
||||
import {
|
||||
SUPPORTED_LOCALES,
|
||||
catalogKeys,
|
||||
translateCount,
|
||||
translateMessage,
|
||||
} from '../renderers/shared/i18n.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const cli = path.join(skillRoot, 'bin/archify.mjs');
|
||||
const templatePath = path.join(skillRoot, 'assets/template.html');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-i18n-'));
|
||||
const chromePath = process.env.ARCHIFY_CHROME ? findChrome() : null;
|
||||
let sequence = 0;
|
||||
|
||||
const EXAMPLES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function example(type) {
|
||||
return JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', EXAMPLES[type]), 'utf8'));
|
||||
}
|
||||
|
||||
const AUTHORED_TEXT_KEYS = new Set([
|
||||
'title',
|
||||
'subtitle',
|
||||
'label',
|
||||
'sublabel',
|
||||
'tag',
|
||||
'note',
|
||||
'context',
|
||||
'responsibility',
|
||||
'classification',
|
||||
'step',
|
||||
]);
|
||||
|
||||
function authoredExample(type, locale) {
|
||||
const document = example(type);
|
||||
const authored = [];
|
||||
let authoredIndex = 0;
|
||||
const nextAuthoredText = () => {
|
||||
authoredIndex += 1;
|
||||
const value = locale === 'zh-CN'
|
||||
? `文案${String(authoredIndex).padStart(2, '0')}`
|
||||
: `Copy${String(authoredIndex).padStart(2, '0')}`;
|
||||
authored.push(value);
|
||||
return value;
|
||||
};
|
||||
const rewrite = (value, path = []) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => rewrite(item, [...path, index]));
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== 'object') return;
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (typeof child === 'string' && AUTHORED_TEXT_KEYS.has(key)) {
|
||||
value[key] = nextAuthoredText();
|
||||
} else if (key === 'items' && path.includes('cards') && Array.isArray(child)) {
|
||||
value[key] = child.map((item) => (typeof item === 'string' ? nextAuthoredText() : item));
|
||||
} else {
|
||||
rewrite(child, [...path, key]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
rewrite(document);
|
||||
document.meta.locale = locale;
|
||||
if (!document.meta.subtitle) document.meta.subtitle = nextAuthoredText();
|
||||
return { document, authored };
|
||||
}
|
||||
|
||||
function run(type, document, command = 'render') {
|
||||
const id = sequence++;
|
||||
const input = path.join(tmp, `${id}-${type}.json`);
|
||||
const output = path.join(tmp, `${id}-${type}.html`);
|
||||
fs.writeFileSync(input, JSON.stringify(document));
|
||||
const args = command === 'render'
|
||||
? [cli, 'render', type, input, output]
|
||||
: [cli, 'validate', type, input, '--json'];
|
||||
const result = spawnSync(process.execPath, args, { cwd: skillRoot, encoding: 'utf8' });
|
||||
return {
|
||||
...result,
|
||||
output,
|
||||
html: result.status === 0 && command === 'render' ? fs.readFileSync(output, 'utf8') : '',
|
||||
};
|
||||
}
|
||||
|
||||
async function evaluate(browser, sessionId, expression, awaitPromise = false) {
|
||||
const response = await browser.cdp.send('Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
awaitPromise,
|
||||
}, sessionId);
|
||||
if (response.exceptionDetails) {
|
||||
throw new Error(response.exceptionDetails.exception?.description
|
||||
|| response.exceptionDetails.text
|
||||
|| 'browser evaluation failed');
|
||||
}
|
||||
return response.result?.value;
|
||||
}
|
||||
|
||||
async function loadArtifact(browser, artifactPath) {
|
||||
const sessionId = await browser.sessionPromise;
|
||||
await browser.cdp.send('Emulation.setDeviceMetricsOverride', {
|
||||
width: 1440,
|
||||
height: 900,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
}, sessionId);
|
||||
const loaded = browser.cdp.waitFor('Page.loadEventFired', sessionId);
|
||||
const navigation = await browser.cdp.send('Page.navigate', {
|
||||
url: pathToFileURL(artifactPath).href,
|
||||
}, sessionId);
|
||||
if (navigation.errorText) throw new Error(`Chrome navigation failed: ${navigation.errorText}`);
|
||||
await loaded;
|
||||
await evaluate(browser, sessionId, `new Promise(function (resolve) {
|
||||
requestAnimationFrame(function () { requestAnimationFrame(function () { resolve(true); }); });
|
||||
})`, true);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
test('zh-CN localizes renderer-owned output across all five modes without translating authored content', () => {
|
||||
assert.deepEqual(SUPPORTED_LOCALES, ['en', 'zh-CN']);
|
||||
for (const type of Object.keys(EXAMPLES)) {
|
||||
const document = example(type);
|
||||
const authoredTitle = document.meta.title;
|
||||
document.meta.locale = 'zh-CN';
|
||||
delete document.meta.subtitle;
|
||||
|
||||
const result = run(type, document);
|
||||
assert.equal(result.status, 0, `${type}: ${result.stderr || result.stdout}`);
|
||||
assert.match(result.html, /^<!DOCTYPE html>\n<html lang="zh-CN"/);
|
||||
assert.match(result.html, /<svg\b[^>]*\blang="zh-CN"/);
|
||||
assert.ok(result.html.includes(`<title>${authoredTitle}</title>`), `${type}: authored title changed`);
|
||||
assert.ok(result.html.includes(`<h1>${authoredTitle}</h1>`), `${type}: authored heading changed`);
|
||||
assert.match(result.html, /<text\b[^>]*>\u56fe\u4f8b<\/text>/);
|
||||
assert.match(result.html, /aria-label="\u805a\u7126/);
|
||||
assert.match(result.html, new RegExp(`<desc id="archify-diagram-description">\u7531 Archify \u751f\u6210\u7684`));
|
||||
assert.match(result.html, /"locale":"zh-CN"/);
|
||||
assert.match(result.html, />\u5bfc\u51fa\u56fe\u8868</);
|
||||
assert.doesNotMatch(result.html, /\{\{i18n:/);
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit en and zh-CN preserve complete authored field inventories across all five modes', () => {
|
||||
for (const type of Object.keys(EXAMPLES)) {
|
||||
const english = authoredExample(type, 'en');
|
||||
const chinese = authoredExample(type, 'zh-CN');
|
||||
assert.equal(english.authored.length, chinese.authored.length, `${type}: authored shapes differ`);
|
||||
assert.ok(english.authored.length >= 10, `${type}: authored inventory is unexpectedly small`);
|
||||
if (type === 'dataflow') {
|
||||
assert.ok(
|
||||
english.authored.includes(english.document.flows[0].classification),
|
||||
'dataflow: classification is missing from the authored inventory',
|
||||
);
|
||||
}
|
||||
if (type === 'lifecycle') {
|
||||
assert.ok(
|
||||
english.authored.includes(english.document.states[0].step),
|
||||
'lifecycle: step is missing from the authored inventory',
|
||||
);
|
||||
}
|
||||
|
||||
for (const candidate of [english, chinese]) {
|
||||
const locale = candidate.document.meta.locale;
|
||||
const result = run(type, candidate.document);
|
||||
assert.equal(result.status, 0, `${type}/${locale}: ${result.stderr || result.stdout}`);
|
||||
assert.match(result.html, new RegExp(`^<!DOCTYPE html>\\n<html lang="${locale}"`));
|
||||
assert.match(result.html, new RegExp(`<svg\\b[^>]*\\blang="${locale}"`));
|
||||
assert.match(result.html, new RegExp(`"locale":"${locale}"`));
|
||||
for (const authoredText of candidate.authored) {
|
||||
assert.ok(result.html.includes(authoredText), `${type}/${locale}: lost authored text ${authoredText}`);
|
||||
}
|
||||
if (locale === 'zh-CN') {
|
||||
assert.ok(result.html.includes(`<title>${candidate.document.meta.title}</title>`), type);
|
||||
assert.match(result.html, />导出图表</);
|
||||
} else {
|
||||
assert.ok(result.html.includes(`<title>${candidate.document.meta.title} Diagram</title>`), type);
|
||||
assert.match(result.html, />Export diagram</);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('omitted locale preserves non-English authored content and the English Viewer contract in all five modes', () => {
|
||||
for (const type of Object.keys(EXAMPLES)) {
|
||||
const document = example(type);
|
||||
const authoredTitle = `作者内容-${type}`;
|
||||
document.meta.title = authoredTitle;
|
||||
delete document.meta.locale;
|
||||
delete document.meta.subtitle;
|
||||
|
||||
const result = run(type, document);
|
||||
assert.equal(result.status, 0, `${type}: ${result.stderr || result.stdout}`);
|
||||
assert.match(result.html, /^<!DOCTYPE html>\n<html lang="en"/);
|
||||
assert.ok(result.html.includes(`<title>${authoredTitle} Diagram</title>`), `${type}: authored title changed`);
|
||||
assert.ok(result.html.includes(`<h1>${authoredTitle}</h1>`), `${type}: authored heading changed`);
|
||||
assert.match(result.html, /<svg\b[^>]*\blang="en"/);
|
||||
assert.match(result.html, /aria-label="Focus /);
|
||||
assert.match(result.html, /"locale":"en"/);
|
||||
assert.match(result.html, />Export diagram</);
|
||||
}
|
||||
});
|
||||
|
||||
test('unsupported locale values fail schema validation in every mode', () => {
|
||||
for (const locale of ['fr', 'zh-HK']) {
|
||||
for (const type of Object.keys(EXAMPLES)) {
|
||||
const document = example(type);
|
||||
document.meta.locale = locale;
|
||||
const result = run(type, document, 'validate');
|
||||
assert.notEqual(result.status, 0, `${type}: unsupported locale ${locale} unexpectedly passed`);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.equal(payload.ok, false);
|
||||
assert.ok(payload.diagnostics.some((entry) => entry.subject?.path === '/meta/locale'), `${type}: ${locale}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('real Chrome keeps zh-CN Finder, Route, Export, and accessibility UI localized in all five modes', {
|
||||
skip: chromePath ? false : 'Set ARCHIFY_CHROME to run the real browser localization regression.',
|
||||
}, async () => {
|
||||
const browser = new ChromeVisualBrowser(chromePath);
|
||||
try {
|
||||
for (const type of Object.keys(EXAMPLES)) {
|
||||
const document = example(type);
|
||||
document.meta.locale = 'zh-CN';
|
||||
document.meta.title = `浏览器本地化-${type}`;
|
||||
const result = run(type, document);
|
||||
assert.equal(result.status, 0, `${type}: ${result.stderr || result.stdout}`);
|
||||
|
||||
const sessionId = await loadArtifact(browser, result.output);
|
||||
const state = await evaluate(browser, sessionId, `(function () {
|
||||
var finderButton = document.getElementById('btn-node-finder');
|
||||
var routeButton = document.getElementById('btn-route-probe');
|
||||
var exportButton = document.getElementById('btn-export');
|
||||
finderButton.click();
|
||||
var finder = {
|
||||
hidden: document.getElementById('node-finder').hidden,
|
||||
title: document.getElementById('node-finder-title').textContent.trim(),
|
||||
searchLabel: document.getElementById('node-finder-input').getAttribute('aria-label')
|
||||
};
|
||||
document.getElementById('node-finder-close').click();
|
||||
routeButton.click();
|
||||
var route = {
|
||||
hidden: document.getElementById('route-probe').hidden,
|
||||
title: document.getElementById('route-probe-title').textContent.trim(),
|
||||
label: routeButton.getAttribute('aria-label')
|
||||
};
|
||||
routeButton.click();
|
||||
exportButton.click();
|
||||
var exportMenu = document.getElementById('export-menu');
|
||||
function pseudoContent(selector) {
|
||||
var content = getComputedStyle(document.querySelector(selector), '::after').content || '';
|
||||
return content.replace(/^["']|["']$/g, '');
|
||||
}
|
||||
var presetBadges = {};
|
||||
['signal-flow', 'blueprint', 'editorial'].forEach(function (preset) {
|
||||
document.documentElement.setAttribute('data-preset', preset);
|
||||
presetBadges[preset] = {
|
||||
header: pseudoContent('.header-row'),
|
||||
plate: pseudoContent('.diagram-container')
|
||||
};
|
||||
});
|
||||
return {
|
||||
htmlLang: document.documentElement.lang,
|
||||
svgLang: document.querySelector('.diagram-container svg').getAttribute('lang'),
|
||||
toolbarLabel: document.querySelector('.diagram-nav').getAttribute('aria-label'),
|
||||
finder: finder,
|
||||
route: route,
|
||||
exportMenuOpen: exportMenu.classList.contains('open'),
|
||||
exportLabel: exportButton.getAttribute('aria-label'),
|
||||
exportMenuLabel: exportMenu.getAttribute('aria-label'),
|
||||
exportMenuText: exportMenu.textContent,
|
||||
presetBadges: presetBadges
|
||||
};
|
||||
})()`);
|
||||
|
||||
assert.equal(state.htmlLang, 'zh-CN', type);
|
||||
assert.equal(state.svgLang, 'zh-CN', type);
|
||||
assert.equal(state.toolbarLabel, '图表视图控制', type);
|
||||
assert.deepEqual(state.finder, {
|
||||
hidden: false,
|
||||
title: '查找节点',
|
||||
searchLabel: '搜索图表节点',
|
||||
}, type);
|
||||
assert.deepEqual(state.route, {
|
||||
hidden: false,
|
||||
title: '选择起点节点',
|
||||
label: '清除已追踪路径',
|
||||
}, type);
|
||||
assert.equal(state.exportMenuOpen, true, type);
|
||||
assert.equal(state.exportLabel, '导出图表', type);
|
||||
assert.equal(state.exportMenuLabel, '导出', type);
|
||||
assert.match(state.exportMenuText, /分享卡片/, type);
|
||||
assert.deepEqual(state.presetBadges, {
|
||||
'signal-flow': { header: '信号流', plate: 'none' },
|
||||
blueprint: { header: '蓝图 / 修订 01', plate: '' },
|
||||
editorial: { header: '编辑风格 / 现场笔记', plate: 'ARCHIFY / 图版 04' },
|
||||
}, type);
|
||||
|
||||
const shareCardFailure = await evaluate(browser, sessionId, `(async function () {
|
||||
var originalGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function () { return null; };
|
||||
try {
|
||||
await Archify.exportMenu.shareCard();
|
||||
return { rejected: false, message: '' };
|
||||
} catch (error) {
|
||||
return { rejected: true, message: String(error && error.message || error) };
|
||||
} finally {
|
||||
HTMLCanvasElement.prototype.getContext = originalGetContext;
|
||||
}
|
||||
})()`, true);
|
||||
assert.deepEqual(shareCardFailure, {
|
||||
rejected: true,
|
||||
message: '无法为分享卡片创建二维画布上下文',
|
||||
}, type);
|
||||
|
||||
const visual = spawnSync(process.execPath, [cli, 'visual-check', result.output, '--json'], {
|
||||
cwd: skillRoot,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ARCHIFY_CHROME: chromePath },
|
||||
});
|
||||
assert.ok([0, 1].includes(visual.status), `${type}: ${visual.stderr || visual.stdout}`);
|
||||
const receipt = JSON.parse(visual.stdout);
|
||||
assert.equal(receipt.visualReview, 'pending', type);
|
||||
assert.equal(receipt.chrome.status, 'available', type);
|
||||
assert.equal(receipt.readability.status, 'pass', type);
|
||||
assert.equal(receipt.viewerChrome.status, 'pass', type);
|
||||
assert.equal(receipt.captures.status, 'pass', type);
|
||||
assert.equal(
|
||||
receipt.containment.viewports.every((viewport) => viewport.overflowX === false),
|
||||
true,
|
||||
`${type}: localized Viewer introduced horizontal overflow`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('every Viewer message reference resolves through the shared catalog', () => {
|
||||
const template = fs.readFileSync(templatePath, 'utf8');
|
||||
const keys = new Set(catalogKeys());
|
||||
const references = new Set([
|
||||
...[...template.matchAll(/\{\{i18n:([a-zA-Z0-9_.-]+)\}\}/g)].map((match) => match[1]),
|
||||
...[...template.matchAll(/['"](viewer\.[a-zA-Z0-9_.-]+)['"]/g)].map((match) => match[1]),
|
||||
]);
|
||||
const unresolved = [...references].filter((key) => (
|
||||
!key.endsWith('.') && !keys.has(key) && !(keys.has(`${key}.one`) && keys.has(`${key}.other`))
|
||||
));
|
||||
assert.deepEqual(unresolved, []);
|
||||
});
|
||||
|
||||
test('every supported catalog is complete and preserves interpolation variables', () => {
|
||||
const variables = (value) => [...value.matchAll(/\{([a-zA-Z0-9_]+)\}/g)]
|
||||
.map((match) => match[1])
|
||||
.sort();
|
||||
for (const key of catalogKeys()) {
|
||||
const expected = variables(translateMessage('en', key));
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const message = translateMessage(locale, key);
|
||||
assert.ok(message && message !== 'undefined', `${locale}: ${key}`);
|
||||
assert.deepEqual(variables(message), expected, `${locale}: ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('runtime labels stay localized after composition', () => {
|
||||
assert.equal(translateMessage('zh-CN', 'viewer.kind.backend'), '后端');
|
||||
assert.equal(translateMessage('zh-CN', 'viewer.kind.decision'), '决策');
|
||||
assert.equal(translateMessage('zh-CN', 'viewer.passport.relationship.connectsFrom'), '连接自');
|
||||
assert.equal(translateMessage('zh-CN', 'viewer.nav.level.auto'), '自动');
|
||||
|
||||
const zhHops = translateCount('zh-CN', 'viewer.route.hop', 2);
|
||||
assert.equal(
|
||||
translateMessage('zh-CN', 'viewer.finder.result.routeTarget', { label: '终点', links: zhHops }),
|
||||
'选择终点作为路径终点,2 跳',
|
||||
);
|
||||
const enHop = translateCount('en', 'viewer.route.overview.hop', 1);
|
||||
const enNode = translateCount('en', 'viewer.route.overview.node', 2);
|
||||
assert.equal(
|
||||
translateMessage('en', 'viewer.route.overview.status', { nodes: enNode, hops: enHop }),
|
||||
'2 nodes · 1 directed hop · shortest authored route',
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
test('Share Card and export failures use catalog messages instead of fixed English', () => {
|
||||
assert.equal(
|
||||
translateCount('zh-CN', 'viewer.export.card.routeSummary', 2, { source: '来源', target: '目标' }),
|
||||
'路径:来源 → 目标 · 2 个有向跳转',
|
||||
);
|
||||
assert.equal(
|
||||
translateMessage('zh-CN', 'viewer.export.error.toBlobNull', { label: '分享卡片' }),
|
||||
'分享卡片的 canvas.toBlob 未返回数据',
|
||||
);
|
||||
|
||||
const template = fs.readFileSync(templatePath, 'utf8');
|
||||
for (const hardcoded of [
|
||||
"'Route: '",
|
||||
"'Share Card variants cannot be combined'",
|
||||
"canvas2dOrThrow(canvas, 'Share Card')",
|
||||
"'Share Card export could not remove temporary viewer state'",
|
||||
"'WebM motion export requires a trace animation and browser MediaRecorder support'",
|
||||
]) {
|
||||
assert.ok(!template.includes(hardcoded), hardcoded);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-intent-trace-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all typed renderers inherit one geometry-neutral Intent Trace', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /Archify\.intentTrace = \(function \(\)/, mode);
|
||||
assert.match(html, /id="intent-trace-status" role="status" aria-live="polite" aria-atomic="true"/, mode);
|
||||
assert.match(html, /svg\.setAttribute\('data-intent-trace-active', id\)/, mode);
|
||||
assert.match(html, /data-intent-trace-overlay/, mode);
|
||||
assert.equal((html.match(/<svg\b/g) || []).length, 1, `${mode} keeps one static canonical SVG`);
|
||||
assert.doesNotMatch(canonicalSvg(html), /data-intent-trace|intent-trace-flow/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('Intent Trace derives exact one-hop direction from stable renderer relationships', () => {
|
||||
const html = render('workflow', CASES.workflow);
|
||||
assert.match(html, /function show\(id, options\)/);
|
||||
assert.match(html, /if \(from !== id && to !== id\) return/);
|
||||
assert.match(html, /direction = from === id && to === id \? 'loop' : \(from === id \? 'out' : 'in'\)/);
|
||||
assert.match(html, /related\[from\] = true/);
|
||||
assert.match(html, /related\[to\] = true/);
|
||||
assert.match(html, /edge\.setAttribute\('data-intent-trace-match', ''\)/);
|
||||
assert.match(html, /node\.setAttribute\('data-intent-trace-selected', ''\)/);
|
||||
assert.match(html, /clone\.setAttribute\('data-direction', direction\)/);
|
||||
assert.match(html, /counts\[direction\] \+= 1/);
|
||||
});
|
||||
|
||||
test('Intent Trace keeps incoming and outgoing motion on authored source-to-target geometry', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
const incomingRule = html.match(/\.intent-trace-flow\[data-direction="in"\]\s*\{[\s\S]*?\}/)?.[0] || '';
|
||||
assert.ok(incomingRule, `${mode}: expected the incoming Intent Trace style`);
|
||||
assert.doesNotMatch(
|
||||
incomingRule,
|
||||
/animation-direction\s*:\s*reverse/,
|
||||
`${mode}: incoming authored geometry must not be replayed from target to source`,
|
||||
);
|
||||
assert.match(incomingRule, /animation-direction\s*:\s*normal/, mode);
|
||||
assert.match(html, /function traceGeometry\(shape, direction\)[\s\S]+shape\.cloneNode\(false\)/, mode);
|
||||
assert.match(html, /@keyframes archify-intent-trace-flow[\s\S]+stroke-dashoffset: -1/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('Intent Trace separates hover, keyboard, touch, and committed focus', () => {
|
||||
const html = render('sequence', CASES.sequence);
|
||||
assert.match(html, /window\.matchMedia\('\(hover: hover\) and \(pointer: fine\)'\)/);
|
||||
assert.match(html, /event\.pointerType === 'touch'/);
|
||||
assert.match(html, /addEventListener\('pointerover'/);
|
||||
assert.match(html, /addEventListener\('pointerout'/);
|
||||
assert.match(html, /addEventListener\('focusin'/);
|
||||
assert.match(html, /addEventListener\('focusout'/);
|
||||
assert.match(html, /show\(node\.getAttribute\('data-node-id'\), \{ announce: true \}\)/);
|
||||
assert.match(html, /Press Enter for details/);
|
||||
assert.match(html, /html\.getAttribute\('data-embed'\) === 'true'/);
|
||||
assert.match(html, /container\.classList\.contains\('is-panning'\)/);
|
||||
assert.match(html, /svg\.hasAttribute\('data-story-active'\)/);
|
||||
assert.match(html, /svg\.hasAttribute\('data-relationship-preview-active'\)/);
|
||||
assert.match(html, /Archify\.focus\.active\(\)/);
|
||||
assert.match(html, /Archify\.intentTrace\.clear\(\{ announce: false \}\)/);
|
||||
assert.match(html, /e\.key === 'Escape' && Archify\.intentTrace\.active\(\)/);
|
||||
});
|
||||
|
||||
test('Intent Trace normalizes motion, respects reduced motion, and exports cleanly', () => {
|
||||
const html = render('architecture', CASES.architecture);
|
||||
assert.match(html, /clone\.setAttribute\('pathLength', '1'\)/);
|
||||
assert.match(html, /\.intent-trace-flow\[data-direction="out"\]/);
|
||||
assert.match(html, /\.intent-trace-flow\[data-direction="in"\]/);
|
||||
assert.match(html, /\.intent-trace-flow\[data-direction="loop"\]/);
|
||||
assert.match(html, /@keyframes archify-intent-trace-flow/);
|
||||
assert.match(html, /animation: archify-intent-trace-flow 1\.15s linear 1 both/);
|
||||
assert.match(html, /@media \(prefers-reduced-motion: reduce\)[\s\S]+\.intent-trace-flow \{[\s\S]+animation: none !important/);
|
||||
assert.match(html, /clone\.removeAttribute\('data-intent-trace-active'\)/);
|
||||
assert.match(html, /clone\.querySelectorAll\('\[data-intent-trace-overlay\]'\)/);
|
||||
assert.match(html, /clone\.querySelectorAll\('\[data-intent-trace-match\], \[data-intent-trace-selected\]'\)/);
|
||||
assert.match(html, /!clone\.hasAttribute\('data-intent-trace-active'\)/);
|
||||
assert.doesNotMatch(canonicalSvg(html), /data-intent-trace|intent-trace-flow/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,97 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(skillRoot, '..');
|
||||
const docsRoot = path.join(repoRoot, 'docs');
|
||||
const landing = fs.readFileSync(path.join(docsRoot, 'index.html'), 'utf8');
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(docsRoot, 'gallery', 'manifest.json'), 'utf8'));
|
||||
|
||||
const proofs = [
|
||||
{
|
||||
key: 'signal', id: 'agent-tool-call', artifact: 'gallery/artifacts/agent-tool-call.workflow.html',
|
||||
preset: 'signal-flow', nodes: 12, edges: 11, view: 'happy-path',
|
||||
},
|
||||
{
|
||||
key: 'blueprint', id: 'deployment-ownership', artifact: 'gallery/artifacts/production-deployment.architecture.html',
|
||||
preset: 'blueprint', nodes: 12, edges: 12, view: 'request-boundary',
|
||||
},
|
||||
{
|
||||
key: 'classic', id: 'cache-miss', artifact: 'gallery/artifacts/cache-miss.sequence.html',
|
||||
preset: 'classic', nodes: 7, edges: 12, view: 'cache-fallback',
|
||||
},
|
||||
];
|
||||
|
||||
test('landing metadata describes the full technical-diagram product and trusted hero promise', () => {
|
||||
assert.match(landing, /<title>Archify — Technical Diagrams from Plain English<\/title>/);
|
||||
assert.match(landing, /<meta property="og:title" content="Archify — Technical Diagrams from Plain English">/);
|
||||
assert.match(landing, /An agent skill for Cursor, Claude Code, Codex CLI, and OpenCode/);
|
||||
assert.equal((landing.match(/npx -y skills add tt-a1i\/archify --skill archify --agent cursor --global --copy --yes/g) || []).length, 2);
|
||||
assert.match(landing, /From plain English<br>to architecture <em>you can trust\.<\/em>/);
|
||||
});
|
||||
|
||||
test('landing hero leads with three real generated proof artifacts', () => {
|
||||
assert.match(landing, /id="hero-proof-stage"/);
|
||||
assert.match(landing, /id="hero-proof-panel" role="tabpanel"/);
|
||||
assert.equal((landing.match(/class="spec-card"/g) || []).length, 3);
|
||||
assert.equal((landing.match(/role="tab"/g) || []).length, 3);
|
||||
assert.doesNotMatch(landing, /class="hero-screenshot/);
|
||||
|
||||
for (const proof of proofs) {
|
||||
const entry = manifest.entries.find(item => item.id === proof.id);
|
||||
assert.ok(entry, `${proof.id}: proof manifest entry missing`);
|
||||
assert.equal(entry.artifact, proof.artifact);
|
||||
assert.equal(entry.visualPreset, proof.preset);
|
||||
assert.equal(entry.animation, 'trace');
|
||||
assert.equal(entry.nodeCount, proof.nodes);
|
||||
assert.equal(entry.edgeCount, proof.edges);
|
||||
assert.ok(entry.viewIds.includes(proof.view));
|
||||
assert.ok(entry.checks.every(check => check.ok), `${proof.id}: validation receipt is not green`);
|
||||
assert.ok(fs.existsSync(path.join(docsRoot, proof.artifact)), `${proof.id}: live artifact missing`);
|
||||
assert.match(landing, new RegExp(`data-proof="${proof.key}"`));
|
||||
assert.ok(landing.includes(`artifact: '${proof.artifact}'`));
|
||||
assert.ok(landing.includes(`view: '${proof.view}'`));
|
||||
}
|
||||
});
|
||||
|
||||
test('landing proof switcher is bilingual and keyboard navigable', () => {
|
||||
assert.match(landing, /proof-live':'Live proof'/);
|
||||
assert.match(landing, /proof-live':'实时成品'/);
|
||||
assert.match(landing, /event\.key === 'ArrowRight'/);
|
||||
assert.match(landing, /event\.key === 'ArrowLeft'/);
|
||||
assert.match(landing, /event\.key === 'Home'/);
|
||||
assert.match(landing, /event\.key === 'End'/);
|
||||
assert.match(landing, /proofFrame\.dataset\.proof !== key/);
|
||||
assert.match(landing, /\?embed=1&play=1&theme=dark#view=happy-path/);
|
||||
assert.match(landing, /sandbox="allow-scripts"/);
|
||||
assert.match(landing, /const playback = play \? '&play=1' : ''/);
|
||||
assert.match(landing, /proofEmbedUrl\(proof, \{ play: deliberate \}\)/);
|
||||
assert.doesNotMatch(landing, /proofFrame\.contentWindow|proofFrame\.contentDocument/);
|
||||
assert.match(landing, /\?present=1&play=1#view=/);
|
||||
assert.match(landing, /#view=\$\{encodeURIComponent\(proof\.view\)\}/);
|
||||
assert.match(landing, /Pin one exact Story Moment, copy its stable link, and let someone else open the same authored node/);
|
||||
});
|
||||
|
||||
test('landing makes Route Journey and core exploration shortcuts discoverable in both languages', () => {
|
||||
assert.match(landing, /Route Journey keeps the complete authored path visible/);
|
||||
assert.match(landing, /Route Journey 始终保留完整作者路径/);
|
||||
assert.match(landing, /one finite, reader-controlled pass over each exact incoming relationship/);
|
||||
assert.match(landing, /沿每条精确入向关系播放一次由读者控制的有限旅程/);
|
||||
assert.match(landing, /data-i18n="f7-tag">INSPECT · PLAY · PAUSE/);
|
||||
assert.match(landing, /'f7-tag':'INSPECT · PLAY · PAUSE'/);
|
||||
assert.match(landing, /'f7-tag':'检查 · 播放 · 暂停'/);
|
||||
assert.match(landing, /data-i18n="kbd-zoom">Reading depth \/ reset/);
|
||||
assert.match(landing, /'kbd-zoom':'阅读层级 \/ 复位'/);
|
||||
assert.match(landing, /data-i18n="kbd-guide">Diagram guide<\/span><kbd>\?<\/kbd>/);
|
||||
assert.match(landing, /'kbd-guide':'图表指南'/);
|
||||
assert.match(landing, /data-i18n="kbd-find">Find node \/ route endpoint<\/span><kbd>\/<\/kbd>/);
|
||||
assert.match(landing, /'kbd-find':'查找节点 \/ 路径端点'/);
|
||||
assert.match(landing, /data-i18n="kbd-route">Trace, inspect, and play a route<\/span><kbd>R<\/kbd>/);
|
||||
assert.match(landing, /'kbd-route':'探查、检查并播放路径'/);
|
||||
assert.match(landing, /data-i18n="kbd-lens">Compare semantic kinds<\/span><kbd>L<\/kbd>/);
|
||||
assert.match(landing, /'kbd-lens':'对比语义类型'/);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,404 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const cli = path.join(skillRoot, 'bin/archify.mjs');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-legend-contract-'));
|
||||
let sequence = 0;
|
||||
|
||||
const FIXTURES = {
|
||||
architecture: {
|
||||
schema_version: 1,
|
||||
diagram_type: 'architecture',
|
||||
meta: { title: 'Legend architecture', viewBox: [720, 420] },
|
||||
components: [
|
||||
{ id: 'ui', type: 'frontend', label: 'UI', pos: [60, 90] },
|
||||
{ id: 'store', type: 'database', label: 'Store', pos: [300, 90] },
|
||||
],
|
||||
connections: [],
|
||||
},
|
||||
workflow: {
|
||||
schema_version: 1,
|
||||
diagram_type: 'workflow',
|
||||
meta: { title: 'Legend workflow', viewBox: [720, 360] },
|
||||
lanes: [{ id: 'main', label: 'Main' }],
|
||||
nodes: [
|
||||
{ id: 'ui', lane: 'main', col: 0, type: 'frontend', label: 'UI' },
|
||||
{ id: 'agent', lane: 'main', col: 2, type: 'backend', label: 'Agent' },
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
sequence: {
|
||||
schema_version: 1,
|
||||
diagram_type: 'sequence',
|
||||
meta: { title: 'Legend sequence', viewBox: [720, 560] },
|
||||
participants: [
|
||||
{ id: 'client', type: 'frontend', label: 'Client' },
|
||||
{ id: 'api', type: 'backend', label: 'API' },
|
||||
],
|
||||
messages: [
|
||||
{ from: 'client', to: 'api', y: 220, label: 'request', variant: 'emphasis' },
|
||||
{ from: 'api', to: 'client', y: 280, label: 'response', variant: 'return' },
|
||||
],
|
||||
},
|
||||
dataflow: {
|
||||
schema_version: 1,
|
||||
diagram_type: 'dataflow',
|
||||
meta: { title: 'Default Flow Only' },
|
||||
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' },
|
||||
],
|
||||
},
|
||||
lifecycle: {
|
||||
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' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const CATALOGS = {
|
||||
architecture: ['frontend', 'backend', 'database', 'cloud', 'security', 'messagebus', 'external'],
|
||||
workflow: ['frontend', 'backend', 'security', 'messagebus', 'database', 'cloud', 'external'],
|
||||
sequence: ['emphasis', 'return', 'security', 'dashed', 'default'],
|
||||
dataflow: ['emphasis', 'security', 'dashed', 'database', 'default'],
|
||||
lifecycle: ['start', 'active', 'waiting', 'decision', 'success', 'failure', 'neutral', 'external'],
|
||||
};
|
||||
|
||||
const AUTO_KINDS = {
|
||||
architecture: ['frontend', 'database'],
|
||||
workflow: ['frontend', 'backend'],
|
||||
sequence: ['emphasis', 'return'],
|
||||
dataflow: ['default'],
|
||||
lifecycle: ['start', 'active', 'success'],
|
||||
};
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function withLegend(type, legend) {
|
||||
const doc = clone(FIXTURES[type]);
|
||||
if (legend !== undefined) doc.meta.legend = legend;
|
||||
return doc;
|
||||
}
|
||||
|
||||
function run(type, doc, command = 'render') {
|
||||
const id = sequence++;
|
||||
const input = path.join(tmp, `${id}-${type}.json`);
|
||||
const output = path.join(tmp, `${id}-${type}.html`);
|
||||
fs.writeFileSync(input, JSON.stringify(doc));
|
||||
const args = command === 'render'
|
||||
? [cli, 'render', type, input, output]
|
||||
: [cli, 'validate', type, input, '--json'];
|
||||
const result = spawnSync(process.execPath, args, { cwd: skillRoot, encoding: 'utf8' });
|
||||
return {
|
||||
...result,
|
||||
html: result.status === 0 && command === 'render' ? fs.readFileSync(output, 'utf8') : '',
|
||||
};
|
||||
}
|
||||
|
||||
function render(type, doc) {
|
||||
const result = run(type, doc);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
return result.html;
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
function attrValues(source, attribute) {
|
||||
const pattern = new RegExp(`${attribute}="([^"]+)"`, 'g');
|
||||
return [...source.matchAll(pattern)].map((match) => match[1]);
|
||||
}
|
||||
|
||||
function legendKinds(html) {
|
||||
return attrValues(canonicalSvg(html), 'data-legend-semantic-kind');
|
||||
}
|
||||
|
||||
function validateFailure(type, doc) {
|
||||
const result = run(type, doc, 'validate');
|
||||
assert.notEqual(result.status, 0, `expected ${type} validation to fail`);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.equal(payload.ok, false);
|
||||
return payload;
|
||||
}
|
||||
|
||||
test('public typed renderers default to auto and expose only authored semantic kinds', () => {
|
||||
for (const type of Object.keys(FIXTURES)) {
|
||||
const html = render(type, FIXTURES[type]);
|
||||
assert.deepEqual(legendKinds(html), AUTO_KINDS[type], type);
|
||||
}
|
||||
});
|
||||
|
||||
test('Dataflow database node facts are interactive while flow variants stay visual-only', () => {
|
||||
const withDatabase = clone(FIXTURES.dataflow);
|
||||
withDatabase.nodes[1].type = 'database';
|
||||
const databaseSvg = canonicalSvg(render('dataflow', withDatabase));
|
||||
assert.deepEqual(attrValues(databaseSvg, 'data-legend-semantic-kind'), ['database', 'default']);
|
||||
assert.deepEqual(attrValues(databaseSvg, 'data-legend-kind'), ['database']);
|
||||
assert.equal((databaseSvg.match(/data-legend-bridge=""/g) || []).length, 1);
|
||||
assert.ok(attrValues(databaseSvg, 'data-node-kind').includes('database'));
|
||||
|
||||
const forcedWithoutFact = canonicalSvg(render('dataflow', withLegend('dataflow', {
|
||||
entries: { database: { visible: true } },
|
||||
})));
|
||||
assert.deepEqual(attrValues(forcedWithoutFact, 'data-legend-semantic-kind'), ['database', 'default']);
|
||||
assert.deepEqual(attrValues(forcedWithoutFact, 'data-legend-kind'), []);
|
||||
assert.doesNotMatch(forcedWithoutFact, /data-legend-bridge/);
|
||||
});
|
||||
|
||||
test('Issue #52 dataflow and lifecycle reproductions publish truthful default legends', () => {
|
||||
const dataflow = canonicalSvg(render('dataflow', FIXTURES.dataflow));
|
||||
assert.deepEqual(attrValues(dataflow, 'data-legend-semantic-kind'), ['default']);
|
||||
assert.doesNotMatch(dataflow, /policy \/ PII|async batch|primary data|data store/i);
|
||||
assert.doesNotMatch(dataflow, /data-legend-bridge|data-legend-kind=/);
|
||||
|
||||
const lifecycle = canonicalSvg(render('lifecycle', FIXTURES.lifecycle));
|
||||
const lifecycleLegend = lifecycle.slice(lifecycle.indexOf('<!-- Legend -->'));
|
||||
assert.deepEqual(attrValues(lifecycleLegend, 'data-legend-semantic-kind'), ['start', 'active', 'success']);
|
||||
assert.doesNotMatch(lifecycleLegend, /waiting|failure \/ exit/i);
|
||||
assert.deepEqual(attrValues(lifecycleLegend, 'data-legend-kind'), ['start', 'active', 'success']);
|
||||
});
|
||||
|
||||
test('all mode follows each renderer-owned stable catalog order', () => {
|
||||
for (const type of Object.keys(FIXTURES)) {
|
||||
const html = render(type, withLegend(type, { mode: 'all' }));
|
||||
assert.deepEqual(legendKinds(html), CATALOGS[type], type);
|
||||
}
|
||||
});
|
||||
|
||||
test('hidden mode removes the complete legend and overrides visible true', () => {
|
||||
for (const type of Object.keys(FIXTURES)) {
|
||||
const forcedKind = CATALOGS[type].at(-1);
|
||||
const html = render(type, withLegend(type, {
|
||||
mode: 'hidden',
|
||||
entries: { [forcedKind]: { label: 'Must stay hidden', visible: true } },
|
||||
}));
|
||||
const svg = canonicalSvg(html);
|
||||
assert.doesNotMatch(svg, />Legend</);
|
||||
assert.doesNotMatch(svg, /data-legend(?:-semantic-kind|-kind|-bridge)?=/);
|
||||
assert.doesNotMatch(svg, /Must stay hidden/);
|
||||
}
|
||||
});
|
||||
|
||||
test('visibility overrides apply after auto/all and empty legends leave no chrome', () => {
|
||||
const cases = {
|
||||
architecture: { hidden: 'frontend', forced: 'external' },
|
||||
workflow: { hidden: 'frontend', forced: 'security' },
|
||||
sequence: { hidden: 'emphasis', forced: 'dashed' },
|
||||
dataflow: { hidden: 'default', forced: 'database' },
|
||||
lifecycle: { hidden: 'active', forced: 'waiting' },
|
||||
};
|
||||
for (const [type, kinds] of Object.entries(cases)) {
|
||||
const html = render(type, withLegend(type, {
|
||||
entries: {
|
||||
[kinds.hidden]: { visible: false },
|
||||
[kinds.forced]: { visible: true },
|
||||
},
|
||||
}));
|
||||
const expected = AUTO_KINDS[type]
|
||||
.filter((kind) => kind !== kinds.hidden)
|
||||
.concat(kinds.forced)
|
||||
.sort((left, right) => CATALOGS[type].indexOf(left) - CATALOGS[type].indexOf(right));
|
||||
assert.deepEqual(legendKinds(html), expected, type);
|
||||
}
|
||||
|
||||
const allMinusSecurity = render('sequence', withLegend('sequence', {
|
||||
mode: 'all',
|
||||
entries: { security: { visible: false } },
|
||||
}));
|
||||
assert.deepEqual(
|
||||
legendKinds(allMinusSecurity),
|
||||
CATALOGS.sequence.filter((kind) => kind !== 'security'),
|
||||
);
|
||||
|
||||
for (const type of Object.keys(FIXTURES)) {
|
||||
const entries = Object.fromEntries(AUTO_KINDS[type].map((kind) => [kind, { visible: false }]));
|
||||
const empty = canonicalSvg(render(type, withLegend(type, { entries })));
|
||||
assert.doesNotMatch(empty, />Legend</, type);
|
||||
assert.doesNotMatch(empty, /data-legend/, type);
|
||||
}
|
||||
});
|
||||
|
||||
test('label overrides round-trip through all five public renderers', () => {
|
||||
for (const type of Object.keys(FIXTURES)) {
|
||||
const kind = AUTO_KINDS[type][0];
|
||||
const label = `Custom ${type} label`;
|
||||
const svg = canonicalSvg(render(type, withLegend(type, {
|
||||
entries: { [kind]: { label } },
|
||||
})));
|
||||
assert.match(svg, new RegExp(`data-legend-semantic-kind="${kind}"`), type);
|
||||
assert.match(svg, new RegExp(`>${label}<`), type);
|
||||
if (['architecture', 'workflow', 'lifecycle'].includes(type)) {
|
||||
assert.match(svg, new RegExp(`data-legend-kind="${kind}"[^>]+data-legend-label="${label}"`), type);
|
||||
} else {
|
||||
assert.doesNotMatch(svg, /data-legend-kind=/, type);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('label overrides preserve stable kinds and exact Semantic Legend boundaries', () => {
|
||||
const architecture = render('architecture', withLegend('architecture', {
|
||||
entries: {
|
||||
frontend: { label: 'Reader <UI> & "ops"' },
|
||||
external: { label: 'Future integration', visible: true },
|
||||
},
|
||||
}));
|
||||
const svg = canonicalSvg(architecture);
|
||||
const baselineSvg = canonicalSvg(render('architecture', FIXTURES.architecture));
|
||||
assert.deepEqual(attrValues(svg, 'data-node-id'), attrValues(baselineSvg, 'data-node-id'));
|
||||
assert.deepEqual(attrValues(svg, 'data-node-kind'), attrValues(baselineSvg, 'data-node-kind'));
|
||||
assert.deepEqual(attrValues(svg, 'data-edge-from'), attrValues(baselineSvg, 'data-edge-from'));
|
||||
assert.match(svg, />Reader <UI> & "ops"</);
|
||||
assert.doesNotMatch(svg, /<UI>/);
|
||||
assert.match(svg, />Future integration</);
|
||||
assert.deepEqual(attrValues(svg, 'data-legend-semantic-kind'), ['frontend', 'database', 'external']);
|
||||
assert.deepEqual(attrValues(svg, 'data-legend-kind'), ['frontend', 'database']);
|
||||
assert.match(svg, /data-legend-kind="frontend"[^>]+data-legend-label="Reader <UI> & "ops""/);
|
||||
assert.match(architecture, /entry\.getAttribute\('data-legend-label'\)/);
|
||||
|
||||
for (const type of ['sequence', 'dataflow']) {
|
||||
const kind = type === 'sequence' ? 'emphasis' : 'default';
|
||||
const html = canonicalSvg(render(type, withLegend(type, {
|
||||
entries: { [kind]: { label: 'Visible only' } },
|
||||
})));
|
||||
assert.match(html, />Visible only</);
|
||||
assert.doesNotMatch(html, /data-legend-bridge|data-legend-kind=/);
|
||||
}
|
||||
});
|
||||
|
||||
test('strict per-renderer schemas reject malformed legend contracts with path-prefixed errors', () => {
|
||||
const known = {
|
||||
architecture: 'frontend', workflow: 'frontend', sequence: 'default', dataflow: 'default', lifecycle: 'start',
|
||||
};
|
||||
const cases = [
|
||||
[{ mode: 'sometimes' }, '/meta/legend/mode'],
|
||||
[{ entries: { unknown_kind: { visible: true } } }, '/meta/legend/entries'],
|
||||
[(type) => ({ entries: { [known[type]]: { label: '' } } }), '/meta/legend/entries/'],
|
||||
[(type) => ({ entries: { [known[type]]: { visible: 'yes' } } }), '/meta/legend/entries/'],
|
||||
[(type) => ({ entries: { [known[type]]: { color: '#fff' } } }), '/meta/legend/entries/'],
|
||||
[{ mode: 'auto', extra: true }, '/meta/legend'],
|
||||
];
|
||||
for (const type of Object.keys(FIXTURES)) {
|
||||
for (const [legendOrFactory, pathPrefix] of cases) {
|
||||
const legend = typeof legendOrFactory === 'function' ? legendOrFactory(type) : legendOrFactory;
|
||||
const failure = validateFailure(type, withLegend(type, legend));
|
||||
assert.ok(
|
||||
failure.diagnostics.some((diagnostic) => diagnostic.subject.path.startsWith(pathPrefix)),
|
||||
`${type}: expected a diagnostic under ${pathPrefix}: ${JSON.stringify(failure.diagnostics)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('measured legends fail explicitly instead of wrapping into diagram content', () => {
|
||||
const label = '界'.repeat(40);
|
||||
const entries = Object.fromEntries(CATALOGS.workflow.map((kind) => [kind, { label }]));
|
||||
const failure = validateFailure('workflow', withLegend('workflow', { mode: 'all', entries }));
|
||||
const diagnostic = failure.diagnostics.find((entry) => entry.code === 'legend/vertical-overflow');
|
||||
assert.ok(diagnostic, JSON.stringify(failure.diagnostics));
|
||||
assert.equal(diagnostic.subject.path, '/meta/legend');
|
||||
assert.ok(diagnostic.evidence.rowCount > 1);
|
||||
|
||||
const routedEntries = Object.fromEntries(CATALOGS.architecture.map((kind) => [
|
||||
kind,
|
||||
{ label: `Long ${kind} convention` },
|
||||
]));
|
||||
const routed = withLegend('architecture', { mode: 'all', entries: routedEntries });
|
||||
routed.connections = [{
|
||||
from: 'ui',
|
||||
to: 'store',
|
||||
label: 'bottom route',
|
||||
fromSide: 'bottom',
|
||||
toSide: 'bottom',
|
||||
via: [[120, 382], [360, 382]],
|
||||
labelAt: [240, 370],
|
||||
}];
|
||||
const renderFailure = run('architecture', routed);
|
||||
assert.notEqual(renderFailure.status, 0);
|
||||
assert.match(renderFailure.stderr, /legend\/content-overlap/);
|
||||
assert.equal(renderFailure.html, '');
|
||||
});
|
||||
|
||||
test('explicit Architecture viewBox rejects legend title rectangles that overlap content', () => {
|
||||
const doc = clone(FIXTURES.architecture);
|
||||
doc.meta.viewBox = [320, 320];
|
||||
doc.meta.legend = { mode: 'auto' };
|
||||
doc.components = [
|
||||
{ id: 'ui', type: 'frontend', label: 'UI', pos: [40, 216], size: [120, 60] },
|
||||
];
|
||||
const failure = validateFailure('architecture', doc);
|
||||
const diagnostic = failure.diagnostics.find((entry) => entry.code === 'legend/vertical-overflow');
|
||||
assert.ok(diagnostic, JSON.stringify(failure.diagnostics));
|
||||
assert.equal(diagnostic.subject.path, '/meta/legend');
|
||||
assert.ok(diagnostic.evidence.requiredTopY < diagnostic.evidence.availableTopY);
|
||||
});
|
||||
|
||||
test('a single unfit label fails with a path-specific width diagnostic', () => {
|
||||
const failure = validateFailure('architecture', withLegend('architecture', {
|
||||
entries: { frontend: { label: '界'.repeat(80) } },
|
||||
}));
|
||||
const diagnostic = failure.diagnostics.find((entry) => entry.code === 'legend/label-too-wide');
|
||||
assert.ok(diagnostic, JSON.stringify(failure.diagnostics));
|
||||
assert.equal(diagnostic.subject.path, '/meta/legend/entries/frontend/label');
|
||||
assert.ok(diagnostic.evidence.measuredWidthPx > diagnostic.evidence.availableWidthPx);
|
||||
});
|
||||
|
||||
test('measured legend rows share baselines and stay within the viewBox for localized labels', () => {
|
||||
const doc = withLegend('lifecycle', {
|
||||
mode: 'all',
|
||||
entries: {
|
||||
start: { label: '开始 / Start of the complete lifecycle' },
|
||||
active: { label: '正在执行 active processing' },
|
||||
waiting: { label: '等待人工输入' },
|
||||
decision: { label: 'Decision gate with deterministic wrapping' },
|
||||
success: { label: '成功完成' },
|
||||
failure: { label: 'Failure / 失败' },
|
||||
neutral: { label: 'Neutral state' },
|
||||
external: { label: 'External system' },
|
||||
},
|
||||
});
|
||||
const svg = canonicalSvg(render('lifecycle', doc));
|
||||
const viewBox = attrValues(svg.match(/<svg\b[^>]*>/)?.[0] || '', 'viewBox')[0].split(/\s+/).map(Number);
|
||||
const tags = [...svg.matchAll(/<g\b[^>]*data-legend-semantic-kind="[^"]+"[^>]*>/g)].map((match) => match[0]);
|
||||
assert.equal(tags.length, CATALOGS.lifecycle.length);
|
||||
const boxes = tags.map((tag) => ({
|
||||
x: Number(attrValues(tag, 'data-legend-x')[0]),
|
||||
y: Number(attrValues(tag, 'data-legend-baseline')[0]),
|
||||
width: Number(attrValues(tag, 'data-legend-width')[0]),
|
||||
}));
|
||||
assert.ok(boxes.every((box) => Number.isFinite(box.x) && Number.isFinite(box.y) && Number.isFinite(box.width)));
|
||||
assert.ok(boxes.every((box) => box.x >= 0 && box.x + box.width <= viewBox[2]));
|
||||
const rows = new Map();
|
||||
for (const box of boxes) rows.set(box.y, [...(rows.get(box.y) || []), box]);
|
||||
for (const row of rows.values()) {
|
||||
const sorted = [...row].sort((left, right) => left.x - right.x);
|
||||
for (let index = 1; index < sorted.length; index += 1) {
|
||||
assert.ok(sorted[index - 1].x + sorted[index - 1].width <= sorted[index].x);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,111 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-motion-governor-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example, animation = 'trace') {
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', example), 'utf8'));
|
||||
doc.meta = { ...doc.meta };
|
||||
if (animation) doc.meta.animation = animation;
|
||||
else delete doc.meta.animation;
|
||||
const input = path.join(tmp, `${mode}-${animation || 'static'}.json`);
|
||||
const output = path.join(tmp, `${mode}-${animation || 'static'}.html`);
|
||||
fs.writeFileSync(input, JSON.stringify(doc));
|
||||
execFileSync('node', [path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`), input, output], {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function svgBlock(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all renderers inherit one viewer-only Live/Still Motion Governor', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /id="btn-motion"[^>]+hidden[^>]+aria-label="Pause motion"/, mode);
|
||||
assert.match(html, /Archify\.motionGovernor = \(function \(\)/, mode);
|
||||
assert.match(html, /var capable = !!\(svg && svg\.getAttribute\('data-animation'\) === 'trace'\)/, mode);
|
||||
assert.match(svgBlock(html), /data-animation="trace"/, mode);
|
||||
assert.doesNotMatch(svgBlock(html), /data-motion-(?:capable|owner)|motion-control/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('static artifacts stay truly still while trace artifacts opt into ambient motion', () => {
|
||||
const html = render('architecture', CASES.architecture, null);
|
||||
const svg = svgBlock(html);
|
||||
assert.doesNotMatch(svg, /data-animation=/);
|
||||
assert.match(html, /\.pulse-dot \{[\s\S]*?animation: none;/);
|
||||
assert.match(html, /html\[data-motion-capable="true"\] \.pulse-dot \{ animation: pulse 2s infinite; \}/);
|
||||
assert.match(html, /html\[data-motion-capable="true"\]\[data-preset="signal-flow"\]\[data-ambient-motion="running"\] \.diagram-container::before/);
|
||||
assert.match(html, /if \(!capable\) \{[\s\S]*?btn\.hidden = true;[\s\S]*?capable: false/);
|
||||
assert.match(html, /html\.setAttribute\('data-motion-capable', 'true'\);[\s\S]*?btn\.hidden = false/);
|
||||
});
|
||||
|
||||
test('reader pause is persistent, explicit, and reduced-motion aware', () => {
|
||||
const html = render('workflow', CASES.workflow);
|
||||
assert.match(html, /var STORAGE_KEY = 'archify-motion'/);
|
||||
assert.match(html, /localStorage\.setItem\(STORAGE_KEY, 'still'\)/);
|
||||
assert.match(html, /localStorage\.removeItem\(STORAGE_KEY\)/);
|
||||
assert.match(html, /html\.setAttribute\('data-motion', paused \? 'still' : 'live'\)/);
|
||||
assert.match(html, /btn\.setAttribute\('aria-pressed', paused \? 'false' : 'true'\)/);
|
||||
assert.match(html, /Motion paused by reduced-motion preference/);
|
||||
assert.match(html, /motionQuery\.addEventListener\('change', render\)/);
|
||||
assert.match(html, /document\.addEventListener\('visibilitychange', syncVisibility\)/);
|
||||
assert.match(html, /Archify\.guidedViews\.isPlaying\(\)[\s\S]*?Archify\.guidedViews\.pause\(\)/);
|
||||
assert.match(html, /play\.disabled = !playing && !automaticPlaybackAllowed/);
|
||||
assert.match(html, /Story playback unavailable while motion is Still/);
|
||||
assert.match(html, /\.pulse-dot \{ animation: none !important; \}/);
|
||||
assert.match(html, /html\[data-motion="still"\] \.story-trail-flow/);
|
||||
});
|
||||
|
||||
test('strong semantic intent receives the single motion budget', () => {
|
||||
const html = render('workflow', CASES.workflow);
|
||||
assert.match(html, /if \(svg\.hasAttribute\('data-story-playing'\) \|\| svg\.hasAttribute\('data-story-follow'\)\) return 'story'/);
|
||||
assert.match(html, /if \(svg\.hasAttribute\('data-story-active'\)\) return 'chapter'/);
|
||||
assert.match(html, /data-route-picking'[\s\S]*?return 'route'/);
|
||||
assert.match(html, /data-lens-active'\)\) return 'lens'/);
|
||||
assert.match(html, /data-relationship-preview-active'\)\) return 'relationship'/);
|
||||
assert.match(html, /data-intent-trace-active'\)\) return 'intent'/);
|
||||
assert.match(html, /data-focus-active'\)\) return 'focus'/);
|
||||
assert.match(html, /data-legend-preview-active'\)\) return 'legend'/);
|
||||
assert.match(html, /new MutationObserver\(function \(\) \{ publishOwner\(\); \}\)/);
|
||||
assert.match(html, /html\[data-motion-owner\] svg\[data-animation="trace"\] \[data-animate\]/);
|
||||
assert.match(html, /function claim\(next, cleanup\)[\s\S]*?return ownerToken/);
|
||||
assert.match(html, /function release\(token\)[\s\S]*?token !== ownerToken/);
|
||||
assert.match(html, /function clearClaim\(preempted\)[\s\S]*?try \{ cleanup\(\); \} catch/);
|
||||
assert.match(html, /function claim\(next, cleanup\)[\s\S]*?clearClaim\(true\)/);
|
||||
assert.match(html, /animation: archify-route-probe-flow 1\.1s[\s\S]*?1 both/);
|
||||
assert.doesNotMatch(html, /archify-route-probe-flow[^;]*infinite/);
|
||||
});
|
||||
|
||||
test('motion control is mobile-contained, embed-safe, and export-neutral', () => {
|
||||
const html = render('sequence', CASES.sequence);
|
||||
assert.match(html, /\.toolbar #btn-motion\[hidden\] \{ display: none !important; \}/);
|
||||
assert.match(html, /\.toolbar button \{[\s\S]*?min-height: 2\.75rem;/);
|
||||
assert.match(html, /@media \(max-width: 360px\) \{[\s\S]*?\.toolbar #btn-motion \{ min-width: 4\.4rem;/);
|
||||
assert.match(html, /#theme-label, #preset-label, #present-label \{ display: none; \}/);
|
||||
assert.match(html, /html\[data-embed="true"\] \.diagram-container::before/);
|
||||
assert.match(html, /html\[data-share-playback="true"\] \.diagram-container::before/);
|
||||
assert.match(html, /Still also parks bounded[\s\S]*?viewer signals without discarding their static meaning/);
|
||||
assert.match(html, /recordExportReceipt\('svg', blob, d\.canonicalStateClean\)/);
|
||||
assert.doesNotMatch(svgBlock(html), /btn-motion|data-motion=|data-motion-owner/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,118 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
|
||||
import { openArtifact, openLoopbackUrl } from '../bin/open-artifact.mjs';
|
||||
|
||||
const target = path.resolve("/tmp/-复杂 path 'quoted'/diagram.html");
|
||||
|
||||
test('open artifact: uses argument arrays without shell interpolation on every supported platform', () => {
|
||||
const cases = [
|
||||
{
|
||||
platform: 'darwin',
|
||||
command: 'open',
|
||||
args: [target],
|
||||
method: 'open',
|
||||
},
|
||||
{
|
||||
platform: 'linux',
|
||||
command: 'xdg-open',
|
||||
args: [target],
|
||||
method: 'xdg-open',
|
||||
},
|
||||
{
|
||||
platform: 'win32',
|
||||
command: 'powershell.exe',
|
||||
args: [
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
'Start-Process -FilePath $args[0]',
|
||||
target,
|
||||
],
|
||||
method: 'powershell',
|
||||
},
|
||||
];
|
||||
|
||||
for (const expected of cases) {
|
||||
let invocation;
|
||||
const result = openArtifact(target, {
|
||||
platform: expected.platform,
|
||||
spawn(command, args, options) {
|
||||
invocation = { command, args, options };
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
requested: true,
|
||||
status: 'opened',
|
||||
target,
|
||||
method: expected.method,
|
||||
});
|
||||
assert.equal(invocation.command, expected.command);
|
||||
assert.deepEqual(invocation.args, expected.args);
|
||||
assert.equal(invocation.options.shell, false);
|
||||
assert.equal(invocation.options.timeout, 5000);
|
||||
}
|
||||
});
|
||||
|
||||
test('open artifact: distinguishes missing support from opener execution failure', () => {
|
||||
const missing = openArtifact(target, {
|
||||
platform: 'linux',
|
||||
spawn() {
|
||||
return { error: Object.assign(new Error('missing'), { code: 'ENOENT' }) };
|
||||
},
|
||||
});
|
||||
assert.equal(missing.status, 'unsupported');
|
||||
assert.equal(missing.method, 'xdg-open');
|
||||
|
||||
const timedOut = openArtifact(target, {
|
||||
platform: 'darwin',
|
||||
spawn() {
|
||||
return { error: Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }) };
|
||||
},
|
||||
});
|
||||
assert.equal(timedOut.status, 'failed');
|
||||
assert.equal(timedOut.method, 'open');
|
||||
|
||||
const unknown = openArtifact(target, { platform: 'plan9' });
|
||||
assert.deepEqual(unknown, {
|
||||
requested: true,
|
||||
status: 'unsupported',
|
||||
target,
|
||||
method: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('open artifact: live preview opens only an exact loopback HTTP root', () => {
|
||||
const url = 'http://127.0.0.1:43127/';
|
||||
let invocation;
|
||||
const result = openLoopbackUrl(url, {
|
||||
platform: 'darwin',
|
||||
spawn(command, args, options) {
|
||||
invocation = { command, args, options };
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
requested: true,
|
||||
status: 'opened',
|
||||
target: url,
|
||||
method: 'open',
|
||||
});
|
||||
assert.deepEqual(invocation.args, [url]);
|
||||
assert.equal(invocation.options.shell, false);
|
||||
|
||||
for (const rejected of [
|
||||
'https://127.0.0.1:43127/',
|
||||
'http://localhost:43127/',
|
||||
'http://0.0.0.0:43127/',
|
||||
'http://127.0.0.1:43127/path',
|
||||
'http://127.0.0.1:43127/?source=secret',
|
||||
'not a url',
|
||||
]) {
|
||||
assert.throws(() => openLoopbackUrl(rejected), /loopback|valid/i, rejected);
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,587 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startPreview } from '../bin/preview.mjs';
|
||||
import { loadDiagram, writeDiagram } from '../renderers/shared/cli.mjs';
|
||||
import { pathsAlias } from '../renderers/shared/output-path.mjs';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(here, '..');
|
||||
const cli = path.join(skillRoot, 'bin/archify.mjs');
|
||||
const workflowFixture = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
|
||||
const baseFixture = path.join(skillRoot, 'examples/checkout-platform.base.architecture.json');
|
||||
const headFixture = path.join(skillRoot, 'examples/checkout-platform.head.architecture.json');
|
||||
|
||||
function run(args, cwd) {
|
||||
return spawnSync(process.execPath, [cli, ...args], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
function copyInstalledSkill(target) {
|
||||
fs.cpSync(skillRoot, target, {
|
||||
recursive: true,
|
||||
filter(source) {
|
||||
const relative = path.relative(skillRoot, source);
|
||||
return relative !== 'node_modules'
|
||||
&& !relative.startsWith(`node_modules${path.sep}`)
|
||||
&& relative !== 'test'
|
||||
&& !relative.startsWith(`test${path.sep}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function directoryAliasesNames(directory, authoredName, lookupName) {
|
||||
const authoredPath = path.join(directory, authoredName);
|
||||
const lookupPath = path.join(directory, lookupName);
|
||||
fs.writeFileSync(authoredPath, 'filesystem semantics probe', { flag: 'wx' });
|
||||
try {
|
||||
let authored;
|
||||
let lookup;
|
||||
try {
|
||||
authored = fs.statSync(authoredPath);
|
||||
lookup = fs.statSync(lookupPath);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return false;
|
||||
throw error;
|
||||
}
|
||||
return authored.dev === lookup.dev && authored.ino === lookup.ino;
|
||||
} finally {
|
||||
fs.unlinkSync(authoredPath);
|
||||
}
|
||||
}
|
||||
|
||||
test('future-path aliases follow the containing directory case and Unicode semantics', () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-semantics-'));
|
||||
const caseInsensitive = directoryAliasesNames(cwd, 'ArchifyCaseProbe', 'archifycaseprobe');
|
||||
const normalizationInsensitive = directoryAliasesNames(
|
||||
cwd,
|
||||
'archify-norm-\u00e9-probe',
|
||||
'archify-norm-e\u0301-probe',
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
pathsAlias(path.join(cwd, 'Future.HTML'), path.join(cwd, 'future.html')),
|
||||
caseInsensitive,
|
||||
);
|
||||
assert.equal(
|
||||
pathsAlias(path.join(cwd, 'Caf\u00e9.html'), path.join(cwd, 'Cafe\u0301.html')),
|
||||
normalizationInsensitive,
|
||||
);
|
||||
});
|
||||
|
||||
test('compare rejects case-only future targets before input work when the directory aliases case', () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-compare-case-'));
|
||||
const caseInsensitive = directoryAliasesNames(cwd, 'ArchifyCaseProbe', 'archifycaseprobe');
|
||||
const output = path.join(cwd, 'Future.HTML');
|
||||
const receiptPath = path.join(cwd, 'future.html');
|
||||
|
||||
const result = run([
|
||||
'compare', 'architecture',
|
||||
path.join(cwd, 'missing-base.json'),
|
||||
path.join(cwd, 'missing-head.json'),
|
||||
output,
|
||||
'--receipt', receiptPath,
|
||||
'--json',
|
||||
], cwd);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(
|
||||
receipt.diagnostics[0].code,
|
||||
caseInsensitive ? 'output/target-alias' : 'delta/base-input',
|
||||
);
|
||||
assert.equal(receipt.stage, caseInsensitive ? 'prepare' : 'input');
|
||||
});
|
||||
|
||||
test('render reports an output symlink cycle as a structured output diagnostic', () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-cycle-'));
|
||||
const input = path.join(cwd, 'diagram.workflow.json');
|
||||
const output = path.join(cwd, 'cycle-a.html');
|
||||
const otherLink = path.join(cwd, 'cycle-b.html');
|
||||
fs.copyFileSync(workflowFixture, input);
|
||||
fs.symlinkSync(otherLink, output, 'file');
|
||||
fs.symlinkSync(output, otherLink, 'file');
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(skillRoot, 'renderers/workflow/render-workflow.mjs'), input, output],
|
||||
{
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ARCHIFY_DIAGNOSTIC_FORMAT: 'json' },
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
const failure = JSON.parse(result.stderr);
|
||||
assert.equal(failure.diagnostics[0].code, 'output/symlink-cycle');
|
||||
assert.equal(failure.diagnostics[0].subject.output, output);
|
||||
assert.ok(failure.diagnostics[0].supportedFixes.length > 0);
|
||||
});
|
||||
|
||||
test('render rejects an output symlink that aliases its JSON input', () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-render-'));
|
||||
const input = path.join(cwd, 'diagram.workflow.json');
|
||||
const output = path.join(cwd, 'diagram.html');
|
||||
const source = fs.readFileSync(workflowFixture);
|
||||
fs.writeFileSync(input, source);
|
||||
fs.symlinkSync(input, output, 'file');
|
||||
|
||||
const result = run(['render', 'workflow', input, output], cwd);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /output must not replace an input/i);
|
||||
assert.deepEqual(fs.readFileSync(input), source);
|
||||
});
|
||||
|
||||
test('render rejects an existing output hard link to its JSON input', () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-render-hardlink-'));
|
||||
const input = path.join(cwd, 'diagram.workflow.json');
|
||||
const output = path.join(cwd, 'diagram.html');
|
||||
const source = fs.readFileSync(workflowFixture);
|
||||
fs.writeFileSync(input, source);
|
||||
fs.linkSync(input, output);
|
||||
|
||||
const result = run(['render', 'workflow', input, output], cwd);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /output must not replace an input/i);
|
||||
assert.deepEqual(fs.readFileSync(input), source);
|
||||
});
|
||||
|
||||
test('render rejects an absolute meta.output when no CLI output is provided', () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-meta-absolute-'));
|
||||
const input = path.join(cwd, 'diagram.workflow.json');
|
||||
const output = path.join(cwd, 'authored.html');
|
||||
const source = JSON.parse(fs.readFileSync(workflowFixture, 'utf8'));
|
||||
source.meta.output = output;
|
||||
fs.writeFileSync(input, JSON.stringify(source));
|
||||
|
||||
const result = run(['render', 'workflow', input], cwd);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /meta\.output must be a relative path/i);
|
||||
assert.equal(fs.existsSync(output), false);
|
||||
});
|
||||
|
||||
test('render rejects a relative meta.output that escapes the working directory', () => {
|
||||
const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-meta-parent-'));
|
||||
const cwd = path.join(parent, 'work');
|
||||
fs.mkdirSync(cwd);
|
||||
const input = path.join(cwd, 'diagram.workflow.json');
|
||||
const output = path.join(parent, 'escaped.html');
|
||||
const source = JSON.parse(fs.readFileSync(workflowFixture, 'utf8'));
|
||||
source.meta.output = '../escaped.html';
|
||||
fs.writeFileSync(input, JSON.stringify(source));
|
||||
|
||||
const result = run(['render', 'workflow', input], cwd);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /meta\.output must stay inside the current working directory/i);
|
||||
assert.equal(fs.existsSync(output), false);
|
||||
});
|
||||
|
||||
test('render rejects a meta.output that escapes through a directory symlink', () => {
|
||||
const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-meta-link-'));
|
||||
const cwd = path.join(parent, 'work');
|
||||
const outside = path.join(parent, 'outside');
|
||||
fs.mkdirSync(cwd);
|
||||
fs.mkdirSync(outside);
|
||||
fs.symlinkSync(outside, path.join(cwd, 'linked'), 'dir');
|
||||
const input = path.join(cwd, 'diagram.workflow.json');
|
||||
const output = path.join(outside, 'authored.html');
|
||||
const source = JSON.parse(fs.readFileSync(workflowFixture, 'utf8'));
|
||||
source.meta.output = 'linked/authored.html';
|
||||
fs.writeFileSync(input, JSON.stringify(source));
|
||||
|
||||
const result = run(['render', 'workflow', input], cwd);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /meta\.output must stay inside the current working directory/i);
|
||||
assert.equal(fs.existsSync(output), false);
|
||||
});
|
||||
|
||||
test('render requires a meta.output target with an html extension', () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-meta-extension-'));
|
||||
const input = path.join(cwd, 'diagram.workflow.json');
|
||||
const output = path.join(cwd, 'authored.json');
|
||||
const source = JSON.parse(fs.readFileSync(workflowFixture, 'utf8'));
|
||||
source.meta.output = 'authored.json';
|
||||
fs.writeFileSync(input, JSON.stringify(source));
|
||||
|
||||
const result = run(['render', 'workflow', input], cwd);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /meta\.output must target an? \.html file/i);
|
||||
assert.equal(fs.existsSync(output), false);
|
||||
});
|
||||
|
||||
test('render rejects a meta.output symlink that resolves to a non-html target', () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-meta-extension-link-'));
|
||||
const input = path.join(cwd, 'diagram.workflow.json');
|
||||
const target = path.join(cwd, 'authored.json');
|
||||
const output = path.join(cwd, 'authored.html');
|
||||
const source = JSON.parse(fs.readFileSync(workflowFixture, 'utf8'));
|
||||
source.meta.output = 'authored.html';
|
||||
fs.writeFileSync(input, JSON.stringify(source));
|
||||
fs.writeFileSync(target, 'trusted target');
|
||||
fs.symlinkSync(target, output, 'file');
|
||||
|
||||
const result = run(['render', 'workflow', input], cwd);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /meta\.output must resolve to an? \.html file/i);
|
||||
assert.equal(fs.readFileSync(target, 'utf8'), 'trusted target');
|
||||
});
|
||||
|
||||
test('deliver rejects a future-path alias of its JSON input with a structured diagnostic', () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-deliver-'));
|
||||
const realDirectory = path.join(cwd, 'real');
|
||||
const linkedDirectory = path.join(cwd, 'linked');
|
||||
fs.mkdirSync(realDirectory);
|
||||
fs.symlinkSync(realDirectory, linkedDirectory, 'dir');
|
||||
const input = path.join(realDirectory, 'diagram.workflow.json');
|
||||
const output = path.join(linkedDirectory, 'diagram.workflow.json');
|
||||
const source = fs.readFileSync(workflowFixture);
|
||||
fs.writeFileSync(input, source);
|
||||
|
||||
const result = run(['deliver', 'workflow', input, output, '--json'], cwd);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.stage, 'prepare');
|
||||
assert.equal(receipt.diagnostics[0].code, 'output/input-alias');
|
||||
assert.deepEqual(fs.readFileSync(input), source);
|
||||
});
|
||||
|
||||
test('deliver rechecks aliases immediately before committing a verified candidate', { timeout: 10000 }, async () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-deliver-race-'));
|
||||
const installedRoot = path.join(cwd, 'skill');
|
||||
const installedBin = path.join(installedRoot, 'bin');
|
||||
const installedShared = path.join(installedRoot, 'renderers/shared');
|
||||
const installedRenderer = path.join(installedRoot, 'renderers/workflow');
|
||||
const installedScripts = path.join(installedRoot, 'scripts');
|
||||
fs.mkdirSync(installedBin, { recursive: true });
|
||||
fs.mkdirSync(installedShared, { recursive: true });
|
||||
fs.mkdirSync(installedRenderer, { recursive: true });
|
||||
fs.mkdirSync(installedScripts, { recursive: true });
|
||||
fs.copyFileSync(cli, path.join(installedBin, 'archify.mjs'));
|
||||
fs.copyFileSync(
|
||||
path.join(skillRoot, 'renderers/shared/output-path.mjs'),
|
||||
path.join(installedShared, 'output-path.mjs'),
|
||||
);
|
||||
fs.writeFileSync(path.join(installedRenderer, 'render-workflow.mjs'), `
|
||||
import fs from 'node:fs';
|
||||
const [, output] = process.argv.slice(2);
|
||||
fs.writeFileSync(process.env.ARCHIFY_TEST_RENDER_STARTED, output);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
fs.writeFileSync(output, '<!doctype html><title>verified candidate</title><svg></svg>');
|
||||
`);
|
||||
fs.writeFileSync(path.join(installedScripts, 'check-render-output.mjs'), `
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
checks: [{ name: 'single_svg', ok: true }],
|
||||
composition: {
|
||||
profile: 'showcase',
|
||||
status: 'pass',
|
||||
summary: { errors: 0, warnings: 0 }
|
||||
}
|
||||
}));
|
||||
`);
|
||||
|
||||
const inputDirectory = path.join(cwd, 'input');
|
||||
const initialOutputDirectory = path.join(cwd, 'safe-output');
|
||||
const linkedDirectory = path.join(cwd, 'linked-output');
|
||||
fs.mkdirSync(inputDirectory);
|
||||
fs.mkdirSync(initialOutputDirectory);
|
||||
fs.symlinkSync(initialOutputDirectory, linkedDirectory, 'dir');
|
||||
const input = path.join(inputDirectory, 'diagram.json');
|
||||
const output = path.join(linkedDirectory, 'diagram.json');
|
||||
const source = Buffer.from('{"meta":{"title":"race input"}}');
|
||||
fs.writeFileSync(input, source);
|
||||
const marker = path.join(cwd, 'renderer-started');
|
||||
|
||||
const child = spawn(process.execPath, [
|
||||
path.join(installedBin, 'archify.mjs'),
|
||||
'deliver', 'workflow', input, output, '--json',
|
||||
], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ARCHIFY_TEST_RENDER_STARTED: marker },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
|
||||
const started = Date.now();
|
||||
while (!fs.existsSync(marker) && Date.now() - started < 3000) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
assert.equal(fs.existsSync(marker), true, `renderer did not start; stderr=${stderr}`);
|
||||
const candidatePath = fs.readFileSync(marker, 'utf8');
|
||||
const candidateRelative = path.relative(linkedDirectory, candidatePath);
|
||||
fs.mkdirSync(path.dirname(path.join(inputDirectory, candidateRelative)), { recursive: true });
|
||||
fs.unlinkSync(linkedDirectory);
|
||||
fs.symlinkSync(inputDirectory, linkedDirectory, 'dir');
|
||||
|
||||
const status = await new Promise((resolve) => child.once('close', resolve));
|
||||
|
||||
assert.equal(status, 1, stderr);
|
||||
const receipt = JSON.parse(stdout);
|
||||
assert.equal(receipt.stage, 'commit');
|
||||
assert.equal(receipt.diagnostics[0].code, 'output/input-alias');
|
||||
assert.deepEqual(fs.readFileSync(input), source);
|
||||
});
|
||||
|
||||
test('compare rejects an artifact path that aliases either architecture input', () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-compare-'));
|
||||
const realDirectory = path.join(cwd, 'real');
|
||||
const linkedDirectory = path.join(cwd, 'linked');
|
||||
fs.mkdirSync(realDirectory);
|
||||
fs.symlinkSync(realDirectory, linkedDirectory, 'dir');
|
||||
const base = path.join(realDirectory, 'review.html');
|
||||
const output = path.join(linkedDirectory, 'review.html');
|
||||
const baseSource = fs.readFileSync(baseFixture);
|
||||
fs.writeFileSync(base, baseSource);
|
||||
|
||||
const result = run(['compare', 'architecture', base, headFixture, output, '--json'], cwd);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.stage, 'prepare');
|
||||
assert.equal(receipt.diagnostics[0].code, 'output/input-alias');
|
||||
assert.deepEqual(fs.readFileSync(base), baseSource);
|
||||
});
|
||||
|
||||
test('compare rejects a receipt path that aliases either architecture input', () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-compare-receipt-'));
|
||||
const base = path.join(cwd, 'base.json');
|
||||
const output = path.join(cwd, 'delta.html');
|
||||
const baseSource = fs.readFileSync(baseFixture);
|
||||
fs.writeFileSync(base, baseSource);
|
||||
|
||||
const result = run([
|
||||
'compare', 'architecture', base, headFixture, output,
|
||||
'--receipt', base, '--json',
|
||||
], cwd);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.stage, 'prepare');
|
||||
assert.equal(receipt.diagnostics[0].code, 'output/input-alias');
|
||||
assert.deepEqual(fs.readFileSync(base), baseSource);
|
||||
assert.equal(fs.existsSync(output), false);
|
||||
});
|
||||
|
||||
test('compare rejects a dangling receipt symlink to the future artifact path', () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-compare-pair-'));
|
||||
const output = path.join(cwd, 'delta.html');
|
||||
const receiptPath = path.join(cwd, 'delta.receipt.json');
|
||||
fs.symlinkSync(output, receiptPath, 'file');
|
||||
|
||||
const result = run([
|
||||
'compare', 'architecture', baseFixture, headFixture, output,
|
||||
'--receipt', receiptPath, '--json',
|
||||
], cwd);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.equal(receipt.stage, 'prepare');
|
||||
assert.equal(receipt.diagnostics[0].code, 'output/target-alias');
|
||||
assert.equal(fs.lstatSync(receiptPath).isSymbolicLink(), true);
|
||||
assert.equal(fs.existsSync(output), false);
|
||||
});
|
||||
|
||||
test('preview applies the meta.output relative-path boundary before starting a server', async () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-preview-meta-'));
|
||||
const input = path.join(cwd, 'diagram.workflow.json');
|
||||
const output = path.join(cwd, 'authored.html');
|
||||
const source = JSON.parse(fs.readFileSync(workflowFixture, 'utf8'));
|
||||
source.meta.output = output;
|
||||
fs.writeFileSync(input, JSON.stringify(source));
|
||||
|
||||
const failure = await startPreview({
|
||||
type: 'workflow',
|
||||
input,
|
||||
open: false,
|
||||
watch: false,
|
||||
cwd,
|
||||
}).then(async (preview) => {
|
||||
await preview.stop();
|
||||
return null;
|
||||
}, (error) => error);
|
||||
|
||||
assert.ok(failure instanceof Error);
|
||||
assert.match(failure.message, /meta\.output must be a relative path/i);
|
||||
assert.equal(fs.existsSync(output), false);
|
||||
});
|
||||
|
||||
test('the shared renderer rechecks its guarded output immediately before writing', () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-render-race-'));
|
||||
const inputDirectory = path.join(cwd, 'input');
|
||||
const initialOutputDirectory = path.join(cwd, 'safe-output');
|
||||
const linkedDirectory = path.join(cwd, 'linked-output');
|
||||
fs.mkdirSync(inputDirectory);
|
||||
fs.mkdirSync(initialOutputDirectory);
|
||||
fs.symlinkSync(initialOutputDirectory, linkedDirectory, 'dir');
|
||||
const input = path.join(inputDirectory, 'diagram.workflow.json');
|
||||
const output = path.join(linkedDirectory, 'diagram.workflow.json');
|
||||
const source = fs.readFileSync(workflowFixture);
|
||||
fs.writeFileSync(input, source);
|
||||
|
||||
const loaded = loadDiagram({
|
||||
rendererDir: path.join(skillRoot, 'renderers/workflow'),
|
||||
diagramType: 'workflow',
|
||||
defaultExample: 'agent-tool-call.workflow.json',
|
||||
argv: ['node', 'render-workflow.mjs', input, output],
|
||||
});
|
||||
fs.unlinkSync(linkedDirectory);
|
||||
fs.symlinkSync(inputDirectory, linkedDirectory, 'dir');
|
||||
|
||||
assert.throws(
|
||||
() => writeDiagram({
|
||||
outPath: loaded.outPath,
|
||||
template: loaded.template,
|
||||
diagramType: 'workflow',
|
||||
meta: loaded.diagram.meta,
|
||||
svg: '<svg role="img"></svg>',
|
||||
cards: [],
|
||||
}),
|
||||
/output must not replace an input/i,
|
||||
);
|
||||
assert.deepEqual(fs.readFileSync(input), source);
|
||||
});
|
||||
|
||||
test('compare rechecks every target immediately before committing the artifact pair', { timeout: 10000 }, async () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-compare-race-'));
|
||||
const installedRoot = path.join(cwd, 'skill');
|
||||
const installedBin = path.join(installedRoot, 'bin');
|
||||
const installedShared = path.join(installedRoot, 'renderers/shared');
|
||||
const installedRenderer = path.join(installedRoot, 'renderers/architecture');
|
||||
const installedScripts = path.join(installedRoot, 'scripts');
|
||||
const installedDelta = path.join(installedRoot, 'delta');
|
||||
for (const directory of [installedBin, installedShared, installedRenderer, installedScripts, installedDelta]) {
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
}
|
||||
fs.copyFileSync(cli, path.join(installedBin, 'archify.mjs'));
|
||||
fs.copyFileSync(
|
||||
path.join(skillRoot, 'renderers/shared/output-path.mjs'),
|
||||
path.join(installedShared, 'output-path.mjs'),
|
||||
);
|
||||
fs.writeFileSync(path.join(installedRenderer, 'render-architecture.mjs'), `
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
const [, output] = process.argv.slice(2);
|
||||
if (path.basename(output) === 'head.html') {
|
||||
const marker = process.env.ARCHIFY_TEST_RENDER_STARTED;
|
||||
const markerCandidate = marker + '.tmp';
|
||||
fs.writeFileSync(markerCandidate, output);
|
||||
fs.renameSync(markerCandidate, marker);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
fs.writeFileSync(output, '<!doctype html><svg role="img"></svg>');
|
||||
`);
|
||||
fs.writeFileSync(path.join(installedScripts, 'check-render-output.mjs'), `
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
checks: [{ name: 'single_svg', ok: true }],
|
||||
composition: {
|
||||
profile: 'showcase',
|
||||
status: 'pass',
|
||||
summary: { errors: 0, warnings: 0 }
|
||||
}
|
||||
}));
|
||||
`);
|
||||
fs.writeFileSync(path.join(installedDelta, 'architecture-delta.mjs'), `
|
||||
export class ArchitectureDeltaError extends Error {}
|
||||
export const annotateArchitectureSideSvg = (svg) => svg;
|
||||
export const buildDeltaSvg = () => '<svg role="img"></svg>';
|
||||
export const canonicalArchitecture = (value) => value;
|
||||
export const canonicalArchitectureJson = (value) => JSON.stringify(value);
|
||||
export const compareArchitecture = () => ({
|
||||
command: 'compare',
|
||||
base: {},
|
||||
head: {},
|
||||
completeness: 'complete',
|
||||
proofLevel: 'authored'
|
||||
});
|
||||
export const extractArchitectureSvg = () => '<svg role="img"></svg>';
|
||||
export const extractArtifactCss = () => '';
|
||||
export const renderArchitectureDeltaHtml = () => '<!doctype html><svg role="img"></svg>';
|
||||
export const validateArchitectureDeltaHtml = () => ({ checksPassed: 1, checkCount: 1 });
|
||||
`);
|
||||
|
||||
const inputDirectory = path.join(cwd, 'input');
|
||||
const initialOutputDirectory = path.join(cwd, 'safe-output');
|
||||
const linkedDirectory = path.join(cwd, 'linked-output');
|
||||
fs.mkdirSync(inputDirectory);
|
||||
fs.mkdirSync(initialOutputDirectory);
|
||||
fs.symlinkSync(initialOutputDirectory, linkedDirectory, 'dir');
|
||||
const base = path.join(inputDirectory, 'diagram.json');
|
||||
const head = path.join(cwd, 'head.json');
|
||||
const output = path.join(linkedDirectory, 'diagram.json');
|
||||
const source = Buffer.from('{"side":"base"}');
|
||||
fs.writeFileSync(base, source);
|
||||
fs.writeFileSync(head, '{"side":"head"}');
|
||||
const marker = path.join(cwd, 'renderer-started');
|
||||
|
||||
const child = spawn(process.execPath, [
|
||||
path.join(installedBin, 'archify.mjs'),
|
||||
'compare', 'architecture', base, head, output, '--json',
|
||||
], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ARCHIFY_TEST_RENDER_STARTED: marker },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
|
||||
const started = Date.now();
|
||||
while (!fs.existsSync(marker) && Date.now() - started < 3000) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
assert.equal(fs.existsSync(marker), true, `renderer did not start; stderr=${stderr}`);
|
||||
const candidatePath = fs.readFileSync(marker, 'utf8');
|
||||
const candidateRelative = path.relative(linkedDirectory, candidatePath);
|
||||
fs.mkdirSync(path.dirname(path.join(inputDirectory, candidateRelative)), { recursive: true });
|
||||
fs.unlinkSync(linkedDirectory);
|
||||
fs.symlinkSync(inputDirectory, linkedDirectory, 'dir');
|
||||
|
||||
const status = await new Promise((resolve) => child.once('close', resolve));
|
||||
|
||||
assert.equal(status, 1, stderr);
|
||||
const receipt = JSON.parse(stdout);
|
||||
assert.equal(receipt.stage, 'commit');
|
||||
assert.equal(receipt.diagnostics[0].code, 'output/input-alias');
|
||||
assert.deepEqual(fs.readFileSync(base), source);
|
||||
});
|
||||
|
||||
test('doctor reports a missing output-path safety runtime in an installed skill', () => {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-doctor-'));
|
||||
const installedRoot = path.join(cwd, 'skill');
|
||||
copyInstalledSkill(installedRoot);
|
||||
fs.rmSync(path.join(installedRoot, 'renderers/shared/output-path.mjs'));
|
||||
|
||||
const result = spawnSync(process.execPath, [path.join(installedRoot, 'bin/archify.mjs'), 'doctor'], {
|
||||
cwd: installedRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stdout, /\[missing\] Output path safety runtime/);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-presentation-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function svg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all typed renderers ship the same presentation stage contract', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /id="btn-present"[^>]+aria-label="Enter presentation stage"[^>]+aria-pressed="false"/, mode);
|
||||
assert.match(html, /Archify\.presentation = \(function \(\)/, mode);
|
||||
assert.match(html, /enter: function \(\) \{ return setActive\(true\); \}/, mode);
|
||||
assert.match(html, /exit: function \(\) \{ return setActive\(false\); \}/, mode);
|
||||
assert.match(html, /html\[data-present="true"\]:not\(\[data-embed="true"\]\) \.diagram-container/, mode);
|
||||
assert.match(html, /height: 100dvh/, mode);
|
||||
assert.match(html, /\.cards \{ display: none; \}/, mode);
|
||||
assert.doesNotMatch(html.match(/<html[^>]*>/)?.[0] || '', /data-present=/, mode);
|
||||
assert.doesNotMatch(svg(html), /data-present|btn-present|Presentation Stage/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('presentation stage supports direct links and preserves view hashes', () => {
|
||||
const html = render('architecture', CASES.architecture);
|
||||
assert.match(html, /get\('present'\) === '1'/);
|
||||
assert.match(html, /document\.documentElement\.setAttribute\('data-present', 'true'\)/);
|
||||
assert.match(html, /url\.searchParams\.set\('present', '1'\)/);
|
||||
assert.match(html, /url\.searchParams\.delete\('present'\)/);
|
||||
assert.match(html, /url\.pathname \+ url\.search \+ url\.hash/);
|
||||
assert.match(html, /Embed mode wins when both query parameters are present/);
|
||||
});
|
||||
|
||||
test('presentation keyboard behavior exits in layers and remains accessible', () => {
|
||||
const html = render('workflow', CASES.workflow);
|
||||
assert.match(html, /e\.defaultPrevented\) return/);
|
||||
assert.match(html, /e\.key === 'f' \|\| e\.key === 'F'/);
|
||||
assert.match(html, /Archify\.presentation\.toggle\(\)/);
|
||||
assert.match(html, /e\.key === 'Escape' && Archify\.focus\.active\(\)/);
|
||||
assert.match(html, /e\.key === 'Escape' && Archify\.presentation\.active\(\)/);
|
||||
assert.match(html, /btn\.setAttribute\('aria-pressed', next \? 'true' : 'false'\)/);
|
||||
assert.match(html, /Exit presentation stage \(F or Escape\)/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,113 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-preset-tryon-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, preset) {
|
||||
const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', CASES[mode]), 'utf8'));
|
||||
if (preset === undefined) delete source.meta.visual_preset;
|
||||
else source.meta.visual_preset = preset;
|
||||
source.meta.animation = 'none';
|
||||
const fixtureName = preset || 'default';
|
||||
const input = path.join(tmp, `${mode}-${fixtureName}.json`);
|
||||
const output = path.join(tmp, `${mode}-${fixtureName}.html`);
|
||||
fs.writeFileSync(input, JSON.stringify(source));
|
||||
execFileSync(process.execPath, [path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`), input, output]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function svgBlock(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
function presetRuntime(html) {
|
||||
return html.match(/Archify\.preset = \(function \(\) \{[\s\S]*?\n \}\)\(\);/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all five renderers expose one reader-controlled visual style picker', () => {
|
||||
for (const mode of Object.keys(CASES)) {
|
||||
const html = render(mode);
|
||||
assert.match(html, /id="btn-preset"[^>]+aria-haspopup="menu"[^>]+aria-controls="preset-menu"/, mode);
|
||||
assert.match(html, /id="preset-label"/, mode);
|
||||
assert.match(html, /title="Choose visual style \(S cycles\)"/, mode);
|
||||
assert.match(html, /id="preset-menu" role="menu" aria-label="Visual style"/, mode);
|
||||
for (const preset of ['classic', 'signal-flow', 'blueprint', 'editorial']) {
|
||||
assert.match(html, new RegExp(`data-preset-value="${preset}"[^>]+role="menuitemradio"`), `${mode}: ${preset}`);
|
||||
}
|
||||
assert.match(html, /Archify\.preset = \(function \(\)/, mode);
|
||||
assert.match(html, /S -> cycle visual style/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('style selection synchronizes page, picker, and canonical SVG without touching geometry', () => {
|
||||
const html = render('architecture');
|
||||
const runtime = presetRuntime(html);
|
||||
assert.match(runtime, /\['classic', 'signal-flow', 'blueprint', 'editorial'\]/);
|
||||
assert.match(runtime, /html\.setAttribute\('data-preset', preset\)/);
|
||||
assert.match(runtime, /svg\.setAttribute\('data-preset', preset\)/);
|
||||
assert.match(runtime, /data-preset-option/);
|
||||
assert.match(runtime, /option\.setAttribute\('aria-checked', String\(selected\)\)/);
|
||||
assert.match(runtime, /return \{ cycle: cycle, apply: apply, current: current, authored: authored, open: open, close: close, isOpen: isOpen \}/);
|
||||
});
|
||||
|
||||
test('omitted visual preset opens as Classic and theme switching cannot change it', () => {
|
||||
const html = render('architecture');
|
||||
const themeRuntime = html.match(/Archify\.theme = \(function \(\) \{[\s\S]*?\n \}\)\(\);/)?.[0] || '';
|
||||
assert.match(html, /<html lang="en" data-theme="dark" data-preset="classic">/);
|
||||
assert.match(svgBlock(html), /<svg\b[^>]* data-preset="classic"/);
|
||||
assert.match(themeRuntime, /html\.setAttribute\('data-theme', theme\)/);
|
||||
assert.doesNotMatch(themeRuntime, /data-preset|Archify\.preset/);
|
||||
});
|
||||
|
||||
test('style picker follows the accessible menu-button interaction contract', () => {
|
||||
const html = render('architecture');
|
||||
const runtime = presetRuntime(html);
|
||||
assert.match(runtime, /function open\(focusLast\)/);
|
||||
assert.match(runtime, /function close\(focusTrigger\)/);
|
||||
assert.match(runtime, /e\.key === 'ArrowDown' \|\| e\.key === 'ArrowUp'/);
|
||||
assert.match(runtime, /e\.key === 'Escape'/);
|
||||
assert.match(runtime, /e\.key === 'Tab'/);
|
||||
assert.match(runtime, /case 'Home':/);
|
||||
assert.match(runtime, /case 'End':/);
|
||||
assert.match(runtime, /document\.addEventListener\('click'/);
|
||||
assert.match(html, /\.preset-option-swatch\.editorial/);
|
||||
assert.match(
|
||||
html,
|
||||
/@media \(max-width: 720px\)[\s\S]*?\.toolbar \{[\s\S]*?position: relative;/,
|
||||
'the mobile toolbar must preserve its stacking context so the fixed preset menu stays above guided views',
|
||||
);
|
||||
});
|
||||
|
||||
test('style try-on is session-only and unavailable to passive embeds', () => {
|
||||
const html = render('workflow', 'signal-flow');
|
||||
const runtime = presetRuntime(html);
|
||||
assert.match(runtime, /html\.getAttribute\('data-embed'\) === 'true'/);
|
||||
assert.doesNotMatch(runtime, /localStorage|sessionStorage|history\.|location\.|URLSearchParams/);
|
||||
assert.match(html, /html\[data-embed="true"\] \.toolbar/);
|
||||
assert.match(html, /@media print/);
|
||||
});
|
||||
|
||||
test('same topology keeps identical canonical SVG geometry across all four styles', () => {
|
||||
const normalize = (svg) => svg.replace(/ data-preset="(?:classic|signal-flow|blueprint|editorial)"/, '');
|
||||
const variants = ['classic', 'signal-flow', 'blueprint', 'editorial'].map((preset) => normalize(svgBlock(render('architecture', preset))));
|
||||
assert.equal(variants[1], variants[0]);
|
||||
assert.equal(variants[2], variants[0]);
|
||||
assert.equal(variants[3], variants[0]);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,40 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(here, '..');
|
||||
const repoRoot = path.resolve(skillRoot, '..');
|
||||
const skill = fs.readFileSync(path.join(skillRoot, 'SKILL.md'), 'utf8');
|
||||
const delivery = fs.readFileSync(path.join(skillRoot, 'references', 'delivery-contract.md'), 'utf8');
|
||||
const readme = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8');
|
||||
const english = fs.readFileSync(path.join(repoRoot, 'README_EN.md'), 'utf8');
|
||||
const chinese = fs.readFileSync(path.join(repoRoot, 'README_ZH.md'), 'utf8');
|
||||
|
||||
test('preview contract: the skill keeps live preview explicit, desktop-only, and last-good', () => {
|
||||
assert.match(delivery, /archify\.mjs preview <type> <input>\.json <output>\.html/);
|
||||
assert.match(delivery, /active desktop authoring loop/i);
|
||||
assert.match(delivery, /previous verified revision on screen and on disk/i);
|
||||
assert.match(delivery, /never start it by default/i);
|
||||
assert.match(delivery, /CI, unattended agents, remote sharing, or mobile use/i);
|
||||
assert.match(delivery, /must never enter the generated artifact or any export/i);
|
||||
});
|
||||
|
||||
test('preview contract: all README languages document the same optional command without changing the hero', () => {
|
||||
assert.equal(readme, english);
|
||||
for (const text of [readme, chinese]) {
|
||||
assert.match(text, /bin\/archify\.mjs preview workflow/);
|
||||
assert.match(text, /--no-open/);
|
||||
assert.match(text, /127\.0\.0\.1/);
|
||||
assert.match(text, /Ctrl-C/);
|
||||
assert.match(text, /docs\/assets\/archify-readme-hero\.png/);
|
||||
}
|
||||
});
|
||||
|
||||
test('preview contract: the canonical delivery reference owns no-leak and zero-dependency boundaries', () => {
|
||||
assert.match(delivery, /Last-Good Live Preview/);
|
||||
assert.match(delivery, /zero-dependency Skill ZIP/i);
|
||||
assert.match(delivery, /Server state, port, source path, diagnostics, error text, and reload tokens must never enter/i);
|
||||
});
|
||||
@@ -0,0 +1,499 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
|
||||
import { startPreview } from '../bin/preview.mjs';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(here, '..');
|
||||
|
||||
function sha256(file) {
|
||||
return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
}
|
||||
|
||||
async function stateAt(url) {
|
||||
const response = await fetch(new URL('/state', url));
|
||||
assert.equal(response.status, 200);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function waitForState(url, predicate, message, timeoutMs = 12000) {
|
||||
const started = Date.now();
|
||||
let latest;
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
latest = await stateAt(url);
|
||||
if (predicate(latest)) return latest;
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
assert.fail(`${message}; latest state: ${JSON.stringify(latest)}`);
|
||||
}
|
||||
|
||||
function rawRequest(url, { method = 'GET', pathname = '/', hostHeader } = {}) {
|
||||
const target = new URL(url);
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = http.request({
|
||||
hostname: target.hostname,
|
||||
port: target.port,
|
||||
method,
|
||||
path: pathname,
|
||||
headers: hostHeader ? { Host: hostHeader } : undefined,
|
||||
}, (response) => {
|
||||
let body = '';
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', (chunk) => { body += chunk; });
|
||||
response.on('end', () => resolve({ status: response.statusCode, body, headers: response.headers }));
|
||||
});
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
test('preview: rejects destructive or unsupported startup targets before watching', async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-preview-startup-'));
|
||||
const input = path.join(tmp, 'diagram.json');
|
||||
fs.writeFileSync(input, '{}');
|
||||
await assert.rejects(
|
||||
startPreview({ type: 'architecture', input, output: input, open: false }),
|
||||
/must not replace its JSON input/i,
|
||||
);
|
||||
await assert.rejects(
|
||||
startPreview({ type: 'mindmap', input, output: path.join(tmp, 'out.html'), open: false }),
|
||||
/Unknown diagram type/i,
|
||||
);
|
||||
await assert.rejects(
|
||||
startPreview({ type: 'architecture', input, output: path.join(tmp, 'out.html'), quality: 'pretty', open: false }),
|
||||
/Unknown quality profile/i,
|
||||
);
|
||||
|
||||
const realDirectory = path.join(tmp, 'real');
|
||||
const linkedDirectory = path.join(tmp, 'linked');
|
||||
fs.mkdirSync(realDirectory);
|
||||
fs.symlinkSync(realDirectory, linkedDirectory, 'dir');
|
||||
await assert.rejects(
|
||||
startPreview({
|
||||
type: 'architecture',
|
||||
input: path.join(realDirectory, 'future.json'),
|
||||
output: path.join(linkedDirectory, 'future.json'),
|
||||
open: false,
|
||||
}),
|
||||
/must not replace its JSON input/i,
|
||||
);
|
||||
assert.deepEqual(fs.readdirSync(tmp).filter((name) => name.startsWith('.archify-preview-')), []);
|
||||
});
|
||||
|
||||
test('preview: invalid candidates preserve the last verified artifact and repair automatically', { timeout: 30000 }, async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-preview-last-good-'));
|
||||
const input = path.join(tmp, 'diagram.architecture.json');
|
||||
const output = path.join(tmp, 'diagram.html');
|
||||
const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/web-app.architecture.json'), 'utf8'));
|
||||
source.meta.title = 'Last Good One';
|
||||
fs.writeFileSync(input, JSON.stringify(source));
|
||||
|
||||
const preview = await startPreview({
|
||||
type: 'architecture',
|
||||
input,
|
||||
output,
|
||||
quality: 'showcase',
|
||||
open: false,
|
||||
debounceMs: 60,
|
||||
pollMs: 80,
|
||||
});
|
||||
|
||||
try {
|
||||
const first = await waitForState(preview.url, (state) => state.status === 'verified' && state.revision === 1, 'first revision did not verify');
|
||||
assert.equal(first.generation, 1);
|
||||
assert.equal(first.lastVerified.sha256, sha256(output));
|
||||
const firstSha = sha256(output);
|
||||
const firstArtifact = await (await fetch(new URL('/artifact.html', preview.url))).text();
|
||||
assert.match(firstArtifact, /Last Good One/);
|
||||
|
||||
fs.rmSync(input);
|
||||
const missing = await waitForState(preview.url, (state) => state.status === 'needs-fix' && state.generation === 2, 'deleted source did not report failure');
|
||||
assert.equal(missing.failure.stage, 'input');
|
||||
assert.equal(missing.revision, 1);
|
||||
assert.equal(sha256(output), firstSha, 'deleted input replaced the last verified output');
|
||||
|
||||
fs.writeFileSync(input, '{"meta":');
|
||||
const failed = await waitForState(preview.url, (state) => state.status === 'needs-fix' && state.generation === 3, 'invalid source did not report failure');
|
||||
assert.equal(failed.revision, 1);
|
||||
assert.equal(failed.failure.stage, 'input');
|
||||
assert.match(failed.failure.message, /Could not read delivery input/);
|
||||
assert.doesNotMatch(JSON.stringify(failed), new RegExp(input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
assert.equal(sha256(output), firstSha, 'invalid input replaced the last verified output');
|
||||
assert.equal(await (await fetch(new URL('/artifact.html', preview.url))).text(), firstArtifact);
|
||||
|
||||
source.components[0].unexpected = true;
|
||||
fs.writeFileSync(input, JSON.stringify(source));
|
||||
const schemaFailed = await waitForState(preview.url, (state) => state.status === 'needs-fix' && state.generation === 4, 'schema failure did not report render stage');
|
||||
assert.equal(schemaFailed.failure.stage, 'render');
|
||||
assert.match(schemaFailed.failure.message, /\/components\/0.*additional properties/i);
|
||||
assert.doesNotMatch(schemaFailed.failure.message, /file:\/\/|\/Users\/|node:internal/);
|
||||
assert.equal(sha256(output), firstSha, 'schema failure replaced the last verified output');
|
||||
|
||||
delete source.components[0].unexpected;
|
||||
source.meta.title = 'Verified Repair';
|
||||
source.components[0].label = 'Repaired Browser';
|
||||
fs.writeFileSync(input, JSON.stringify(source));
|
||||
const repaired = await waitForState(preview.url, (state) => state.status === 'verified' && state.revision === 2, 'repaired source did not publish');
|
||||
assert.equal(repaired.generation, 5);
|
||||
assert.notEqual(repaired.lastVerified.sha256, firstSha);
|
||||
const repairedArtifact = await (await fetch(new URL('/artifact.html', preview.url))).text();
|
||||
assert.match(repairedArtifact, /Verified Repair/);
|
||||
assert.match(repairedArtifact, /Repaired Browser/);
|
||||
assert.equal(repaired.lastVerified.sha256, sha256(output));
|
||||
|
||||
const page = await rawRequest(preview.url);
|
||||
assert.equal(page.status, 200);
|
||||
assert.match(page.body, /Archify Live Preview/);
|
||||
assert.match(page.body, /<summary role="button" aria-controls="diagnostic-panel">View diagnostic<\/summary>/);
|
||||
assert.match(page.headers['content-security-policy'], /default-src 'none'/);
|
||||
const script = page.body.match(/<script>\n([\s\S]*?)\n <\/script>/)?.[1];
|
||||
assert.ok(script, 'preview shell script missing');
|
||||
assert.doesNotThrow(() => new vm.Script(script));
|
||||
assert.equal((await rawRequest(preview.url, { method: 'POST' })).status, 405);
|
||||
assert.equal((await rawRequest(preview.url, { pathname: '/../../etc/passwd' })).status, 404);
|
||||
assert.equal((await rawRequest(preview.url, { hostHeader: 'example.com' })).status, 403);
|
||||
} finally {
|
||||
await preview.stop();
|
||||
}
|
||||
|
||||
await assert.rejects(fetch(preview.url));
|
||||
assert.deepEqual(fs.readdirSync(tmp).filter((name) => name.startsWith('.archify-preview-')), []);
|
||||
});
|
||||
|
||||
test('preview: content digests suppress identical writes and a burst publishes only its stable tail', { timeout: 30000 }, async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-preview-burst-'));
|
||||
const input = path.join(tmp, 'diagram.workflow.json');
|
||||
const output = path.join(tmp, 'diagram.html');
|
||||
const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/agent-tool-call.workflow.json'), 'utf8'));
|
||||
const original = JSON.stringify(source);
|
||||
fs.writeFileSync(input, original);
|
||||
const preview = await startPreview({
|
||||
type: 'workflow',
|
||||
input,
|
||||
output,
|
||||
open: false,
|
||||
debounceMs: 90,
|
||||
pollMs: 70,
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForState(preview.url, (state) => state.status === 'verified' && state.revision === 1, 'initial workflow did not verify');
|
||||
fs.writeFileSync(input, original);
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
let state = await stateAt(preview.url);
|
||||
assert.equal(state.generation, 1);
|
||||
assert.equal(state.revision, 1);
|
||||
|
||||
fs.writeFileSync(input, JSON.stringify(source, null, 2));
|
||||
state = await waitForState(preview.url, (candidate) => candidate.status === 'verified' && candidate.generation === 2, 'semantically identical source did not settle');
|
||||
assert.equal(state.revision, 1, 'identical artifact bytes triggered a browser revision');
|
||||
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
source.meta.title = `Burst ${index}`;
|
||||
fs.writeFileSync(input, JSON.stringify(source));
|
||||
await new Promise((resolve) => setTimeout(resolve, 12));
|
||||
}
|
||||
source.meta.title = 'Stable Tail';
|
||||
fs.writeFileSync(input, JSON.stringify(source));
|
||||
|
||||
state = await waitForState(preview.url, (candidate) => candidate.status === 'verified' && candidate.revision === 2, 'stable burst tail did not verify');
|
||||
assert.equal(state.generation, 3);
|
||||
const artifact = await (await fetch(new URL('/artifact.html', preview.url))).text();
|
||||
assert.match(artifact, /Stable Tail/);
|
||||
assert.doesNotMatch(artifact, /Burst 7/);
|
||||
} finally {
|
||||
await preview.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('preview: a superseded slow candidate can never become a published revision', { timeout: 30000 }, async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-preview-latest-wins-'));
|
||||
const input = path.join(tmp, 'diagram.json');
|
||||
const output = path.join(tmp, 'diagram.html');
|
||||
const deliveryCli = path.join(tmp, 'fake-delivery.mjs');
|
||||
fs.writeFileSync(deliveryCli, `
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
const [, , input, output] = process.argv.slice(2);
|
||||
const source = JSON.parse(fs.readFileSync(input, 'utf8'));
|
||||
await new Promise((resolve) => setTimeout(resolve, source.title === 'Slow Old' ? 550 : 40));
|
||||
const artifact = Buffer.from('<!doctype html><title>' + source.title + '</title><svg></svg>');
|
||||
fs.writeFileSync(output, artifact);
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
artifact: { sha256: createHash('sha256').update(artifact).digest('hex'), bytes: artifact.byteLength },
|
||||
validation: { checksPassed: 1, checkCount: 1, compositionProfile: 'showcase', compositionStatus: 'pass' }
|
||||
}));
|
||||
`);
|
||||
fs.writeFileSync(input, JSON.stringify({ title: 'Slow Old' }));
|
||||
|
||||
const preview = await startPreview({
|
||||
type: 'architecture',
|
||||
input,
|
||||
output,
|
||||
open: false,
|
||||
debounceMs: 25,
|
||||
pollMs: 40,
|
||||
deliveryCli,
|
||||
});
|
||||
try {
|
||||
await waitForState(preview.url, (state) => state.status === 'checking' && state.generation === 1, 'slow generation did not start');
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
fs.writeFileSync(input, JSON.stringify({ title: 'Fast New' }));
|
||||
const state = await waitForState(preview.url, (candidate) => candidate.status === 'verified' && candidate.generation === 2, 'latest generation did not publish');
|
||||
assert.equal(state.revision, 1, 'superseded generation was published before the latest one');
|
||||
const artifact = await (await fetch(new URL('/artifact.html', preview.url))).text();
|
||||
assert.match(artifact, /Fast New/);
|
||||
assert.doesNotMatch(artifact, /Slow Old/);
|
||||
assert.equal(fs.readFileSync(output, 'utf8'), artifact);
|
||||
} finally {
|
||||
await preview.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('preview: each delivery reads the immutable bytes bound to its observed digest', { timeout: 30000 }, async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-preview-snapshot-'));
|
||||
const input = path.join(tmp, 'diagram.json');
|
||||
const output = path.join(tmp, 'diagram.html');
|
||||
const deliveryCli = path.join(tmp, 'snapshot-delivery.mjs');
|
||||
const readMarker = path.join(tmp, 'delivery-read.txt');
|
||||
fs.writeFileSync(deliveryCli, `
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
const [, , input, output] = process.argv.slice(2);
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
const source = JSON.parse(fs.readFileSync(input, 'utf8'));
|
||||
fs.writeFileSync(${JSON.stringify(readMarker)}, source.title);
|
||||
await new Promise((resolve) => setTimeout(resolve, 180));
|
||||
const artifact = Buffer.from('<!doctype html><title>' + source.title + '</title><svg></svg>');
|
||||
fs.writeFileSync(output, artifact);
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
artifact: { sha256: createHash('sha256').update(artifact).digest('hex'), bytes: artifact.byteLength },
|
||||
validation: { checksPassed: 1, checkCount: 1, compositionProfile: 'showcase', compositionStatus: 'pass' }
|
||||
}));
|
||||
`);
|
||||
fs.writeFileSync(input, JSON.stringify({ title: 'Source A' }));
|
||||
|
||||
const preview = await startPreview({
|
||||
type: 'architecture',
|
||||
input,
|
||||
output,
|
||||
open: false,
|
||||
debounceMs: 10,
|
||||
pollMs: 5000,
|
||||
watch: false,
|
||||
deliveryCli,
|
||||
});
|
||||
try {
|
||||
await waitForState(preview.url, (state) => state.status === 'checking' && state.generation === 1, 'snapshot generation did not start');
|
||||
fs.writeFileSync(input, JSON.stringify({ title: 'Source B' }));
|
||||
const markerStarted = Date.now();
|
||||
while (!fs.existsSync(readMarker) && Date.now() - markerStarted < 3000) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
assert.ok(fs.existsSync(readMarker), 'fake delivery never read its generation input');
|
||||
fs.writeFileSync(input, JSON.stringify({ title: 'Source A' }));
|
||||
|
||||
const state = await waitForState(preview.url, (candidate) => candidate.status === 'verified' && candidate.revision === 1, 'snapshot generation did not verify');
|
||||
assert.equal(state.generation, 1, 'an unobserved A → B → A edit started a second generation');
|
||||
assert.equal(fs.readFileSync(readMarker, 'utf8'), 'Source A');
|
||||
assert.match(fs.readFileSync(output, 'utf8'), /Source A/);
|
||||
assert.doesNotMatch(fs.readFileSync(output, 'utf8'), /Source B/);
|
||||
} finally {
|
||||
await preview.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('preview: commit rechecks the live digest when watcher and poll have not seen a newer save', { timeout: 10000 }, async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-preview-commit-race-'));
|
||||
const input = path.join(tmp, 'diagram.json');
|
||||
const output = path.join(tmp, 'diagram.html');
|
||||
const deliveryCli = path.join(tmp, 'commit-race-delivery.mjs');
|
||||
fs.writeFileSync(deliveryCli, `
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
const [, , input, output] = process.argv.slice(2);
|
||||
const source = JSON.parse(fs.readFileSync(input, 'utf8'));
|
||||
await new Promise((resolve) => setTimeout(resolve, source.title === 'Prior Good' ? 30 : 260));
|
||||
const artifact = Buffer.from('<!doctype html><title>' + source.title + '</title><svg></svg>');
|
||||
fs.writeFileSync(output, artifact);
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
artifact: { sha256: createHash('sha256').update(artifact).digest('hex'), bytes: artifact.byteLength },
|
||||
validation: { checksPassed: 1, checkCount: 1, compositionProfile: 'showcase', compositionStatus: 'pass' }
|
||||
}));
|
||||
`);
|
||||
fs.writeFileSync(input, JSON.stringify({ title: 'Prior Good' }));
|
||||
|
||||
const preview = await startPreview({
|
||||
type: 'architecture',
|
||||
input,
|
||||
output,
|
||||
open: false,
|
||||
debounceMs: 10,
|
||||
pollMs: 800,
|
||||
watch: false,
|
||||
deliveryCli,
|
||||
});
|
||||
try {
|
||||
await waitForState(preview.url, (state) => state.status === 'verified' && state.revision === 1, 'prior good revision did not verify');
|
||||
const priorArtifact = fs.readFileSync(output, 'utf8');
|
||||
fs.writeFileSync(input, JSON.stringify({ title: 'Intermediate A' }));
|
||||
await waitForState(preview.url, (state) => state.status === 'checking' && state.generation === 2, 'intermediate generation did not start');
|
||||
fs.writeFileSync(input, JSON.stringify({ title: 'Current B' }));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 340));
|
||||
assert.equal(fs.readFileSync(output, 'utf8'), priorArtifact, 'superseded intermediate bytes replaced the prior last-good output');
|
||||
const pending = await stateAt(preview.url);
|
||||
assert.equal(pending.revision, 1, 'superseded intermediate bytes advanced the browser revision');
|
||||
|
||||
const current = await waitForState(preview.url, (state) => state.status === 'verified' && state.generation === 3, 'current generation did not verify');
|
||||
assert.equal(current.revision, 2);
|
||||
assert.match(fs.readFileSync(output, 'utf8'), /Current B/);
|
||||
assert.doesNotMatch(fs.readFileSync(output, 'utf8'), /Intermediate A/);
|
||||
} finally {
|
||||
await preview.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('preview: stopping drains an active delivery without publishing it', { timeout: 30000 }, async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-preview-stop-'));
|
||||
const input = path.join(tmp, 'diagram.json');
|
||||
const output = path.join(tmp, 'diagram.html');
|
||||
const deliveryCli = path.join(tmp, 'slow-delivery.mjs');
|
||||
const prior = '<!doctype html><title>Prior verified artifact</title>';
|
||||
fs.writeFileSync(output, prior);
|
||||
fs.writeFileSync(input, JSON.stringify({ title: 'Do not publish after stop' }));
|
||||
fs.writeFileSync(deliveryCli, `
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
const [, , , output] = process.argv.slice(2);
|
||||
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||
const artifact = Buffer.from('<!doctype html><title>Late candidate</title><svg></svg>');
|
||||
fs.writeFileSync(output, artifact);
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
artifact: { sha256: createHash('sha256').update(artifact).digest('hex'), bytes: artifact.byteLength },
|
||||
validation: { checksPassed: 1, checkCount: 1, compositionProfile: 'showcase', compositionStatus: 'pass' }
|
||||
}));
|
||||
`);
|
||||
|
||||
const preview = await startPreview({
|
||||
type: 'architecture',
|
||||
input,
|
||||
output,
|
||||
open: false,
|
||||
debounceMs: 10,
|
||||
pollMs: 100,
|
||||
deliveryCli,
|
||||
});
|
||||
await waitForState(preview.url, (state) => state.status === 'checking' && state.generation === 1, 'slow stop candidate did not start');
|
||||
await new Promise((resolve) => setTimeout(resolve, 90));
|
||||
const stoppedAt = Date.now();
|
||||
await preview.stop();
|
||||
assert.ok(Date.now() - stoppedAt >= 250, 'preview did not drain the active delivery');
|
||||
assert.equal(fs.readFileSync(output, 'utf8'), prior);
|
||||
assert.deepEqual(fs.readdirSync(tmp).filter((name) => name.startsWith('.archify-preview-')), []);
|
||||
});
|
||||
|
||||
test('preview: stopping has a bounded kill path for a delivery that never exits', { timeout: 5000 }, async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-preview-hung-stop-'));
|
||||
const input = path.join(tmp, 'diagram.json');
|
||||
const output = path.join(tmp, 'diagram.html');
|
||||
const deliveryCli = path.join(tmp, 'hung-delivery.mjs');
|
||||
const prior = '<!doctype html><title>Keep me</title>';
|
||||
fs.writeFileSync(input, JSON.stringify({ title: 'Never completes' }));
|
||||
fs.writeFileSync(output, prior);
|
||||
fs.writeFileSync(deliveryCli, `
|
||||
process.on('SIGTERM', () => {});
|
||||
setInterval(() => {}, 1000);
|
||||
`);
|
||||
|
||||
const preview = await startPreview({
|
||||
type: 'architecture',
|
||||
input,
|
||||
output,
|
||||
open: false,
|
||||
debounceMs: 10,
|
||||
pollMs: 5000,
|
||||
deliveryCli,
|
||||
stopGraceMs: 80,
|
||||
stopKillMs: 80,
|
||||
});
|
||||
await waitForState(preview.url, (state) => state.status === 'checking' && state.generation === 1, 'hung generation did not start');
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
const stoppedAt = Date.now();
|
||||
await preview.stop();
|
||||
assert.ok(Date.now() - stoppedAt < 1000, 'hung delivery kept preview shutdown open');
|
||||
assert.equal(fs.readFileSync(output, 'utf8'), prior);
|
||||
await assert.rejects(fetch(preview.url));
|
||||
assert.deepEqual(fs.readdirSync(tmp).filter((name) => name.startsWith('.archify-preview-')), []);
|
||||
});
|
||||
|
||||
test('preview: checker failures keep their actionable detail instead of a generic stage only', { timeout: 30000 }, async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-preview-checker-'));
|
||||
const input = path.join(tmp, 'diagram.json');
|
||||
const output = path.join(tmp, 'diagram.html');
|
||||
const deliveryCli = path.join(tmp, 'checker-failure.mjs');
|
||||
fs.writeFileSync(input, '{}');
|
||||
fs.writeFileSync(deliveryCli, `
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
stage: 'check',
|
||||
error: 'Final artifact check failed; the previous artifact was preserved.',
|
||||
checker: { checks: [{ name: 'single_svg', ok: false, details: ['found 2 <svg> blocks; expected exactly one'] }] }
|
||||
}));
|
||||
process.exitCode = 1;
|
||||
`);
|
||||
const preview = await startPreview({
|
||||
type: 'architecture',
|
||||
input,
|
||||
output,
|
||||
open: false,
|
||||
debounceMs: 10,
|
||||
pollMs: 100,
|
||||
deliveryCli,
|
||||
});
|
||||
try {
|
||||
const state = await waitForState(preview.url, (candidate) => candidate.status === 'needs-fix', 'checker failure did not surface');
|
||||
assert.equal(state.failure.stage, 'check');
|
||||
assert.match(state.failure.message, /Final artifact check failed/);
|
||||
assert.match(state.failure.message, /found 2 <svg> blocks; expected exactly one/);
|
||||
} finally {
|
||||
await preview.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('preview: all five typed renderers reach a verified first revision', { timeout: 60000 }, async () => {
|
||||
const cases = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
for (const [type, example] of Object.entries(cases)) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `archify-preview-${type}-`));
|
||||
const input = path.join(tmp, example);
|
||||
const output = path.join(tmp, `${type}.html`);
|
||||
fs.copyFileSync(path.join(skillRoot, 'examples', example), input);
|
||||
const preview = await startPreview({ type, input, output, open: false, debounceMs: 10, pollMs: 500 });
|
||||
try {
|
||||
const state = await waitForState(preview.url, (candidate) => candidate.status === 'verified', `${type} did not verify`);
|
||||
assert.equal(state.revision, 1, type);
|
||||
assert.equal(state.lastVerified.checksPassed, state.lastVerified.checkCount, type);
|
||||
assert.equal(state.lastVerified.sha256, sha256(output), type);
|
||||
} finally {
|
||||
await preview.stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const landing = fs.readFileSync(path.resolve(__dirname, '..', '..', 'docs', 'index.html'), 'utf8');
|
||||
|
||||
function cssRule(selector) {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const match = landing.match(new RegExp(`${escaped}\\s*\\{([^}]+)\\}`));
|
||||
assert.ok(match, `${selector}: CSS rule missing`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
test('landing declares a truthful first-fold proof aperture in document order', () => {
|
||||
assert.match(landing, /<section class="hero" data-proof-aperture="first-fold">/);
|
||||
const heroStart = landing.indexOf('data-proof-aperture="first-fold"');
|
||||
const headline = landing.indexOf('data-i18n="hero-h1"', heroStart);
|
||||
const actions = landing.indexOf('class="hero-actions', headline);
|
||||
const proof = landing.indexOf('id="hero-proof-stage"', actions);
|
||||
assert.ok(heroStart < headline && headline < actions && actions < proof);
|
||||
});
|
||||
|
||||
test('desktop hero budget exposes live diagram content without shrinking its canvas', () => {
|
||||
assert.match(cssRule('.hero'), /padding-top:9rem/);
|
||||
assert.match(cssRule('.hero-bento'), /grid-template-columns:repeat\(12,1fr\)/);
|
||||
assert.match(cssRule('.hero-intro'), /grid-column:1 \/ 8/);
|
||||
assert.match(cssRule('.proof-main'), /grid-column:8 \/ 13/);
|
||||
assert.match(cssRule('.proof-main'), /grid-row:1 \/ 3/);
|
||||
assert.match(cssRule('.hero-actions .btn'), /min-height:44px/);
|
||||
assert.match(cssRule('.proof-viewport'), /min-height:430px/);
|
||||
});
|
||||
|
||||
test('narrow viewport preserves a contained fallback without adding a mobile product surface', () => {
|
||||
const mobile = landing.match(/@media\(max-width:640px\)\s*\{([\s\S]+?)\n\s*\}\n\s*<\/style>/)?.[1];
|
||||
assert.ok(mobile, 'narrow mobile media query missing');
|
||||
assert.match(mobile, /\.hero\s*\{\s*padding-top:6\.75rem;\s*\}/);
|
||||
assert.match(mobile, /\.hero-actions \.btn\s*\{\s*flex:1;\s*justify-content:center;\s*\}/);
|
||||
assert.match(mobile, /\.proof-viewport\s*\{\s*min-height:360px;\s*\}/);
|
||||
assert.match(mobile, /\.proof-rail\s*\{\s*grid-template-columns:1fr;\s*\}/);
|
||||
});
|
||||
|
||||
test('proof aperture remains one real eager sandboxed artifact with explicit user-selected identities', () => {
|
||||
assert.equal((landing.match(/<iframe id="hero-proof-frame"/g) || []).length, 1);
|
||||
assert.match(landing, /loading="eager"/);
|
||||
assert.match(landing, /sandbox="allow-scripts"/);
|
||||
assert.doesNotMatch(landing, /sandbox="[^"]*allow-same-origin/);
|
||||
assert.equal((landing.match(/class="spec-card"/g) || []).length, 3);
|
||||
assert.match(landing, /data-proof-playback="first-fold-once"/);
|
||||
assert.match(landing, /\?embed=1&play=1&theme=dark#view=happy-path/);
|
||||
assert.doesNotMatch(landing, /setInterval\(|scrollIntoView\(|scroll-triggered|proof-carousel/);
|
||||
});
|
||||
|
||||
test('initial proof playback uses one sandboxed load without parent-frame reach-through', () => {
|
||||
assert.match(landing, /src="gallery\/artifacts\/agent-tool-call\.workflow\.html\?embed=1&play=1&theme=dark#view=happy-path"/);
|
||||
assert.doesNotMatch(landing, /initialProof|proofFrameDocumentIsReady|proofFrame\.contentWindow|proofFrame\.contentDocument/);
|
||||
assert.match(landing, /proofFrame\.addEventListener\('load', \(\) => \{/);
|
||||
assert.match(landing, /proofStage\.classList\.remove\('is-loading'\)/);
|
||||
});
|
||||
|
||||
test('proof playback delegates reduced motion to the artifact and keeps deliberate-choice boundaries', () => {
|
||||
assert.match(landing, /renderProof\(tab\.dataset\.proof, \{ deliberate: true \}\)/);
|
||||
assert.match(landing, /renderProof\(tabs\[next\]\.dataset\.proof, \{ focus: true, deliberate: true \}\)/);
|
||||
assert.match(landing, /proofEmbedUrl\(proof, \{ play: deliberate \}\)/);
|
||||
assert.match(landing, /document\.querySelectorAll\('\.fade-up'\)\.forEach\(el => el\.classList\.add\('visible'\)\)/);
|
||||
assert.doesNotMatch(landing, /addEventListener\('scroll'/);
|
||||
});
|
||||
|
||||
test('aperture uses normal flow and preserves reduced-motion boundaries', () => {
|
||||
const hero = cssRule('.hero');
|
||||
const proof = cssRule('.proof-main');
|
||||
assert.doesNotMatch(hero + proof, /position:absolute|transform:|top:-|margin-top:-|height:100vh/);
|
||||
assert.match(landing, /@media\s*\(prefers-reduced-motion:\s*reduce\)/);
|
||||
assert.match(landing, /\.fade-up\s*\{\s*opacity:1!important;\s*transform:none!important;/);
|
||||
assert.match(landing, /\.pulse-dot,\.proof-live::before\s*\{\s*animation:none!important;\s*\}/);
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(skillRoot, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-reach-share-card-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all five renderers inherit one active-reach-only Reach Share Card item', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /data-action="reach-share-card"[^>]*hidden disabled[^>]*>[\s\S]*?Reach Share Card[\s\S]*?1200(?:×|×)630 PNG/, mode);
|
||||
assert.match(html, /function syncReachShareItem\(\)/, mode);
|
||||
assert.match(html, /reachShareItem\.hidden = !snapshot;/, mode);
|
||||
assert.match(html, /reachShareItem\.disabled = !snapshot;/, mode);
|
||||
assert.match(html, /function open\(focusLast\)[\s\S]*?syncReachShareItem\(\);/, mode);
|
||||
assert.doesNotMatch(html, /id="reach-share-card"|class="reach-share-card"/, mode);
|
||||
assert.doesNotMatch(canonicalSvg(html), /data-share-reach(?:-|=)/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('Reach Share Card snapshots copy the active authored closure without rerunning traversal', () => {
|
||||
const html = render('architecture', CASES.architecture);
|
||||
const start = html.indexOf('function reachabilitySnapshot() {');
|
||||
const end = html.indexOf('\n function setPassportValue', start);
|
||||
const snapshotBlock = start >= 0 && end > start ? html.slice(start, end) : '';
|
||||
|
||||
assert.match(snapshotBlock, /activeReachability\.nodeIds\.slice\(\)/);
|
||||
assert.match(snapshotBlock, /activeReachability\.edgeKeys\.slice\(\)/);
|
||||
assert.match(snapshotBlock, /activeReachability\.depths/);
|
||||
assert.match(snapshotBlock, /direction: reachabilityMode/);
|
||||
assert.match(snapshotBlock, /origin: \{ id: originId, label:/);
|
||||
assert.match(snapshotBlock, /maxDepth: activeReachability\.maxDepth/);
|
||||
assert.match(snapshotBlock, /seenNodeIds = Object\.create\(null\)/);
|
||||
assert.match(snapshotBlock, /seenEdgeKeys = Object\.create\(null\)/);
|
||||
assert.match(snapshotBlock, /drawableFragments = fragments\.filter\(hasDrawableGeometry\)/);
|
||||
assert.match(snapshotBlock, /drawableFragments\.length !== 1/);
|
||||
assert.match(snapshotBlock, /return null/);
|
||||
assert.doesNotMatch(snapshotBlock, /computeReachability|reachabilityFor|queue\s*=|shortestDirectedPath/);
|
||||
assert.match(html, /reachabilitySnapshot: reachabilitySnapshot/);
|
||||
});
|
||||
|
||||
test('Reach variant decorates only a finite canonical clone with static authored identity', () => {
|
||||
const html = render('workflow', CASES.workflow);
|
||||
const start = html.indexOf('function applyReachSnapshot(clone, snapshot) {');
|
||||
const end = html.indexOf('\n function serializeSvg', start);
|
||||
const applyBlock = start >= 0 && end > start ? html.slice(start, end) : '';
|
||||
|
||||
assert.match(applyBlock, /snapshot\.direction !== 'upstream'/);
|
||||
assert.match(applyBlock, /snapshot\.direction !== 'downstream'/);
|
||||
assert.match(applyBlock, /snapshot\.nodeIds\.length < 2/);
|
||||
assert.match(applyBlock, /snapshot\.origin\.label\.trim\(\)/);
|
||||
assert.match(applyBlock, /nodeId === snapshot\.origin\.id \? depth !== 0 : depth < 1/);
|
||||
assert.match(applyBlock, /edge\.depth !== Math\.max\(snapshot\.depths\[edge\.from\], snapshot\.depths\[edge\.to\]\)/);
|
||||
assert.match(applyBlock, /matchedNodes\.length !== 1/);
|
||||
assert.match(applyBlock, /drawableMatches\.length !== 1/);
|
||||
assert.match(applyBlock, /data-share-reach-match/);
|
||||
assert.match(applyBlock, /data-share-reach-origin/);
|
||||
assert.match(applyBlock, /data-share-reach-depth/);
|
||||
assert.match(applyBlock, /clone\.setAttribute\('data-share-reach', snapshot\.direction\)/);
|
||||
assert.doesNotMatch(applyBlock, /setAttribute\('data-reach-(?:active|match|origin|depth)/);
|
||||
assert.doesNotMatch(applyBlock, /animation:|setTimeout|requestAnimationFrame/);
|
||||
assert.match(html, /canonicalStateClean && finiteSvgDimensions && !opts\.routeSnapshot && applyReachSnapshot\(clone, opts\.reachSnapshot\)/);
|
||||
assert.ok(html.indexOf('var canonicalStateClean =') < html.indexOf('applyReachSnapshot(clone, opts.reachSnapshot)'), 'canonical cleanup must precede reach decoration');
|
||||
});
|
||||
|
||||
test('Reach styling preserves context, direction, and Blueprint restraint without motion', () => {
|
||||
const html = render('dataflow', CASES.dataflow);
|
||||
assert.match(html, /svg\[data-share-reach\] \[data-node-id\], svg\[data-share-reach\] \[data-edge-from\] \{ opacity: 0\.14; \}/);
|
||||
assert.match(html, /svg\[data-share-reach\] \[data-share-reach-match\] \{ opacity: 1; \}/);
|
||||
assert.match(html, /data-share-reach=\\?"upstream\\?"[\s\S]*?--database-stroke/);
|
||||
assert.match(html, /data-share-reach=\\?"downstream\\?"[\s\S]*?--backend-stroke/);
|
||||
assert.match(html, /data-preset=\\?"blueprint\\?"\]\[data-share-reach\][\s\S]*?filter: none/);
|
||||
const reachStyleBlock = html.match(/if \(opts\.reachSnapshot\) \{[\s\S]*?\n \}/)?.[0] || '';
|
||||
assert.doesNotMatch(reachStyleBlock, /animation:|display:\s*none|transform:/);
|
||||
});
|
||||
|
||||
test('Reach Share Card reuses the 1200x630 seam and publishes a truthful scoped receipt', () => {
|
||||
const html = render('architecture', CASES.architecture);
|
||||
assert.match(html, /options\.variant !== 'route' && options\.variant !== 'reach'/);
|
||||
assert.match(html, /Archify\.focus\.reachabilitySnapshot\(\)/);
|
||||
assert.match(html, /renderShareCard\(\{ reachSnapshot: snapshot \}\)/);
|
||||
assert.doesNotMatch(html, /function rasterizeReachShareCard|reachShareCard:/);
|
||||
assert.match(html, /viewerText\('viewer\.export\.card\.reachSummary'/);
|
||||
assert.match(html, /direction: directionLabel/);
|
||||
assert.match(html, /origin: reachSnapshot\.origin\.label/);
|
||||
assert.match(html, /reachSnapshot\.nodeIds\.length - 1/);
|
||||
assert.match(html, /reachSnapshot\.edges\.length/);
|
||||
assert.match(html, /reachSnapshot\.maxDepth/);
|
||||
assert.match(html, /recordExportReceipt\('share-card', blob, false, \{ width: SHARE_CARD_WIDTH, height: SHARE_CARD_HEIGHT \}, 'reach', false, true\)/);
|
||||
assert.match(html, /'-' \+ snapshot\.direction \+ '-reach-share-card\.png'/);
|
||||
assert.match(html, /data-last-export-reach-state-clean/);
|
||||
assert.match(html, /Trace authored reach before exporting a Reach Share Card/);
|
||||
assert.match(html, /downloadReachShareCard: runReachShareCard/);
|
||||
});
|
||||
|
||||
test('Skill, product docs, and READMEs keep the optional truthful boundary explicit', () => {
|
||||
const viewer = fs.readFileSync(path.join(skillRoot, 'references', 'viewer-runtime.md'), 'utf8');
|
||||
assert.match(viewer, /Export → Reach Share Card/);
|
||||
assert.match(viewer, /variant=reach/);
|
||||
assert.match(viewer, /data-share-reach-\*/);
|
||||
assert.match(viewer, /authored reachability/i);
|
||||
assert.match(viewer, /download-only/i);
|
||||
|
||||
for (const readme of ['README.md', 'README_EN.md', 'README_ZH.md']) {
|
||||
const text = fs.readFileSync(path.join(repoRoot, readme), 'utf8');
|
||||
assert.match(text, /Reach Share Card/, readme);
|
||||
assert.match(text, /docs\/assets\/mco-runtime-reach-share-card\.png/, readme);
|
||||
}
|
||||
const png = fs.readFileSync(path.join(repoRoot, 'docs/assets/mco-runtime-reach-share-card.png'));
|
||||
assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
|
||||
assert.equal(png.readUInt32BE(16), 1200);
|
||||
assert.equal(png.readUInt32BE(20), 630);
|
||||
|
||||
const product = fs.readFileSync(path.join(repoRoot, 'PRODUCT.md'), 'utf8');
|
||||
const design = fs.readFileSync(path.join(repoRoot, 'DESIGN.md'), 'utf8');
|
||||
assert.match(product, /Reach Share Card/);
|
||||
assert.match(design, /Reach Share Card/);
|
||||
assert.match(design, /not (?:runtime )?(?:impact|causality|breakage)/i);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,295 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(skillRoot, '..');
|
||||
const assetPath = path.join(repoRoot, 'docs', 'assets', 'archify-live-proof.gif');
|
||||
const receiptPath = path.join(repoRoot, 'docs', 'assets', 'archify-live-proof.json');
|
||||
|
||||
function sha256(file) {
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
}
|
||||
|
||||
function git(cwd, ...args) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
|
||||
}
|
||||
|
||||
function writeStarHistoryCharts(cwd, version) {
|
||||
const assets = path.join(cwd, 'assets');
|
||||
fs.mkdirSync(assets, { recursive: true });
|
||||
fs.writeFileSync(path.join(assets, 'star-history-light.svg'), `<svg><title>light ${version}</title></svg>\n`);
|
||||
fs.writeFileSync(path.join(assets, 'star-history-dark.svg'), `<svg><title>dark ${version}</title></svg>\n`);
|
||||
}
|
||||
|
||||
function skipSubBlocks(buffer, start) {
|
||||
let offset = start;
|
||||
while (offset < buffer.length) {
|
||||
const size = buffer[offset];
|
||||
offset += 1;
|
||||
if (size === 0) return offset;
|
||||
offset += size;
|
||||
}
|
||||
throw new Error('GIF sub-block runs past end of file');
|
||||
}
|
||||
|
||||
function inspectGif(buffer) {
|
||||
assert.match(buffer.subarray(0, 6).toString('ascii'), /^GIF8[79]a$/);
|
||||
const width = buffer.readUInt16LE(6);
|
||||
const height = buffer.readUInt16LE(8);
|
||||
const packed = buffer[10];
|
||||
let offset = 13;
|
||||
if (packed & 0x80) offset += 3 * (2 ** ((packed & 0x07) + 1));
|
||||
let frameCount = 0;
|
||||
let durationCentiseconds = 0;
|
||||
let trailer = false;
|
||||
|
||||
while (offset < buffer.length) {
|
||||
const marker = buffer[offset];
|
||||
offset += 1;
|
||||
if (marker === 0x3b) {
|
||||
trailer = true;
|
||||
break;
|
||||
}
|
||||
if (marker === 0x21) {
|
||||
const label = buffer[offset];
|
||||
offset += 1;
|
||||
if (label === 0xf9) {
|
||||
const blockSize = buffer[offset];
|
||||
offset += 1;
|
||||
assert.equal(blockSize, 4, 'unexpected graphic-control block size');
|
||||
durationCentiseconds += buffer.readUInt16LE(offset + 1);
|
||||
offset += blockSize;
|
||||
assert.equal(buffer[offset], 0, 'graphic-control block missing terminator');
|
||||
offset += 1;
|
||||
} else {
|
||||
offset = skipSubBlocks(buffer, offset);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (marker === 0x2c) {
|
||||
frameCount += 1;
|
||||
const localPacked = buffer[offset + 8];
|
||||
offset += 9;
|
||||
if (localPacked & 0x80) offset += 3 * (2 ** ((localPacked & 0x07) + 1));
|
||||
offset += 1;
|
||||
offset = skipSubBlocks(buffer, offset);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`unexpected GIF marker 0x${marker.toString(16)} at ${offset - 1}`);
|
||||
}
|
||||
assert.equal(trailer, true, 'GIF trailer missing');
|
||||
return { width, height, frameCount, durationSeconds: durationCentiseconds / 100 };
|
||||
}
|
||||
|
||||
test('README motion proof is compact, looping, and backed by current gallery artifacts', () => {
|
||||
const builder = fs.readFileSync(path.join(repoRoot, 'scripts', 'build-readme-showcase.mjs'), 'utf8');
|
||||
assert.match(builder, /\?embed=1&play=1&theme=dark#view=/);
|
||||
const buffer = fs.readFileSync(assetPath);
|
||||
const receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8'));
|
||||
const inspected = inspectGif(buffer);
|
||||
|
||||
assert.deepEqual(inspected, { width: 960, height: 540, frameCount: 54, durationSeconds: 5.4 });
|
||||
assert.ok(buffer.includes(Buffer.from('NETSCAPE2.0')), 'GIF must loop continuously');
|
||||
assert.ok(buffer.byteLength <= 3 * 1024 * 1024, `README GIF is too large: ${buffer.byteLength} bytes`);
|
||||
assert.equal(receipt.schemaVersion, 1);
|
||||
assert.equal(receipt.generator, 'scripts/build-readme-showcase.mjs');
|
||||
assert.equal(receipt.output, 'docs/assets/archify-live-proof.gif');
|
||||
assert.equal(receipt.width, inspected.width);
|
||||
assert.equal(receipt.height, inspected.height);
|
||||
assert.equal(receipt.frameCount, inspected.frameCount);
|
||||
assert.equal(receipt.durationSeconds, inspected.durationSeconds);
|
||||
assert.equal(receipt.bytes, buffer.byteLength);
|
||||
assert.equal(receipt.sha256, sha256(assetPath));
|
||||
assert.deepEqual(receipt.scenes.map(scene => scene.id), ['signal-flow', 'blueprint', 'classic']);
|
||||
for (const scene of receipt.scenes) {
|
||||
const artifact = path.join(repoRoot, scene.artifact);
|
||||
assert.ok(fs.existsSync(artifact), `${scene.id}: source artifact missing`);
|
||||
assert.equal(scene.artifactSha256, sha256(artifact), `${scene.id}: source artifact drift; rebuild README showcase`);
|
||||
assert.match(scene.receipt, /9\/9 checks/);
|
||||
}
|
||||
});
|
||||
|
||||
test('all README languages keep the product hero and retain the verified animated proof', () => {
|
||||
for (const filename of ['README.md', 'README_EN.md', 'README_ZH.md']) {
|
||||
const readme = fs.readFileSync(path.join(repoRoot, filename), 'utf8');
|
||||
const heroIndex = readme.indexOf('docs/assets/archify-readme-hero.png');
|
||||
const titleIndex = readme.indexOf('# Archify');
|
||||
const proofIndex = readme.indexOf('docs/assets/archify-live-proof.gif');
|
||||
const demosIndex = Math.max(readme.indexOf('## See Archify in action'), readme.indexOf('## 看看 Archify 能做什么'));
|
||||
assert.ok(heroIndex >= 0 && heroIndex < titleIndex, `${filename}: product hero is not above the title`);
|
||||
assert.ok(proofIndex > demosIndex, `${filename}: animated proof must live in the demo section`);
|
||||
assert.match(readme, /docs\/assets\/archify-live-proof\.gif/);
|
||||
assert.match(readme, /https:\/\/tt-a1i\.github\.io\/archify\/gallery\.html/);
|
||||
}
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8'),
|
||||
fs.readFileSync(path.join(repoRoot, 'README_EN.md'), 'utf8'),
|
||||
'README.md and README_EN.md must stay synchronized',
|
||||
);
|
||||
});
|
||||
|
||||
test('README demos use checked-in captures and live deep links below the existing hero', () => {
|
||||
const demos = [
|
||||
{
|
||||
asset: 'archify-demo-story.png',
|
||||
link: 'agent-tool-call.workflow.html?theme=dark&present=1&play=1#view=happy-path',
|
||||
},
|
||||
{
|
||||
asset: 'archify-demo-route.png',
|
||||
link: 'cache-miss.sequence.html?theme=dark&present=1#route=web~db',
|
||||
},
|
||||
{
|
||||
asset: 'archify-demo-lens.png',
|
||||
link: 'production-deployment.architecture.html?theme=dark&present=1#lens=backend~database',
|
||||
},
|
||||
];
|
||||
|
||||
for (const demo of demos) {
|
||||
const buffer = fs.readFileSync(path.join(repoRoot, 'docs', 'assets', demo.asset));
|
||||
assert.equal(buffer.subarray(1, 4).toString('ascii'), 'PNG', `${demo.asset}: invalid PNG signature`);
|
||||
assert.equal(buffer.readUInt32BE(16), 1280, `${demo.asset}: unexpected width`);
|
||||
assert.equal(buffer.readUInt32BE(20), 720, `${demo.asset}: unexpected height`);
|
||||
assert.ok(buffer.byteLength < 400 * 1024, `${demo.asset}: capture is too large`);
|
||||
}
|
||||
|
||||
for (const filename of ['README.md', 'README_EN.md', 'README_ZH.md']) {
|
||||
const readme = fs.readFileSync(path.join(repoRoot, filename), 'utf8');
|
||||
const heroIndex = readme.indexOf('docs/assets/archify-readme-hero.png');
|
||||
const proofIndex = readme.indexOf('docs/assets/archify-live-proof.gif');
|
||||
const previewIndex = Math.max(readme.indexOf('## Preview'), readme.indexOf('## 预览'));
|
||||
const demosIndex = Math.max(readme.indexOf('## See Archify in action'), readme.indexOf('## 看看 Archify 能做什么'));
|
||||
const quickStartIndex = Math.max(readme.indexOf('## Quick start'), readme.indexOf('## 快速开始'));
|
||||
assert.ok(heroIndex >= 0 && heroIndex < demosIndex, `${filename}: existing hero proof moved`);
|
||||
assert.ok(demosIndex < previewIndex && previewIndex < quickStartIndex, `${filename}: demo section is misplaced`);
|
||||
assert.ok(demosIndex < proofIndex && proofIndex < previewIndex, `${filename}: animated proof is outside the demo section`);
|
||||
for (const demo of demos) {
|
||||
assert.match(readme, new RegExp(`docs/assets/${demo.asset.replaceAll('.', '\\.')}`));
|
||||
assert.ok(readme.includes(demo.link), `${filename}: missing ${demo.link}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('README stays scannable without deleting the visual proof set', () => {
|
||||
const commonAssets = [
|
||||
'archify-readme-hero.png',
|
||||
'archify-live-proof.gif',
|
||||
'archify-demo-story.png',
|
||||
'archify-demo-route.png',
|
||||
'archify-demo-lens.png',
|
||||
'mco-runtime-share-card.png',
|
||||
'archify-dark.png',
|
||||
'archify-light.png',
|
||||
'archify-menu.png',
|
||||
'archify-workflow.png',
|
||||
'archify-sequence.png',
|
||||
'archify-dataflow.png',
|
||||
'archify-lifecycle.png',
|
||||
];
|
||||
|
||||
for (const filename of ['README.md', 'README_EN.md', 'README_ZH.md']) {
|
||||
const readme = fs.readFileSync(path.join(repoRoot, filename), 'utf8');
|
||||
assert.ok(readme.split('\n').length <= 295, `${filename}: README grew beyond the scannable line budget`);
|
||||
assert.match(readme, filename === 'README_ZH.md' ? /不需要绑定代码库/ : /No repository is required/);
|
||||
for (const asset of commonAssets) {
|
||||
assert.ok(readme.includes(`docs/assets/${asset}`), `${filename}: visual proof ${asset} was removed`);
|
||||
}
|
||||
}
|
||||
|
||||
const english = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8');
|
||||
const wordCount = english.trim().split(/\s+/).length;
|
||||
const intro = english.slice(0, english.indexOf('![License]'));
|
||||
const introBullets = intro.match(/^- \*\*/gm) || [];
|
||||
assert.ok(wordCount <= 2085, `README.md is too verbose again (${wordCount} words)`);
|
||||
assert.ok(introBullets.length <= 8, `README.md has too many top-level capability bullets (${introBullets.length})`);
|
||||
|
||||
const chinese = fs.readFileSync(path.join(repoRoot, 'README_ZH.md'), 'utf8');
|
||||
assert.ok(chinese.includes('docs/assets/claude-skills-settings.png'), 'README_ZH.md lost the Claude Skills setup image');
|
||||
});
|
||||
|
||||
test('all README languages end with the self-hosted star history chart', () => {
|
||||
const lightChart = 'https://raw.githubusercontent.com/tt-a1i/archify/star-history/assets/star-history-light.svg';
|
||||
const darkChart = 'https://raw.githubusercontent.com/tt-a1i/archify/star-history/assets/star-history-dark.svg';
|
||||
const workflow = fs.readFileSync(path.join(repoRoot, '.github', 'workflows', 'star-history.yml'), 'utf8');
|
||||
|
||||
for (const filename of ['README.md', 'README_EN.md', 'README_ZH.md']) {
|
||||
const readme = fs.readFileSync(path.join(repoRoot, filename), 'utf8');
|
||||
const sectionIndex = readme.lastIndexOf('## Star History');
|
||||
const contributingIndex = Math.max(readme.indexOf('## Contributing'), readme.indexOf('## 参与贡献'));
|
||||
assert.ok(sectionIndex > contributingIndex, `${filename}: Star History must follow Contributing`);
|
||||
assert.ok(readme.includes(lightChart), `${filename}: missing light star history chart`);
|
||||
assert.ok(readme.includes(darkChart), `${filename}: missing dark star history chart`);
|
||||
assert.equal(readme.trimEnd().endsWith('</p>'), true, `${filename}: Star History must remain the final section`);
|
||||
}
|
||||
|
||||
assert.match(workflow, /permissions:\n contents: write/);
|
||||
assert.match(workflow, /narayann7\/star-history-action@[0-9a-f]{40}/);
|
||||
assert.match(workflow, /output-dir: assets/);
|
||||
assert.match(workflow, /update-readme: ['"]false['"]/);
|
||||
assert.match(workflow, /commit: ['"]false['"]/);
|
||||
assert.match(workflow, /bash scripts\/publish-star-history\.sh star-history/);
|
||||
assert.doesNotMatch(workflow, /branch: star-history/);
|
||||
assert.doesNotMatch(workflow, /xpzouying\/star-history/);
|
||||
});
|
||||
|
||||
test('Star History publishing advances the data branch without a force push', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-star-history-'));
|
||||
const remote = path.join(fixture, 'remote.git');
|
||||
const firstCheckout = path.join(fixture, 'first');
|
||||
const secondCheckout = path.join(fixture, 'second');
|
||||
const publisher = path.join(repoRoot, 'scripts', 'publish-star-history.sh');
|
||||
|
||||
try {
|
||||
git(fixture, 'init', '--bare', remote);
|
||||
git(fixture, '--git-dir', remote, 'config', 'receive.denyNonFastForwards', 'true');
|
||||
git(fixture, '--git-dir', remote, 'config', 'receive.denyDeletes', 'true');
|
||||
|
||||
fs.mkdirSync(firstCheckout);
|
||||
git(firstCheckout, 'init', '-b', 'main');
|
||||
git(firstCheckout, 'config', 'user.name', 'Fixture');
|
||||
git(firstCheckout, 'config', 'user.email', 'fixture@example.com');
|
||||
fs.writeFileSync(path.join(firstCheckout, 'README.md'), 'fixture\n');
|
||||
git(firstCheckout, 'add', 'README.md');
|
||||
git(firstCheckout, 'commit', '-m', 'seed');
|
||||
git(firstCheckout, 'remote', 'add', 'origin', remote);
|
||||
git(firstCheckout, 'push', '-u', 'origin', 'main');
|
||||
|
||||
const firstTemp = path.join(fixture, 'run-1');
|
||||
fs.mkdirSync(firstTemp);
|
||||
writeStarHistoryCharts(firstCheckout, 'v1');
|
||||
execFileSync('bash', [publisher, 'star-history'], {
|
||||
cwd: firstCheckout,
|
||||
env: { ...process.env, RUNNER_TEMP: firstTemp },
|
||||
});
|
||||
const firstCommit = git(fixture, '--git-dir', remote, 'rev-parse', 'refs/heads/star-history');
|
||||
|
||||
git(fixture, 'clone', '--branch', 'main', remote, secondCheckout);
|
||||
const secondTemp = path.join(fixture, 'run-2');
|
||||
fs.mkdirSync(secondTemp);
|
||||
writeStarHistoryCharts(secondCheckout, 'v2');
|
||||
execFileSync('bash', [publisher, 'star-history'], {
|
||||
cwd: secondCheckout,
|
||||
env: { ...process.env, RUNNER_TEMP: secondTemp },
|
||||
});
|
||||
const secondCommit = git(fixture, '--git-dir', remote, 'rev-parse', 'refs/heads/star-history');
|
||||
|
||||
assert.notEqual(secondCommit, firstCommit);
|
||||
git(fixture, '--git-dir', remote, 'merge-base', '--is-ancestor', firstCommit, secondCommit);
|
||||
assert.deepEqual(
|
||||
git(fixture, '--git-dir', remote, 'ls-tree', '-r', '--name-only', secondCommit).split('\n'),
|
||||
['assets/star-history-dark.svg', 'assets/star-history-light.svg'],
|
||||
);
|
||||
assert.match(
|
||||
git(fixture, '--git-dir', remote, 'show', `${secondCommit}:assets/star-history-light.svg`),
|
||||
/light v2/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { verifyRepositoryEvidence } from '../renderers/shared/repository-evidence.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(skillRoot, '..');
|
||||
const sourcePath = path.join(repoRoot, 'docs', 'cases', 'mco-runtime.architecture.json');
|
||||
const artifactPath = path.join(repoRoot, 'docs', 'cases', 'mco-runtime.architecture.html');
|
||||
const shareCardPath = path.join(repoRoot, 'docs', 'assets', 'mco-runtime-share-card.png');
|
||||
const experimentSourcePath = path.join(repoRoot, 'experiments', 'mco-showcase', 'mco-runtime.architecture.json');
|
||||
const experimentArtifactPath = path.join(repoRoot, 'experiments', 'mco-showcase', 'mco-runtime.html');
|
||||
const cli = path.join(skillRoot, 'bin', 'archify.mjs');
|
||||
const pinnedSource = JSON.parse(fs.readFileSync(sourcePath, 'utf8'));
|
||||
const pinnedRepository = pinnedSource.meta.repository;
|
||||
|
||||
function evidencePayload(html) {
|
||||
const match = html.match(/<script id="archify-source-evidence-data" type="application\/json">([\s\S]*?)<\/script>/);
|
||||
assert.ok(match, 'checked-in MCO proof is missing verified repository evidence');
|
||||
return JSON.parse(match[1]);
|
||||
}
|
||||
|
||||
function connectionLabelGeometry(html) {
|
||||
return Object.fromEntries([...html.matchAll(
|
||||
/<g data-detail="context"[^>]*data-edge-id="([^"]+)"[^>]*>[\s\S]*?<text x="([^"]+)" y="([^"]+)"/g,
|
||||
)].map((match) => [match[1], { x: Number(match[2]), y: Number(match[3]) }]));
|
||||
}
|
||||
|
||||
function automaticMcoRoot() {
|
||||
const candidate = path.resolve(repoRoot, '..', 'mco');
|
||||
if (!fs.existsSync(path.join(candidate, '.git'))) return null;
|
||||
try {
|
||||
verifyRepositoryEvidence('architecture', pinnedSource, candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const pinnedMcoRoot = process.env.ARCHIFY_MCO_REPO_ROOT
|
||||
? path.resolve(process.env.ARCHIFY_MCO_REPO_ROOT)
|
||||
: automaticMcoRoot();
|
||||
|
||||
test('MCO showcase preserves checked-in connection-label geometry', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-mco-showcase-layout-'));
|
||||
try {
|
||||
const source = JSON.parse(fs.readFileSync(experimentSourcePath, 'utf8'));
|
||||
assert.deepEqual(
|
||||
source.meta.repository,
|
||||
pinnedRepository,
|
||||
'MCO case and experiment must pin the same repository revision',
|
||||
);
|
||||
delete source.meta.repository;
|
||||
for (const component of source.components) delete component.sources;
|
||||
const input = path.join(tmp, 'mco-runtime.architecture.json');
|
||||
const output = path.join(tmp, 'mco-runtime.html');
|
||||
fs.writeFileSync(input, `${JSON.stringify(source, null, 2)}\n`);
|
||||
|
||||
const rendered = spawnSync(process.execPath, [
|
||||
cli,
|
||||
'render',
|
||||
'architecture',
|
||||
input,
|
||||
output,
|
||||
'--quality',
|
||||
'showcase',
|
||||
], { encoding: 'utf8' });
|
||||
assert.equal(rendered.status, 0, `${rendered.stdout}\n${rendered.stderr}`);
|
||||
|
||||
const renderedHtml = fs.readFileSync(output, 'utf8');
|
||||
const checkedInHtml = fs.readFileSync(experimentArtifactPath, 'utf8');
|
||||
const renderedLabels = connectionLabelGeometry(renderedHtml);
|
||||
assert.ok(Object.keys(renderedLabels).length >= 8, 'expected the authored MCO connection labels');
|
||||
assert.deepEqual(
|
||||
connectionLabelGeometry(checkedInHtml),
|
||||
renderedLabels,
|
||||
'checked-in MCO connection-label geometry drifted from its typed source',
|
||||
);
|
||||
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('checked-in MCO artifacts are byte-reproducible from the pinned repository revision', {
|
||||
skip: pinnedMcoRoot
|
||||
? false
|
||||
: `Set ARCHIFY_MCO_REPO_ROOT to a matching ${pinnedRepository.url} clone containing revision ${pinnedRepository.revision.slice(0, 7)}.`,
|
||||
}, () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-mco-byte-reproduction-'));
|
||||
try {
|
||||
const experimentOutput = path.join(tmp, 'mco-runtime.experiment.html');
|
||||
const experiment = spawnSync(process.execPath, [
|
||||
cli,
|
||||
'render',
|
||||
'architecture',
|
||||
experimentSourcePath,
|
||||
experimentOutput,
|
||||
'--quality',
|
||||
'showcase',
|
||||
'--repo-root',
|
||||
pinnedMcoRoot,
|
||||
], { encoding: 'utf8' });
|
||||
assert.equal(experiment.status, 0, `${experiment.stdout}\n${experiment.stderr}`);
|
||||
assert.equal(
|
||||
fs.readFileSync(experimentOutput, 'utf8'),
|
||||
fs.readFileSync(experimentArtifactPath, 'utf8'),
|
||||
'checked-in MCO showcase drifted from its typed source and verified repository',
|
||||
);
|
||||
|
||||
const caseOutput = path.join(tmp, 'mco-runtime.case.html');
|
||||
const delivered = spawnSync(process.execPath, [
|
||||
cli,
|
||||
'deliver',
|
||||
'architecture',
|
||||
sourcePath,
|
||||
caseOutput,
|
||||
'--quality',
|
||||
'showcase',
|
||||
'--repo-root',
|
||||
pinnedMcoRoot,
|
||||
'--json',
|
||||
], { encoding: 'utf8' });
|
||||
assert.equal(delivered.status, 0, `${delivered.stdout}\n${delivered.stderr}`);
|
||||
assert.equal(JSON.parse(delivered.stdout).ok, true);
|
||||
assert.equal(
|
||||
fs.readFileSync(caseOutput, 'utf8'),
|
||||
fs.readFileSync(artifactPath, 'utf8'),
|
||||
'checked-in MCO case drifted from its typed source and verified repository',
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('MCO public proof is source-backed, valid, and linked from every README', () => {
|
||||
const source = JSON.parse(fs.readFileSync(sourcePath, 'utf8'));
|
||||
assert.equal(source.meta.title, 'MCO Runtime Architecture');
|
||||
assert.equal(source.meta.quality_profile, 'showcase');
|
||||
assert.equal(source.meta.animation, 'trace');
|
||||
assert.deepEqual(source.meta.views.map(view => view.id), [
|
||||
'dispatch-path',
|
||||
'answer-evidence',
|
||||
'durable-sessions',
|
||||
]);
|
||||
assert.equal(source.components.length, 13);
|
||||
assert.equal(source.connections.length, 12);
|
||||
assert.match(source.components.find((component) => component.id === 'router')?.sublabel || '', /\bdoctor\b/);
|
||||
assert.match(source.components.find((component) => component.id === 'adapters')?.sublabel || '', /\bdetect\b/);
|
||||
assert.match(source.meta.repository.url, /^https:\/\/github\.com\/[^/]+\/[^/]+$/);
|
||||
assert.match(source.meta.repository.revision, /^[0-9a-f]{40}$/);
|
||||
const references = source.components.reduce((count, component) => count + (component.sources?.length || 0), 0);
|
||||
assert.equal(references, 13);
|
||||
const cardCopy = JSON.stringify(source.cards);
|
||||
assert.ok(cardCopy.includes(`main @ ${source.meta.repository.revision.slice(0, 7)}`));
|
||||
assert.ok(cardCopy.includes(new URL(source.meta.repository.url).host + new URL(source.meta.repository.url).pathname));
|
||||
|
||||
const checkedInHtml = fs.readFileSync(artifactPath, 'utf8');
|
||||
const evidence = evidencePayload(checkedInHtml);
|
||||
assert.equal(evidence.verified, true);
|
||||
assert.equal(evidence.repository.url, source.meta.repository.url);
|
||||
assert.equal(evidence.repository.revision, source.meta.repository.revision);
|
||||
assert.equal(evidence.repository.shortRevision, source.meta.repository.revision.slice(0, 7));
|
||||
assert.equal(evidence.referenceCount, references);
|
||||
assert.match(checkedInHtml, /Archify\.sourceEvidence\.installBeacons\(\)/);
|
||||
execFileSync(process.execPath, [cli, 'check', artifactPath], { encoding: 'utf8' });
|
||||
|
||||
const noRootTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-mco-proof-no-root-'));
|
||||
try {
|
||||
const output = path.join(noRootTmp, 'mco-runtime.html');
|
||||
const result = spawnSync(process.execPath, [
|
||||
cli,
|
||||
'deliver',
|
||||
'architecture',
|
||||
sourcePath,
|
||||
output,
|
||||
'--quality',
|
||||
'showcase',
|
||||
'--json',
|
||||
], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(JSON.parse(result.stdout).error, /Pass --repo-root/);
|
||||
assert.equal(fs.existsSync(output), false);
|
||||
} finally {
|
||||
fs.rmSync(noRootTmp, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const png = fs.readFileSync(shareCardPath);
|
||||
assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
|
||||
assert.equal(png.readUInt32BE(16), 1200);
|
||||
assert.equal(png.readUInt32BE(20), 630);
|
||||
assert.ok(png.byteLength > 20_000, 'MCO Share Card is unexpectedly small');
|
||||
|
||||
const repositorySlug = new URL(source.meta.repository.url).pathname.replace(/^\/|\/$/g, '');
|
||||
const shortRevision = source.meta.repository.revision.slice(0, 7);
|
||||
for (const filename of ['README.md', 'README_EN.md', 'README_ZH.md']) {
|
||||
const readme = fs.readFileSync(path.join(repoRoot, filename), 'utf8');
|
||||
assert.match(readme, /docs\/assets\/mco-runtime-share-card\.png/);
|
||||
assert.match(readme, /cases\/mco-runtime\.architecture\.html\?theme=dark&present=1#view=dispatch-path/);
|
||||
assert.match(readme, /docs\/cases\/mco-runtime\.architecture\.json/);
|
||||
assert.ok(readme.includes(`[\`${repositorySlug}\`](${source.meta.repository.url})`), `${filename}: repository link drifted`);
|
||||
assert.ok(readme.includes(`\`${shortRevision}\``), `${filename}: repository revision drifted`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets', 'template.html'), 'utf8');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-relationship-direct-explorer-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
const result = spawnSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
], { encoding: 'utf8' });
|
||||
return { result, html: fs.existsSync(output) ? fs.readFileSync(output, 'utf8') : '' };
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all five renderers inherit one viewer-only Direct Relationship Explorer', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const { result, html } = render(mode, example);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(html, /function installRelationshipHitTargets\(\)/, mode);
|
||||
assert.match(html, /data-relationship-hit-overlay/, mode);
|
||||
assert.match(html, /className \|\| 'relationship-hit-rail'/, mode);
|
||||
assert.doesNotMatch(canonicalSvg(html), /relationship-hit-(?:overlay|target|rail)|data-relationship-direct-active/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('one roving target represents each exact stable edge key and authored direction', () => {
|
||||
assert.match(template, /function relationshipHitRecords\(\)/);
|
||||
assert.match(template, /var recordsByKey = \{\}/);
|
||||
assert.match(template, /existing\.invalid = true/);
|
||||
assert.match(template, /filter\(function \(record\) \{ return !record\.invalid/);
|
||||
assert.match(template, /data-relationship-key/);
|
||||
assert.match(template, /data-relationship-from/);
|
||||
assert.match(template, /data-relationship-to/);
|
||||
assert.match(template, /target\.setAttribute\('role', 'button'\)/);
|
||||
assert.match(template, /relationshipHitOverlay\.setAttribute\('role', 'group'\)/);
|
||||
assert.match(template, /target\.setAttribute\('aria-describedby', relationshipHelp\.id\)/);
|
||||
assert.match(template, /target\.setAttribute\('tabindex', index === 0 \? '0' : '-1'\)/);
|
||||
assert.match(template, /viewerText\('viewer\.passport\.relationship\.inspect'/);
|
||||
assert.match(template, /relationshipEdgeShapes\(edge\)/);
|
||||
assert.match(template, /shape\.cloneNode\(false\)/);
|
||||
assert.match(template, /\.relationship-hit-rail \{[\s\S]*stroke: transparent;[\s\S]*stroke-width: 24/);
|
||||
});
|
||||
|
||||
test('fine-pointer and keyboard intent preview the exact edge before activation', () => {
|
||||
assert.match(template, /function directRelationshipBlocked\(\)/);
|
||||
assert.match(template, /function scheduleDirectRelationshipPreview\(target\)/);
|
||||
assert.match(template, /if \(pinnedRelationshipKey \|\| hoveredRelationship !== target/);
|
||||
assert.match(template, /previewRelationship\(target, \{ direct: true \}\)/);
|
||||
assert.match(template, /event\.pointerType === 'touch'/);
|
||||
assert.match(template, /finePointerQuery && !finePointerQuery\.matches/);
|
||||
assert.match(template, /addEventListener\('pointerover'/);
|
||||
assert.match(template, /addEventListener\('pointerout'/);
|
||||
assert.match(template, /addEventListener\('focusin'/);
|
||||
assert.match(template, /addEventListener\('focusout'/);
|
||||
assert.match(template, /data-relationship-direct-active/);
|
||||
assert.match(template, /\.relationship-hit-target:focus-visible \.relationship-focus-rail/);
|
||||
});
|
||||
|
||||
test('activation opens the existing source passport and pins its exact relationship row', () => {
|
||||
assert.match(template, /function inspectRelationship\(key, options\)/);
|
||||
assert.match(template, /if \(directPreviewTimer\) window\.clearTimeout\(directPreviewTimer\)/);
|
||||
assert.match(template, /if \(pinnedRelationshipKey === key\)/);
|
||||
assert.match(template, /set\(record\.from, \{ toggle: false, updateUrl: false \}\)/);
|
||||
assert.match(template, /relationshipList\.querySelectorAll\('\[data-relationship-key\]'\)/);
|
||||
assert.match(template, /pinnedRelationship = row/);
|
||||
assert.match(template, /pinnedRelationshipKey = key/);
|
||||
assert.match(template, /data-relationship-pin-active/);
|
||||
assert.match(template, /function clearRelationshipPreview\(options\)/);
|
||||
assert.match(template, /clearRelationshipPreview\(\{ clearPin: true \}\)/);
|
||||
assert.match(template, /copyBtn\.textContent = viewerText\('viewer\.passport\.copyNode'\)/);
|
||||
assert.match(template, /previewRelationship\(row\)/);
|
||||
assert.match(template, /inspectRelationship: inspectRelationship/);
|
||||
assert.match(template, /event\.key !== 'ArrowRight'/);
|
||||
assert.match(template, /event\.key !== 'ArrowLeft'/);
|
||||
assert.match(template, /event\.key !== 'Home'/);
|
||||
assert.match(template, /event\.key !== 'End'/);
|
||||
assert.match(template, /event\.key === 'Enter' \|\| event\.key === ' '/);
|
||||
});
|
||||
|
||||
test('direct relationship targets support one-tap touch, yield to stronger states, and stay export-clean', () => {
|
||||
assert.match(template, /html\.getAttribute\('data-embed'\) === 'true'/);
|
||||
assert.match(template, /svg\.hasAttribute\('data-story-active'\)/);
|
||||
assert.match(template, /svg\.hasAttribute\('data-route-active'\)/);
|
||||
assert.match(template, /svg\.hasAttribute\('data-lens-active'\)/);
|
||||
assert.match(template, /event\.target\.closest\('\[data-relationship-hit-key\]'\)/);
|
||||
assert.match(template, /@media \(hover: none\), \(pointer: coarse\)[\s\S]*\.relationship-hit-rail \{ stroke-width: 24/);
|
||||
assert.match(template, /@media print \{[\s\S]*\.relationship-hit-overlay/);
|
||||
assert.match(template, /clone\.removeAttribute\('data-relationship-direct-active'\)/);
|
||||
assert.match(template, /clone\.removeAttribute\('data-relationship-pin-active'\)/);
|
||||
assert.match(template, /clone\.querySelectorAll\('\[data-relationship-hit-overlay\]'\)/);
|
||||
assert.match(template, /\[data-relationship-hit-overlay\][^']*\[data-relationship-pulse-overlay\]/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,128 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-relationship-lens-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: { example: 'web-app.architecture.json', collection: 'connections' },
|
||||
workflow: { example: 'agent-tool-call.workflow.json', collection: 'edges' },
|
||||
sequence: { example: 'cache-miss-request.sequence.json', collection: 'messages' },
|
||||
dataflow: { example: 'product-analytics.dataflow.json', collection: 'flows' },
|
||||
lifecycle: { example: 'agent-run.lifecycle.json', collection: 'transitions' },
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function svg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
test('all typed renderers expose named, stable relationships without changing geometry', () => {
|
||||
for (const [mode, config] of Object.entries(CASES)) {
|
||||
const html = render(mode, config.example);
|
||||
const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', config.example), 'utf8'));
|
||||
const relationships = source[config.collection];
|
||||
const diagram = svg(html);
|
||||
const keys = new Set(Array.from(diagram.matchAll(/data-edge-key="(\d+)"/g), (match) => match[1]));
|
||||
|
||||
assert.equal(keys.size, relationships.length, `${mode} keeps one stable key per source relationship`);
|
||||
relationships.forEach((relationship, index) => {
|
||||
const expectedKey = source.schema_version === 2 ? '\\d+' : String(index);
|
||||
assert.match(diagram, new RegExp(`data-edge-from="${escapeAttr(relationship.from)}"[^>]+data-edge-to="${escapeAttr(relationship.to)}"[^>]+data-edge-key="${expectedKey}"`), `${mode} relationship ${index}`);
|
||||
if (relationship.label) {
|
||||
assert.match(diagram, new RegExp(`data-edge-label="${escapeAttr(relationship.label).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`), `${mode} named relationship ${index}`);
|
||||
}
|
||||
});
|
||||
assert.match(diagram, /data-node-id="[^"]+" data-node-label="[^"]+" tabindex="0"/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('relationship lens groups incoming, outgoing, and self-loop paths and follows neighbors', () => {
|
||||
const html = render('architecture', CASES.architecture.example);
|
||||
assert.match(html, /id="focus-chip" hidden role="region" aria-labelledby="relationship-lens-title"/);
|
||||
assert.match(html, /id="relationship-lens-list" aria-label="Connected relationships"/);
|
||||
assert.match(html, /function relationshipsFor\(id, byId\)/);
|
||||
assert.match(html, /direction = from === id && to === id \? 'loop' : \(from === id \? 'out' : 'in'\)/);
|
||||
assert.match(html, /\{ id: 'out', label: viewerText\('viewer\.passport\.relationship\.group\.out'\) \}/);
|
||||
assert.match(html, /\{ id: 'in', label: viewerText\('viewer\.passport\.relationship\.group\.in'\) \}/);
|
||||
assert.match(html, /data-relationship-target/);
|
||||
assert.match(html, /data-relationship-key/);
|
||||
assert.match(html, /data-relationship-from/);
|
||||
assert.match(html, /data-relationship-to/);
|
||||
assert.match(html, /set\(id, \{ toggle: false \}\)/);
|
||||
assert.match(html, /Archify\.view\.reveal\(\[id\], \{ includeNeighbors: true, reason: 'relationship' \}\)/);
|
||||
assert.doesNotMatch(svg(html), /relationship-lens|Connected relationships/);
|
||||
});
|
||||
|
||||
test('relationship preview precisely links pointer and keyboard rows to an edge and its endpoints', () => {
|
||||
const html = render('sequence', CASES.sequence.example);
|
||||
const diagram = svg(html);
|
||||
assert.match(html, /function previewRelationship\(button, options\)/);
|
||||
assert.match(html, /edge\.getAttribute\('data-edge-key'\) === key/);
|
||||
assert.match(html, /data-relationship-preview-source/);
|
||||
assert.match(html, /data-relationship-preview-target/);
|
||||
assert.match(html, /addEventListener\('pointerover'/);
|
||||
assert.match(html, /addEventListener\('pointerout'/);
|
||||
assert.match(html, /addEventListener\('focusin'/);
|
||||
assert.match(html, /addEventListener\('focusout'/);
|
||||
assert.match(html, /pinnedRelationship \|\| focusedRelationship \|\| hoveredRelationship/);
|
||||
assert.doesNotMatch(diagram, /data-relationship-preview(?:-active|-node|-source|-target)?=/);
|
||||
});
|
||||
|
||||
test('relationship preview is export-clean and visually geometry-neutral', () => {
|
||||
const html = render('dataflow', CASES.dataflow.example);
|
||||
assert.match(html, /clone\.removeAttribute\('data-relationship-preview-active'\)/);
|
||||
assert.match(html, /clone\.querySelectorAll\('\[data-relationship-preview\], \[data-relationship-preview-node\], \[data-relationship-preview-source\], \[data-relationship-preview-target\]'\)/);
|
||||
assert.match(html, /!clone\.hasAttribute\('data-relationship-preview-active'\)/);
|
||||
assert.match(html, /Relationship Preview is temporary exploration state layered on top of/);
|
||||
assert.doesNotMatch(html, /data-relationship-preview[^\n{]*\{[^}]*\b(?:x|y|transform)\s*:/);
|
||||
});
|
||||
|
||||
test('relationship lens is keyboard navigable, mobile-pinned, and excluded from embed and print', () => {
|
||||
const html = render('workflow', CASES.workflow.example);
|
||||
assert.match(html, /event\.key !== 'ArrowDown'/);
|
||||
assert.match(html, /event\.key !== 'ArrowUp'/);
|
||||
assert.match(html, /event\.key !== 'Home'/);
|
||||
assert.match(html, /event\.key !== 'End'/);
|
||||
assert.match(html, /buttons\[index\]\.focus\(\)/);
|
||||
assert.match(html, /data-wide-diagram="true"\] \.focus-chip/);
|
||||
assert.match(html, /\.focus-chip\[data-relationship-previewing="true"\] \.relationship-lens-list/);
|
||||
assert.match(html, /\.relationship-lens-row:not\(\[data-preview-active="true"\]\)/);
|
||||
assert.match(html, /var mobile = window\.innerWidth <= 720/);
|
||||
assert.match(html, /previewingOnMobile = mobile && chip\.getAttribute\('data-relationship-previewing'\) === 'true'/);
|
||||
assert.match(html, /nodeCenter < \(visibleTop \+ visibleBottom\) \/ 2 \? pinnedBottom : pinnedTop/);
|
||||
assert.match(html, /html\[data-embed="true"\] \.focus-chip/);
|
||||
assert.match(html, /\.toolbar, \.diagram-nav, \.focus-chip, \.guided-views/);
|
||||
assert.match(html, /chip\.hidden = options\.hideChip === true \|\| normalized\.length !== 1 \|\| selectionMode/);
|
||||
assert.match(html, /event\.target\.closest\('\.diagram-nav, \.focus-chip, \.node-finder, \.diagram-guide, \.overview-map, \.route-probe, \.semantic-lens'\)/);
|
||||
assert.match(html, /function placeRelationshipLens\(\)/);
|
||||
assert.match(html, /visibleTop = Math\.max\(padding, -containerRect\.top \+ padding\)/);
|
||||
assert.match(html, /window\.addEventListener\('scroll', requestLensPlacement, \{ passive: true \}\)/);
|
||||
assert.match(html, /container\.addEventListener\('scroll', requestLensPlacement, \{ passive: true \}\)/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,130 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-relationship-permalink-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: { example: 'web-app.architecture.json', collection: 'connections' },
|
||||
workflow: { example: 'agent-tool-call.workflow.json', collection: 'edges' },
|
||||
sequence: { example: 'cache-miss-request.sequence.json', collection: 'messages' },
|
||||
dataflow: { example: 'product-analytics.dataflow.json', collection: 'flows' },
|
||||
lifecycle: { example: 'agent-run.lifecycle.json', collection: 'transitions' },
|
||||
};
|
||||
|
||||
function fixture(mode) {
|
||||
return JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', CASES[mode].example), 'utf8'));
|
||||
}
|
||||
|
||||
function run(mode, doc, suffix) {
|
||||
const input = path.join(tmp, `${mode}-${suffix}.json`);
|
||||
const output = path.join(tmp, `${mode}-${suffix}.html`);
|
||||
fs.writeFileSync(input, JSON.stringify(doc));
|
||||
const result = spawnSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`), input, output,
|
||||
], { encoding: 'utf8' });
|
||||
return { result, html: fs.existsSync(output) ? fs.readFileSync(output, 'utf8') : '' };
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
function relationshipKey(html, id) {
|
||||
return html.match(new RegExp(`data-edge-key="(\\d+)" data-edge-id="${id}"`))?.[1] ?? null;
|
||||
}
|
||||
|
||||
test('all typed renderers preserve optional authored relationship ids beside runtime keys', () => {
|
||||
for (const mode of Object.keys(CASES)) {
|
||||
const doc = fixture(mode);
|
||||
doc[CASES[mode].collection][0].id = 'shareable-relation';
|
||||
const { result, html } = run(mode, doc, 'stable-id');
|
||||
assert.equal(result.status, 0, `${mode}: ${result.stderr}`);
|
||||
assert.match(html, /data-edge-key="\d+" data-edge-id="shareable-relation"/, mode);
|
||||
assert.match(canonicalSvg(html), /data-edge-id="shareable-relation"/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('authored relationship identity and readable-v2 compiler keys survive source-order changes', () => {
|
||||
const original = fixture('workflow');
|
||||
const reordered = fixture('workflow');
|
||||
const moved = reordered.edges.shift();
|
||||
reordered.edges.splice(1, 0, moved);
|
||||
|
||||
const first = run('workflow', original, 'original-order');
|
||||
const second = run('workflow', reordered, 'reordered');
|
||||
assert.equal(first.result.status, 0, first.result.stderr);
|
||||
assert.equal(second.result.status, 0, second.result.stderr);
|
||||
assert.notEqual(relationshipKey(first.html, 'request-chat'), null);
|
||||
assert.equal(
|
||||
relationshipKey(second.html, 'request-chat'),
|
||||
relationshipKey(first.html, 'request-chat'),
|
||||
);
|
||||
assert.match(first.html, /'#relation=' \+ encodeURIComponent\(record\.id\)/);
|
||||
assert.match(second.html, /'#relation=' \+ encodeURIComponent\(record\.id\)/);
|
||||
});
|
||||
|
||||
test('relationship ids stay optional and duplicate ids fail closed in the shared zero-install path', () => {
|
||||
for (const mode of Object.keys(CASES)) {
|
||||
const idless = fixture(mode);
|
||||
delete idless[CASES[mode].collection][0].id;
|
||||
const plain = run(mode, idless, 'idless');
|
||||
assert.equal(plain.result.status, 0, `${mode}: ${plain.result.stderr}`);
|
||||
const keyZeroTags = Array.from(plain.html.matchAll(/<(?:path|g)\b[^>]*data-edge-key="0"[^>]*>/g), (match) => match[0]);
|
||||
assert.ok(keyZeroTags.length > 0, `${mode} emits runtime key zero`);
|
||||
assert.ok(keyZeroTags.every((tag) => !tag.includes('data-edge-id=')), `${mode} does not invent a durable id`);
|
||||
|
||||
const duplicate = fixture(mode);
|
||||
duplicate[CASES[mode].collection][1].id = duplicate[CASES[mode].collection][0].id;
|
||||
const rejected = run(mode, duplicate, 'duplicate');
|
||||
assert.notEqual(rejected.result.status, 0, mode);
|
||||
assert.match(rejected.result.stderr, /Relationship identity validation failed/);
|
||||
assert.match(rejected.result.stderr, /duplicates relationship id/);
|
||||
}
|
||||
});
|
||||
|
||||
test('relationship id syntax is schema-checked before viewer output is written', () => {
|
||||
const doc = fixture('workflow');
|
||||
doc.edges[0].id = 'not a stable id';
|
||||
const { result, html } = run('workflow', doc, 'invalid-id');
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /\/edges\/0\/id/);
|
||||
assert.match(result.stderr, /must match pattern/);
|
||||
assert.equal(html, '');
|
||||
});
|
||||
|
||||
test('the viewer restores and copies stable relation links without exposing numeric keys', () => {
|
||||
const { result, html } = run('workflow', fixture('workflow'), 'viewer');
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(html, /var edgeId = edge\.getAttribute\('data-edge-id'\) \|\| ''/);
|
||||
assert.match(html, /target\.setAttribute\('data-relationship-id', record\.id\)/);
|
||||
assert.match(html, /button\.setAttribute\('data-relationship-id', relationship\.id\)/);
|
||||
assert.match(html, /copyBtn\.textContent = viewerText\('viewer\.passport\.copyRelation'\)/);
|
||||
assert.match(html, /'#relation=' \+ encodeURIComponent\(record\.id\)/);
|
||||
assert.match(html, /var relation = params\.get\('relation'\)/);
|
||||
assert.match(html, /inspectRelationshipById\(relation, \{ updateUrl: false, toggle: false \}\)/);
|
||||
assert.match(html, /if \(html\.getAttribute\('data-embed'\) === 'true'\) return false/);
|
||||
assert.match(html, /if \(html\.getAttribute\('data-embed'\) === 'true' \|\|\s*!inspectRelationshipById/);
|
||||
assert.match(html, /params\.get\('focus'\) \|\| params\.get\('relation'\)/);
|
||||
assert.match(html, /if \(!reveal\(\)\) requestAnimationFrame\(reveal\)/);
|
||||
assert.match(html, /inspectRelationshipById: inspectRelationshipById/);
|
||||
assert.match(html, /id: record\.id \|\| null, key: record\.key/);
|
||||
assert.doesNotMatch(html, /'#relation=' \+ encodeURIComponent\(record\.key\)/);
|
||||
});
|
||||
|
||||
test('runtime overlays drop durable edge ids while canonical SVG keeps authored identity', () => {
|
||||
const { result, html } = run('architecture', fixture('architecture'), 'export-boundary');
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(canonicalSvg(html), /data-edge-id="users-to-cdn"/);
|
||||
assert.doesNotMatch(canonicalSvg(html), /data-relationship-hit-overlay|data-relationship-id=/);
|
||||
assert.ok((html.match(/clone\.removeAttribute\('data-edge-id'\)/g) || []).length >= 6);
|
||||
assert.match(html, /querySelectorAll\('\[data-relationship-hit-overlay\]'\)/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,122 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-relationship-pulse-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all typed renderers inherit one exact-edge Directional Flow Pulse', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /function renderRelationshipPulse\(key\)/, mode);
|
||||
assert.match(html, /function relationshipTokenKind\(edge\)/, mode);
|
||||
assert.match(html, /function relationshipTokenGeometry\(shape, kind, key, options\)/, mode);
|
||||
assert.match(html, /data-relationship-pulse-overlay/, mode);
|
||||
assert.match(html, /setAttribute\('class', 'relationship-flow-pulse'\)/, mode);
|
||||
assert.doesNotMatch(canonicalSvg(html), /relationship-flow-(?:pulse|token)|data-relationship-(?:pulse|token)/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('pulse clones only the previewed authored geometry and keeps source-to-target direction', () => {
|
||||
const html = render('sequence', CASES.sequence);
|
||||
assert.match(html, /edge\.getAttribute\('data-edge-key'\) === key/);
|
||||
assert.match(html, /function relationshipEdgeShapes\(edge\)/);
|
||||
assert.match(html, /shape\.cloneNode\(false\)/);
|
||||
assert.match(html, /clone\.removeAttribute\('marker-end'\)/);
|
||||
assert.match(html, /clone\.removeAttribute\('data-edge-key'\)/);
|
||||
assert.match(html, /clone\.removeAttribute\('filter'\)/);
|
||||
assert.match(html, /clone\.setAttribute\('pathLength', '1'\)/);
|
||||
assert.match(html, /overlay\.setAttribute\('data-relationship-pulse-key', key\)/);
|
||||
assert.match(html, /function relationshipTokenPath\(shape\)/);
|
||||
assert.match(html, /tagName === 'path'.+shape\.getAttribute\('d'\)/s);
|
||||
assert.match(html, /tagName === 'line'/);
|
||||
assert.match(html, /tagName === 'polyline' && shape\.points/);
|
||||
assert.match(html, /motion\.setAttribute\('path', pathData\)/);
|
||||
assert.match(html, /motion\.setAttribute\('rotate', 'auto'\)/);
|
||||
assert.match(html, /svg\.insertBefore\(overlay, firstNode\)/);
|
||||
assert.match(html, /stroke-dashoffset: -1/);
|
||||
});
|
||||
|
||||
test('semantic token classification is evidence-based and fail-closed', () => {
|
||||
const html = render('lifecycle', CASES.lifecycle);
|
||||
assert.match(html, /a-security.+sourceKind === 'security'.+targetKind === 'failure'.+return 'security'/s);
|
||||
assert.match(html, /a-dashed.+sourceKind === 'messagebus'.+targetKind === 'messagebus'.+return 'event'/s);
|
||||
assert.match(html, /sourceKind === 'database' \|\| targetKind === 'database'.+return 'data'/s);
|
||||
assert.match(html, /targetKind === 'waiting' \|\| targetKind === 'success'.+return 'state'/s);
|
||||
assert.match(html, /return 'call';/);
|
||||
assert.doesNotMatch(html, /relationshipTokenKind[\s\S]{0,1800}data-edge-label/);
|
||||
});
|
||||
|
||||
test('semantic tokens use five distinct inline SVG cues on one finite timing owner', () => {
|
||||
const html = render('workflow', CASES.workflow);
|
||||
assert.match(html, /data-token-kind', kind/);
|
||||
assert.match(html, /kind === 'data'[\s\S]+kind === 'event'[\s\S]+kind === 'security'[\s\S]+kind === 'state'/);
|
||||
assert.match(html, /document\.createElementNS\(svgNamespace, 'animateMotion'\)/);
|
||||
assert.match(html, /motion\.setAttribute\('dur', options\.duration \|\| '1\.2s'\)/);
|
||||
assert.match(html, /animation: archify-relationship-token-life 1\.2s linear 1 both/);
|
||||
assert.match(html, /semantic-flow-token-halo/);
|
||||
assert.match(html, /Archify\.flowTokens = \{/);
|
||||
assert.match(html, /data-relationship-token-kind', tokenKind/);
|
||||
assert.match(html, /var tokenAdded = false/);
|
||||
assert.doesNotMatch(html, /relationship-flow-token[^}]+infinite/);
|
||||
});
|
||||
|
||||
test('pulse is finite, event-owned, preset-aware, touch-safe, and motion-safe', () => {
|
||||
const html = render('architecture', CASES.architecture);
|
||||
assert.match(html, /animation: archify-relationship-pulse 1\.2s linear 1 both/);
|
||||
assert.match(html, /@keyframes archify-relationship-token-life/);
|
||||
assert.doesNotMatch(html, /relationship-flow-pulse[^}]+infinite/);
|
||||
assert.match(html, /var activeRelationshipPreview = null/);
|
||||
assert.match(html, /if \(next === activeRelationshipPreview\) return/);
|
||||
assert.match(html, /event\.pointerType === 'touch'/);
|
||||
assert.match(html, /finePointerQuery && !finePointerQuery\.matches/);
|
||||
assert.match(html, /Archify\.motionGovernor && Archify\.motionGovernor\.isPaused\(\)/);
|
||||
assert.match(html, /document\.hidden/);
|
||||
assert.match(html, /addEventListener\('animationcancel', finishPulse/);
|
||||
assert.match(html, /reducedMotionQuery\.addEventListener\('change', syncRelationshipMotionPreference\)/);
|
||||
assert.match(html, /if \(event\.matches\) removeRelationshipPulse\(\)/);
|
||||
assert.match(html, /document\.addEventListener\('visibilitychange'/);
|
||||
assert.match(html, /html\[data-embed="true"\] \.relationship-pulse-overlay/);
|
||||
assert.match(html, /svg\[data-preset="signal-flow"\] \.relationship-flow-pulse/);
|
||||
assert.match(html, /svg\[data-preset="blueprint"\] \.relationship-flow-pulse/);
|
||||
assert.match(html, /@media \(prefers-reduced-motion: reduce\)[\s\S]+\.relationship-pulse-overlay \{ display: none !important; \}/);
|
||||
});
|
||||
|
||||
test('pulse has one owner and never enters print or canonical exports', () => {
|
||||
const html = render('dataflow', CASES.dataflow);
|
||||
assert.match(html, /function removeRelationshipPulse\(\)/);
|
||||
assert.match(html, /svg\.querySelectorAll\('\[data-relationship-pulse-overlay\]'\)/);
|
||||
assert.match(html, /@media print \{[\s\S]+\.relationship-pulse-overlay \{ display: none !important; \}/);
|
||||
assert.match(html, /clone\.querySelectorAll\('\[data-relationship-pulse-overlay\]'\)/);
|
||||
assert.match(html, /\[data-relationship-pulse-overlay\],[^']*\[data-relationship-preview\]/);
|
||||
assert.doesNotMatch(canonicalSvg(html), /relationship-flow-(?:pulse|token)|data-relationship-(?:pulse|token)/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,504 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(here, '..', '..');
|
||||
const checker = path.join(repoRoot, 'scripts', 'check-release-identity.mjs');
|
||||
|
||||
function writeFile(root, relativePath, content) {
|
||||
const target = path.join(root, relativePath);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, content);
|
||||
}
|
||||
|
||||
function runCheck(root) {
|
||||
return spawnSync(process.execPath, [checker, '--root', root], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
function stableUpdateManifest(version) {
|
||||
return JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
skillId: 'archify',
|
||||
channel: 'stable',
|
||||
version,
|
||||
publishedAt: '2026-07-29T00:00:00Z',
|
||||
source: {
|
||||
repository: 'https://github.com/tt-a1i/archify',
|
||||
ref: `v${version}`,
|
||||
treeSha: 'a'.repeat(40),
|
||||
},
|
||||
artifact: { sha256: 'b'.repeat(64) },
|
||||
summary: 'Published stable release.',
|
||||
releaseNotes: `https://github.com/tt-a1i/archify/releases/tag/v${version}`,
|
||||
severity: 'normal',
|
||||
});
|
||||
}
|
||||
|
||||
function writeValidDevelopmentFixture(root, overrides = {}) {
|
||||
const version = '2.13.0-dev.0';
|
||||
const english = [
|
||||
'',
|
||||
'',
|
||||
`Current development version: \`v${version}\``,
|
||||
'',
|
||||
'Raven uses manual ZIP installation: extract archify.zip into `~/.raven/workspace/skills`, which yields `~/.raven/workspace/skills/archify`; Raven is not an agent-switcher target.',
|
||||
].join('\n');
|
||||
const chinese = [
|
||||
'',
|
||||
'',
|
||||
`当前开发版本:\`v${version}\``,
|
||||
'',
|
||||
'Raven 使用 ZIP 手动安装:将 archify.zip 解压到 `~/.raven/workspace/skills`,解压后会得到 `~/.raven/workspace/skills/archify`;Raven 不属于 Agent 切换器目标。',
|
||||
].join('\n');
|
||||
const files = {
|
||||
'archify/package.json': JSON.stringify({ version }),
|
||||
'archify/package-lock.json': JSON.stringify({ version, packages: { '': { version } } }),
|
||||
'archify/skill-release.json': JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
skillId: 'archify',
|
||||
channel: 'development',
|
||||
version,
|
||||
source: { repository: 'https://github.com/tt-a1i/archify' },
|
||||
updateManifestUrl: 'https://tt-a1i.github.io/archify/skill-updates/archify/stable.json',
|
||||
}),
|
||||
'docs/skill-updates/archify/stable.json': stableUpdateManifest('2.12.0'),
|
||||
'archify/SKILL.md': '---\nmetadata:\n version: "2.13"\n---\n',
|
||||
'archify/assets/template.html': '<meta name="generator" content="archify 2.13.0-dev.0">',
|
||||
'CHANGELOG.md': [
|
||||
'# Changelog',
|
||||
'',
|
||||
'## [Unreleased]',
|
||||
'',
|
||||
`> Development identity: \`v${version}\`. Not a stable release.`,
|
||||
'',
|
||||
'### Added',
|
||||
'- Real unreleased work.',
|
||||
'',
|
||||
'## [2.12.0] — 2026-07-23',
|
||||
'',
|
||||
].join('\n'),
|
||||
'README.md': english,
|
||||
'README_EN.md': english,
|
||||
'README_ZH.md': chinese,
|
||||
'scripts/start-template.html': 'development · 开发版 · [[ARCHIFY_VERSION]]',
|
||||
'scripts/guide-template.html': 'development · 开发版 · [[ARCHIFY_VERSION]]',
|
||||
'scripts/gallery-template.html': 'development · 开发版 · [[ARCHIFY_VERSION]]',
|
||||
'docs/index.html': `<span>development · v${version} · 开发版 · 9/9 checks</span><p>Raven manual ZIP / ZIP 手动安装: extract archify.zip into ~/.raven/workspace/skills, which yields ~/.raven/workspace/skills/archify; 将 archify.zip 解压到 ~/.raven/workspace/skills,解压后会得到 ~/.raven/workspace/skills/archify; not an agent-switcher target.</p>`,
|
||||
'docs/start.html': `<span>development · v${version} · 开发版</span><p>Raven manual ZIP / ZIP 手动安装: extract archify.zip into ~/.raven/workspace/skills, which yields ~/.raven/workspace/skills/archify; 将 archify.zip 解压到 ~/.raven/workspace/skills,解压后会得到 ~/.raven/workspace/skills/archify; not an agent-switcher target.</p>`,
|
||||
'ROADMAP.md': `The current development line is \`v${version}\`; it contains the work under Changelog Unreleased and is not a stable release.`,
|
||||
};
|
||||
for (const [relativePath, content] of Object.entries({ ...files, ...overrides })) {
|
||||
writeFile(root, relativePath, content);
|
||||
}
|
||||
}
|
||||
|
||||
function writeValidStableFixture(root, overrides = {}) {
|
||||
const version = '2.13.0';
|
||||
const english = [
|
||||
'',
|
||||
'',
|
||||
`Current stable version: \`v${version}\``,
|
||||
'',
|
||||
'Raven uses manual ZIP installation: extract archify.zip into `~/.raven/workspace/skills`, which yields `~/.raven/workspace/skills/archify`; Raven is not an agent-switcher target.',
|
||||
].join('\n');
|
||||
const chinese = [
|
||||
'',
|
||||
'',
|
||||
`当前稳定版本:\`v${version}\``,
|
||||
'',
|
||||
'Raven 使用 ZIP 手动安装:将 archify.zip 解压到 `~/.raven/workspace/skills`,解压后会得到 `~/.raven/workspace/skills/archify`;Raven 不属于 Agent 切换器目标。',
|
||||
].join('\n');
|
||||
const files = {
|
||||
'archify/package.json': JSON.stringify({ version }),
|
||||
'archify/package-lock.json': JSON.stringify({ version, packages: { '': { version } } }),
|
||||
'archify/skill-release.json': JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
skillId: 'archify',
|
||||
channel: 'stable',
|
||||
version,
|
||||
source: { repository: 'https://github.com/tt-a1i/archify' },
|
||||
updateManifestUrl: 'https://tt-a1i.github.io/archify/skill-updates/archify/stable.json',
|
||||
}),
|
||||
'docs/skill-updates/archify/stable.json': stableUpdateManifest(version),
|
||||
'archify/SKILL.md': '---\nmetadata:\n version: "2.13"\n---\n',
|
||||
'archify/assets/template.html': '<meta name="generator" content="archify 2.13.0">',
|
||||
'CHANGELOG.md': [
|
||||
'# Changelog',
|
||||
'',
|
||||
'## [Unreleased]',
|
||||
'',
|
||||
'## [2.13.0] — 2026-07-29',
|
||||
'- Published work.',
|
||||
'',
|
||||
].join('\n'),
|
||||
'README.md': english,
|
||||
'README_EN.md': english,
|
||||
'README_ZH.md': chinese,
|
||||
'scripts/start-template.html': 'stable · 稳定版 · [[ARCHIFY_VERSION]]',
|
||||
'scripts/guide-template.html': 'stable · 稳定版 · [[ARCHIFY_VERSION]]',
|
||||
'scripts/gallery-template.html': 'stable · 稳定版 · [[ARCHIFY_VERSION]]',
|
||||
'docs/index.html': `<span>stable · v${version} · 稳定版 · 9/9 checks</span><p>Raven manual ZIP / ZIP 手动安装: extract archify.zip into ~/.raven/workspace/skills, which yields ~/.raven/workspace/skills/archify; 将 archify.zip 解压到 ~/.raven/workspace/skills,解压后会得到 ~/.raven/workspace/skills/archify; not an agent-switcher target.</p>`,
|
||||
'docs/start.html': `<span>stable · v${version} · 稳定版</span><p>Raven manual ZIP / ZIP 手动安装: extract archify.zip into ~/.raven/workspace/skills, which yields ~/.raven/workspace/skills/archify; 将 archify.zip 解压到 ~/.raven/workspace/skills,解压后会得到 ~/.raven/workspace/skills/archify; not an agent-switcher target.</p>`,
|
||||
'ROADMAP.md': `The current stable version is \`v${version}\`.`,
|
||||
};
|
||||
for (const [relativePath, content] of Object.entries({ ...files, ...overrides })) {
|
||||
writeFile(root, relativePath, content);
|
||||
}
|
||||
}
|
||||
|
||||
test('an empty Unreleased section accepts a coherent stable release identity', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-release-identity-'));
|
||||
try {
|
||||
writeValidStableFixture(fixture);
|
||||
|
||||
const result = runCheck(fixture);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /release identity ok: 2\.13\.0/);
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('stable release preparation allows only the immediate prior public manifest', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-release-identity-'));
|
||||
try {
|
||||
const changelog = [
|
||||
'# Changelog',
|
||||
'',
|
||||
'## [Unreleased]',
|
||||
'',
|
||||
'## [2.13.0] — 2026-07-29',
|
||||
'- Release being prepared.',
|
||||
'',
|
||||
'## [2.12.0] — 2026-07-23',
|
||||
'- Previously published release.',
|
||||
'',
|
||||
].join('\n');
|
||||
writeValidStableFixture(fixture, {
|
||||
'CHANGELOG.md': changelog,
|
||||
'docs/skill-updates/archify/stable.json': stableUpdateManifest('2.12.0'),
|
||||
});
|
||||
|
||||
const prior = runCheck(fixture);
|
||||
assert.equal(prior.status, 0, prior.stderr);
|
||||
|
||||
writeFile(fixture, 'docs/skill-updates/archify/stable.json', stableUpdateManifest('2.11.0'));
|
||||
const stale = runCheck(fixture);
|
||||
assert.notEqual(stale.status, 0);
|
||||
assert.match(stale.stderr, /immediate prior v2\.12\.0/);
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('the embedded update identity must match the package release exactly', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-release-identity-'));
|
||||
try {
|
||||
writeValidDevelopmentFixture(fixture, {
|
||||
'archify/skill-release.json': JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
skillId: 'archify',
|
||||
channel: 'stable',
|
||||
version: '2.12.0',
|
||||
source: { repository: 'https://example.com/untrusted/archify' },
|
||||
updateManifestUrl: 'https://example.com/latest.json',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = runCheck(fixture);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /archify\/skill-release\.json must identify archify 2\.13\.0-dev\.0 as development/);
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('the published update manifest must track the newest stable changelog release', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-release-identity-'));
|
||||
try {
|
||||
writeValidDevelopmentFixture(fixture, {
|
||||
'docs/skill-updates/archify/stable.json': stableUpdateManifest('2.11.0'),
|
||||
});
|
||||
|
||||
const result = runCheck(fixture);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /stable\.json must describe the newest published stable v2\.12\.0/);
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('the published update manifest must use a canonical UTC timestamp', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-release-identity-'));
|
||||
try {
|
||||
const manifest = JSON.parse(stableUpdateManifest('2.12.0'));
|
||||
manifest.publishedAt = '2026-07-29T08:00:00+08:00';
|
||||
writeValidDevelopmentFixture(fixture, {
|
||||
'docs/skill-updates/archify/stable.json': JSON.stringify(manifest),
|
||||
});
|
||||
|
||||
const result = runCheck(fixture);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /stable\.json must describe the newest published stable v2\.12\.0/);
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('package identities reject leading-zero core and prerelease identifiers', () => {
|
||||
for (const version of ['02.13.0', '2.13.0-dev.01']) {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-release-identity-'));
|
||||
try {
|
||||
writeValidDevelopmentFixture(fixture, {
|
||||
'archify/package.json': JSON.stringify({ version }),
|
||||
});
|
||||
const result = runCheck(fixture);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /not a supported SemVer identity/, version);
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the newest stable release is selected by SemVer rather than changelog order', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-release-identity-'));
|
||||
try {
|
||||
writeValidDevelopmentFixture(fixture, {
|
||||
'CHANGELOG.md': [
|
||||
'# Changelog',
|
||||
'',
|
||||
'## [Unreleased]',
|
||||
'',
|
||||
'> Development identity: `v2.13.0-dev.0`. Not a stable release.',
|
||||
'',
|
||||
'### Added',
|
||||
'- Real unreleased work.',
|
||||
'',
|
||||
'## [2.11.0] — 2026-07-16',
|
||||
'',
|
||||
'## [2.12.0] — 2026-07-23',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const result = runCheck(fixture);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('real Unreleased changes cannot reuse a stable published package identity', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-release-identity-'));
|
||||
try {
|
||||
writeFile(fixture, 'archify/package.json', JSON.stringify({ version: '2.12.0' }));
|
||||
writeFile(fixture, 'CHANGELOG.md', [
|
||||
'# Changelog',
|
||||
'',
|
||||
'## [Unreleased]',
|
||||
'',
|
||||
'### Added',
|
||||
'- Real unreleased work.',
|
||||
'',
|
||||
'## [2.12.0] — 2026-07-23',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const result = runCheck(fixture);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /Unreleased changes require a prerelease package version/);
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('package, lockfile, Skill metadata, escaped Shields badge, and public docs share one development identity', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-release-identity-'));
|
||||
try {
|
||||
writeFile(fixture, 'archify/package.json', JSON.stringify({ version: '2.13.0-dev.0' }));
|
||||
writeFile(fixture, 'archify/package-lock.json', JSON.stringify({
|
||||
version: '2.12.0',
|
||||
packages: { '': { version: '2.12.0' } },
|
||||
}));
|
||||
writeFile(fixture, 'archify/SKILL.md', '---\nmetadata:\n version: "2.12"\n---\n');
|
||||
writeFile(fixture, 'CHANGELOG.md', [
|
||||
'# Changelog',
|
||||
'',
|
||||
'## [Unreleased]',
|
||||
'',
|
||||
'### Added',
|
||||
'- Real unreleased work.',
|
||||
'',
|
||||
'## [2.12.0] — 2026-07-23',
|
||||
'',
|
||||
].join('\n'));
|
||||
const staleEnglish = [
|
||||
'',
|
||||
'',
|
||||
'Archify 2.12 includes unreleased capabilities.',
|
||||
].join('\n');
|
||||
writeFile(fixture, 'README.md', staleEnglish);
|
||||
writeFile(fixture, 'README_EN.md', staleEnglish);
|
||||
writeFile(fixture, 'README_ZH.md', '\n\nArchify 2.12 包含未发布能力。\n');
|
||||
writeFile(fixture, 'docs/index.html', '<span>Agent Skill · v2.12.0</span>');
|
||||
writeFile(fixture, 'docs/start.html', '<span>Archify v2.12.0</span>');
|
||||
|
||||
const result = runCheck(fixture);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /package-lock\.json must match 2\.13\.0-dev\.0/);
|
||||
assert.match(result.stderr, /SKILL\.md metadata version 2\.12 must map to package 2\.13\.0-dev\.0/);
|
||||
assert.match(result.stderr, /README\.md must advertise development identity v2\.13\.0-dev\.0/);
|
||||
assert.match(result.stderr, /docs\/index\.html must advertise development identity v2\.13\.0-dev\.0/);
|
||||
assert.match(result.stderr, /docs\/start\.html must advertise development identity v2\.13\.0-dev\.0/);
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('landing proof receipt matches the current nine-check artifact contract', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-release-identity-'));
|
||||
try {
|
||||
writeValidDevelopmentFixture(fixture, {
|
||||
'docs/index.html': '<span>development · v2.13.0-dev.0 · 开发版 · 8/8 checks</span><p>Raven manual ZIP / ZIP 手动安装: extract archify.zip into ~/.raven/workspace/skills, which yields ~/.raven/workspace/skills/archify; 将 archify.zip 解压到 ~/.raven/workspace/skills,解压后会得到 ~/.raven/workspace/skills/archify; not an agent-switcher target.</p>',
|
||||
});
|
||||
|
||||
const result = runCheck(fixture);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /docs\/index\.html proof receipt must say 9\/9/);
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('landing rejects every stale N/N contract count even when 9/9 is also present', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-release-identity-'));
|
||||
try {
|
||||
writeValidDevelopmentFixture(fixture, {
|
||||
'docs/index.html': [
|
||||
'<span>development · v2.13.0-dev.0 · 开发版 · 9/9 checks</span>',
|
||||
'<span>legacy receipt · 7/7 checks</span>',
|
||||
'<p>Raven manual ZIP / ZIP 手动安装: extract archify.zip into ~/.raven/workspace/skills, which yields ~/.raven/workspace/skills/archify; 将 archify.zip 解压到 ~/.raven/workspace/skills,解压后会得到 ~/.raven/workspace/skills/archify; not an agent-switcher target.</p>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const result = runCheck(fixture);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /every N\/N proof receipt must be exactly 9\/9; found 7\/7/);
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Raven stays a truthful manual ZIP install and never becomes a generated agent-switcher command', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-release-identity-'));
|
||||
try {
|
||||
writeValidDevelopmentFixture(fixture, {
|
||||
'docs/start.html': [
|
||||
'<span>development · v2.13.0-dev.0 · 开发版</span>',
|
||||
'<button data-agent="raven">Raven</button>',
|
||||
'<pre>npx skills add tt-a1i/archify --agent raven</pre>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const result = runCheck(fixture);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /Raven must remain a manual ZIP installation outside the agent switcher/);
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Raven instructions reject extracting the archive into the final Skill directory', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-release-identity-'));
|
||||
try {
|
||||
const nestedEnglish = [
|
||||
'',
|
||||
'',
|
||||
'Current development version: `v2.13.0-dev.0`',
|
||||
'',
|
||||
'Raven is manual ZIP only: extract archify.zip into `~/.raven/workspace/skills/archify`; Raven is not an agent-switcher target.',
|
||||
].join('\n');
|
||||
writeValidDevelopmentFixture(fixture, {
|
||||
'README.md': nestedEnglish,
|
||||
'README_EN.md': nestedEnglish,
|
||||
});
|
||||
|
||||
const result = runCheck(fixture);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /extract archify\.zip into ~\/\.raven\/workspace\/skills, yielding ~\/\.raven\/workspace\/skills\/archify/);
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('renderer template generator carries the complete package prerelease identity', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-release-identity-'));
|
||||
try {
|
||||
writeValidDevelopmentFixture(fixture, {
|
||||
'archify/assets/template.html': '<meta name="generator" content="archify 2.12.0">',
|
||||
});
|
||||
|
||||
const result = runCheck(fixture);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /archify\/assets\/template\.html generator must be archify 2\.13\.0-dev\.0/);
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('roadmap current identity follows the package release state', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-release-identity-'));
|
||||
try {
|
||||
writeValidDevelopmentFixture(fixture, {
|
||||
'ROADMAP.md': 'The current development line is `v2.12.0`; it is not a stable release.',
|
||||
});
|
||||
|
||||
const result = runCheck(fixture);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /ROADMAP\.md must declare the current development line as v2\.13\.0-dev\.0/);
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('generated public-page templates keep a development marker and version placeholder', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-release-identity-'));
|
||||
try {
|
||||
writeValidDevelopmentFixture(fixture, {
|
||||
'scripts/gallery-template.html': 'Proof Lab / 2.12.0',
|
||||
});
|
||||
|
||||
const result = runCheck(fixture);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /scripts\/gallery-template\.html must use \[\[ARCHIFY_VERSION\]\] with development and 开发版 labels/);
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('stable public-page templates reject development labels on version-bearing fallbacks', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-release-identity-'));
|
||||
try {
|
||||
writeValidStableFixture(fixture, {
|
||||
'scripts/guide-template.html': [
|
||||
'<span data-i18n="versionLabel">Scenario guide / development / v[[ARCHIFY_VERSION]]</span>',
|
||||
"versionLabel:'Scenario guide / stable / v[[ARCHIFY_VERSION]]'",
|
||||
"versionLabel:'场景指南 / 稳定版 / v[[ARCHIFY_VERSION]]'",
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const result = runCheck(fixture);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /scripts\/guide-template\.html must not label \[\[ARCHIFY_VERSION\]\] as development or 开发版/);
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,499 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { stageCleanSkill } from '../../scripts/stage-clean-skill.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||
const canonicalZipNodeMajor = 22;
|
||||
const currentNodeMajor = Number(process.versions.node.split('.')[0]);
|
||||
const canonicalZipTest = (name, fn) => test(name, {
|
||||
skip: currentNodeMajor === canonicalZipNodeMajor
|
||||
? false
|
||||
: `canonical ZIP builds require Node ${canonicalZipNodeMajor}`,
|
||||
}, fn);
|
||||
|
||||
function workflowStep(workflow, name) {
|
||||
const marker = ` - name: ${name}`;
|
||||
const start = workflow.indexOf(marker);
|
||||
assert.notEqual(start, -1, `workflow is missing the "${name}" step`);
|
||||
const next = workflow.indexOf('\n - ', start + marker.length);
|
||||
return workflow.slice(start, next === -1 ? workflow.length : next);
|
||||
}
|
||||
|
||||
function workflowJob(workflow, name) {
|
||||
const marker = ` ${name}:`;
|
||||
const start = workflow.indexOf(marker);
|
||||
assert.notEqual(start, -1, `workflow is missing the "${name}" job`);
|
||||
const next = workflow.slice(start + marker.length).search(/\n [a-z][a-z0-9-]*:\n/);
|
||||
return workflow.slice(start, next === -1 ? workflow.length : start + marker.length + next);
|
||||
}
|
||||
|
||||
test('release prevents manifest preannouncement and smokes the exact archive before upload', () => {
|
||||
const workflow = fs.readFileSync(path.join(repoRoot, '.github', 'workflows', 'release.yml'), 'utf8');
|
||||
const tagFetch = workflowStep(workflow, 'Fetch exact tag object');
|
||||
const tagGate = workflowStep(workflow, 'Tag must match package.json version');
|
||||
const annotatedTagGate = workflowStep(workflow, 'Stable release tag must be annotated');
|
||||
const publicationOrder = workflowStep(workflow, 'Stable notifier manifest must remain on the previous release');
|
||||
const build = workflowStep(workflow, 'Build skill archive');
|
||||
const smoke = workflowStep(workflow, 'Validate the exact release archive without installing dependencies');
|
||||
const freshness = workflowStep(workflow, 'Committed zip must match the build (same gate as CI)');
|
||||
const upload = workflowStep(workflow, 'Create GitHub Release with the zip attached');
|
||||
const followUp = workflowStep(workflow, 'Record stable notifier publication follow-up');
|
||||
|
||||
assert.ok(workflow.indexOf(tagFetch) < workflow.indexOf(tagGate), 'the real tag object must be fetched before release identity checks');
|
||||
assert.ok(workflow.indexOf(tagGate) < workflow.indexOf(publicationOrder), 'tag/version gate must precede the publication-order gate');
|
||||
assert.ok(workflow.indexOf(tagGate) < workflow.indexOf(annotatedTagGate), 'tag/version gate must precede the annotated-tag gate');
|
||||
assert.ok(workflow.indexOf(annotatedTagGate) < workflow.indexOf(publicationOrder), 'annotated-tag gate must precede the publication-order gate');
|
||||
assert.ok(workflow.indexOf(publicationOrder) < workflow.indexOf(build), 'manifest preannouncement must fail before the release build');
|
||||
assert.ok(workflow.indexOf(build) < workflow.indexOf(smoke), 'release smoke must follow the archive build');
|
||||
assert.ok(workflow.indexOf(smoke) < workflow.indexOf(freshness), 'release smoke must inspect the built archive before comparison');
|
||||
assert.ok(workflow.indexOf(freshness) < workflow.indexOf(upload), 'freshness must pass before release upload');
|
||||
assert.ok(workflow.indexOf(upload) < workflow.indexOf(followUp), 'manifest follow-up must be recorded only after Release creation');
|
||||
|
||||
assert.match(tagFetch, /git fetch --force --no-tags origin/);
|
||||
assert.match(tagFetch, /refs\/tags\/\$\{GITHUB_REF_NAME\}:refs\/tags\/\$\{GITHUB_REF_NAME\}/);
|
||||
assert.match(tagGate, /require\('\.\/archify\/package\.json'\)\.version/);
|
||||
assert.match(tagGate, /GITHUB_REF_NAME#v/);
|
||||
assert.match(annotatedTagGate, /steps\.release-kind\.outputs\.prerelease == 'false'/);
|
||||
assert.match(annotatedTagGate, /git cat-file -t "refs\/tags\/\$\{GITHUB_REF_NAME\}"/);
|
||||
assert.match(annotatedTagGate, /stable releases require an annotated tag/);
|
||||
assert.match(publicationOrder, /compareSemver\(published\.version, releasing\) >= 0/);
|
||||
assert.match(publicationOrder, /publish the manifest in a follow-up commit/);
|
||||
assert.match(build, /run: scripts\/build-zip\.sh \/tmp\/archify-built\.zip/);
|
||||
assert.match(smoke, /unzip -q \/tmp\/archify-built\.zip -d "\$package_root"/);
|
||||
assert.match(smoke, /node scripts\/package-smoke\.mjs "\$package_root\/archify"/);
|
||||
assert.doesNotMatch(smoke, /\bnpm\s+(?:ci|install)\b/);
|
||||
assert.match(freshness, /cmp -s \/tmp\/archify-built\.zip archify\.zip/);
|
||||
assert.match(upload, /files: archify\.zip/);
|
||||
assert.match(followUp, /docs\/skill-updates\/archify\/stable\.json/);
|
||||
});
|
||||
|
||||
test('an exact tag fetch restores an annotated object after a SHA-only checkout', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-release-tag-fetch-'));
|
||||
const source = path.join(fixture, 'source');
|
||||
const checkout = path.join(fixture, 'checkout');
|
||||
const runGit = (cwd, args) => spawnSync('git', args, { cwd, encoding: 'utf8' });
|
||||
|
||||
try {
|
||||
fs.mkdirSync(source);
|
||||
assert.equal(runGit(source, ['init', '--quiet']).status, 0);
|
||||
assert.equal(runGit(source, ['config', 'user.name', 'Archify Test']).status, 0);
|
||||
assert.equal(runGit(source, ['config', 'user.email', 'archify@example.invalid']).status, 0);
|
||||
fs.writeFileSync(path.join(source, 'release.txt'), 'release\n');
|
||||
assert.equal(runGit(source, ['add', 'release.txt']).status, 0);
|
||||
assert.equal(runGit(source, ['commit', '--quiet', '-m', 'release fixture']).status, 0);
|
||||
assert.equal(runGit(source, ['tag', '-a', 'v1.0.0', '-m', 'Release v1.0.0']).status, 0);
|
||||
const commit = runGit(source, ['rev-parse', 'HEAD']).stdout.trim();
|
||||
|
||||
fs.mkdirSync(checkout);
|
||||
assert.equal(runGit(checkout, ['init', '--quiet']).status, 0);
|
||||
assert.equal(runGit(checkout, ['remote', 'add', 'origin', source]).status, 0);
|
||||
assert.equal(runGit(checkout, [
|
||||
'fetch', '--no-tags', '--depth=1', 'origin',
|
||||
`+${commit}:refs/tags/v1.0.0`,
|
||||
]).status, 0);
|
||||
assert.equal(runGit(checkout, ['cat-file', '-t', 'refs/tags/v1.0.0']).stdout.trim(), 'commit');
|
||||
|
||||
assert.equal(runGit(checkout, [
|
||||
'fetch', '--force', '--no-tags', 'origin',
|
||||
'+refs/tags/v1.0.0:refs/tags/v1.0.0',
|
||||
]).status, 0);
|
||||
assert.equal(runGit(checkout, ['cat-file', '-t', 'refs/tags/v1.0.0']).stdout.trim(), 'tag');
|
||||
assert.equal(runGit(checkout, ['rev-parse', 'refs/tags/v1.0.0^{}']).stdout.trim(), commit);
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('CI binds a public notifier manifest to the Release asset, tagged archive, and tag tree build', () => {
|
||||
const workflow = fs.readFileSync(path.join(repoRoot, '.github', 'workflows', 'ci.yml'), 'utf8');
|
||||
const job = workflowJob(workflow, 'published-update-manifest');
|
||||
assert.match(job, /validateStableUpdateManifest/);
|
||||
assert.match(job, /releases\/latest/);
|
||||
assert.match(job, /latest_stable_tag" != "v\$\{manifest_version\}"/);
|
||||
assert.match(job, /releases\/tags\/v\$\{manifest_version\}/);
|
||||
assert.match(job, /select\(\.draft == false and \.prerelease == false\)/);
|
||||
assert.match(job, /select\(\.name == "archify\.zip"\)/);
|
||||
assert.match(job, /releases\/assets\/\$\{release_asset_id\}/);
|
||||
assert.match(job, /Accept: application\/octet-stream/);
|
||||
assert.match(job, /refs\/tags\/v\$\{manifest_version\}:refs\/tags\/v\$\{manifest_version\}/);
|
||||
assert.match(job, /git show "v\$\{manifest_version\}:archify\.zip" > "\$tagged_archive"/);
|
||||
assert.match(job, /cmp -s "\$published_archive" "\$tagged_archive"/);
|
||||
assert.match(job, /check-stable-update-manifest\.mjs/);
|
||||
assert.match(job, /--archive "\$published_archive"/);
|
||||
assert.match(job, /--tag "v\$\{manifest_version\}"/);
|
||||
assert.match(job, /--source-ref "v\$\{manifest_version\}"/);
|
||||
assert.match(job, /git worktree add --detach "\$tag_checkout" "v\$\{manifest_version\}"/);
|
||||
assert.match(job, /"\$tag_checkout\/scripts\/build-zip\.sh" "\$rebuilt_archive"/);
|
||||
assert.match(job, /cmp -s "\$rebuilt_archive" "\$tagged_archive"/);
|
||||
assert.match(job, /manifest_version" == "2\.15\.0"/);
|
||||
assert.match(job, /missing the deterministic archive builder/);
|
||||
});
|
||||
|
||||
test('release docs disclose that mutable Release assets are verified only at deployment time', () => {
|
||||
const design = fs.readFileSync(
|
||||
path.join(repoRoot, 'docs', 'skill-embedded-optional-update-notifier-design.md'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(design, /部署时点/);
|
||||
assert.match(design, /部署后替换[^。]*不会自动触发复验/);
|
||||
assert.match(design, /immutable release/i);
|
||||
assert.doesNotMatch(design, /即使 Release 资产后来可被替换,也不能脱离/);
|
||||
});
|
||||
|
||||
test('GitHub Pages deploys docs only after every repository gate succeeds', () => {
|
||||
const workflow = fs.readFileSync(path.join(repoRoot, '.github', 'workflows', 'ci.yml'), 'utf8');
|
||||
const job = workflowJob(workflow, 'deploy-pages');
|
||||
assert.match(job, /if: github\.event_name == 'push' && github\.ref == 'refs\/heads\/main'/);
|
||||
assert.match(job, /needs: \[test, webm-artifact, zip-freshness, published-update-manifest, package-smoke\]/);
|
||||
assert.match(job, /pages: write/);
|
||||
assert.match(job, /id-token: write/);
|
||||
assert.match(job, /repos\/\$\{GITHUB_REPOSITORY\}\/git\/ref\/heads\/main/);
|
||||
assert.match(job, /current_main" == "\$GITHUB_SHA"/);
|
||||
assert.match(job, /Skipping obsolete Pages deployment/);
|
||||
assert.match(job, /if: steps\.deployment-head\.outputs\.current == 'true'/);
|
||||
assert.match(job, /actions\/configure-pages@v5/);
|
||||
assert.match(job, /actions\/upload-pages-artifact@v4/);
|
||||
assert.match(job, /path: docs/);
|
||||
assert.match(job, /actions\/deploy-pages@v4/);
|
||||
});
|
||||
|
||||
test('release tags with a SemVer prerelease are marked prerelease and never become latest', () => {
|
||||
const workflow = fs.readFileSync(path.join(repoRoot, '.github', 'workflows', 'release.yml'), 'utf8');
|
||||
const classifier = workflowStep(workflow, 'Classify stable and prerelease tags');
|
||||
const upload = workflowStep(workflow, 'Create GitHub Release with the zip attached');
|
||||
|
||||
assert.ok(workflow.indexOf(classifier) < workflow.indexOf(upload), 'release kind must be known before upload');
|
||||
assert.match(classifier, /version="\$\{GITHUB_REF_NAME#v\}"/);
|
||||
assert.match(classifier, /validateLocalRelease/);
|
||||
assert.match(classifier, /update-contract\.mjs/);
|
||||
assert.match(classifier, /release\.version !== process\.argv\[1\]/);
|
||||
assert.match(classifier, /if \[\[ "\$channel" == "development" \]\]/);
|
||||
assert.match(classifier, /echo "prerelease=true" >> "\$GITHUB_OUTPUT"/);
|
||||
assert.match(classifier, /echo "make_latest=false" >> "\$GITHUB_OUTPUT"/);
|
||||
assert.match(classifier, /echo "prerelease=false" >> "\$GITHUB_OUTPUT"/);
|
||||
assert.match(classifier, /echo "make_latest=true" >> "\$GITHUB_OUTPUT"/);
|
||||
assert.match(upload, /prerelease: \$\{\{ steps\.release-kind\.outputs\.prerelease \}\}/);
|
||||
assert.match(upload, /make_latest: \$\{\{ steps\.release-kind\.outputs\.make_latest \}\}/);
|
||||
});
|
||||
|
||||
test('package smoke rejects every dependency or repository-only artifact', () => {
|
||||
const packageSmoke = path.join(repoRoot, 'scripts', 'package-smoke.mjs');
|
||||
const forbidden = [
|
||||
{ relative: 'node_modules', kind: 'directory' },
|
||||
{ relative: 'package-lock.json', kind: 'file' },
|
||||
{ relative: path.join('scripts', 'generate-validators.mjs'), kind: 'file' },
|
||||
{ relative: 'test', kind: 'directory' },
|
||||
{ relative: '.hive', kind: 'directory' },
|
||||
{ relative: '.workbuddy', kind: 'directory' },
|
||||
];
|
||||
|
||||
for (const { relative, kind } of forbidden) {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-package-gate-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(fixture, 'bin'), { recursive: true });
|
||||
fs.writeFileSync(path.join(fixture, 'bin', 'archify.mjs'), '');
|
||||
const target = path.join(fixture, relative);
|
||||
if (kind === 'directory') fs.mkdirSync(target, { recursive: true });
|
||||
else {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, '');
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, [packageSmoke, fixture], { encoding: 'utf8' });
|
||||
assert.notEqual(result.status, 0, `${relative} must fail package smoke`);
|
||||
assert.match(
|
||||
`${result.stdout}\n${result.stderr}`,
|
||||
new RegExp(`packaged skill must not contain ${relative.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
|
||||
`${relative} must be rejected explicitly`,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('package smoke verifies the embedded notifier identity and local disable switch', () => {
|
||||
const source = fs.readFileSync(path.join(repoRoot, 'scripts', 'package-smoke.mjs'), 'utf8');
|
||||
assert.match(source, /scripts', 'check-update\.mjs/);
|
||||
assert.match(source, /scripts', 'update-contract\.mjs/);
|
||||
assert.match(source, /skill-release\.json/);
|
||||
assert.match(source, /ARCHIFY_UPDATE_CHECK_DISABLED: '1'/);
|
||||
assert.match(source, /reason !== 'disabled'/);
|
||||
});
|
||||
|
||||
test('package smoke increments an arbitrary-precision SemVer patch without Number coercion', () => {
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-package-bigint-version-'));
|
||||
const skillRoot = path.join(scratch, 'archify');
|
||||
try {
|
||||
stageCleanSkill({ repoRoot, destination: skillRoot });
|
||||
const packagePath = path.join(skillRoot, 'package.json');
|
||||
const releasePath = path.join(skillRoot, 'skill-release.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
const release = JSON.parse(fs.readFileSync(releasePath, 'utf8'));
|
||||
const version = '2.16.9007199254740993';
|
||||
packageJson.version = version;
|
||||
release.version = version;
|
||||
release.channel = 'stable';
|
||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
fs.writeFileSync(releasePath, `${JSON.stringify(release, null, 2)}\n`);
|
||||
|
||||
const smoke = spawnSync(process.execPath, [path.join(repoRoot, 'scripts/package-smoke.mjs'), skillRoot], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(smoke.status, 0, smoke.stderr || smoke.stdout);
|
||||
} finally {
|
||||
fs.rmSync(scratch, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('archive build refuses to silently omit required notifier files', () => {
|
||||
const buildSource = fs.readFileSync(path.join(repoRoot, 'scripts', 'build-zip.sh'), 'utf8');
|
||||
const stageSource = fs.readFileSync(path.join(repoRoot, 'scripts', 'stage-clean-skill.mjs'), 'utf8');
|
||||
assert.match(buildSource, /stage-clean-skill\.mjs/);
|
||||
assert.match(stageSource, /archify\/skill-release\.json/);
|
||||
assert.match(stageSource, /archify\/scripts\/check-update\.mjs/);
|
||||
assert.match(stageSource, /archify\/scripts\/update-contract\.mjs/);
|
||||
assert.match(stageSource, /git', \['ls-files', '--stage', '-z'/);
|
||||
assert.match(stageSource, /required package input is not tracked by Git/);
|
||||
});
|
||||
|
||||
canonicalZipTest('package smoke rejects every dependency metadata field in a built package', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-built-package-gate-'));
|
||||
try {
|
||||
const archive = path.join(fixture, 'archify.zip');
|
||||
const build = spawnSync(path.join(repoRoot, 'scripts', 'build-zip.sh'), [archive], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(build.status, 0, `${build.stdout}\n${build.stderr}`);
|
||||
|
||||
const extracted = path.join(fixture, 'extracted');
|
||||
fs.mkdirSync(extracted);
|
||||
const unzip = spawnSync('unzip', ['-q', archive, '-d', extracted], { encoding: 'utf8' });
|
||||
assert.equal(unzip.status, 0, `${unzip.stdout}\n${unzip.stderr}`);
|
||||
const builtPackage = path.join(extracted, 'archify');
|
||||
const dependencyFields = {
|
||||
dependencies: { runtime: '1.0.0' },
|
||||
devDependencies: { build: '1.0.0' },
|
||||
optionalDependencies: { optional: '1.0.0' },
|
||||
peerDependencies: { peer: '1.0.0' },
|
||||
bundledDependencies: ['bundled'],
|
||||
bundleDependencies: ['bundle-alias'],
|
||||
};
|
||||
|
||||
for (const [field, value] of Object.entries(dependencyFields)) {
|
||||
const caseRoot = path.join(fixture, field);
|
||||
fs.cpSync(builtPackage, caseRoot, { recursive: true });
|
||||
const packagePath = path.join(caseRoot, 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
packageJson[field] = value;
|
||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
|
||||
const result = spawnSync(process.execPath, [path.join(repoRoot, 'scripts', 'package-smoke.mjs'), caseRoot], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.notEqual(result.status, 0, `${field} must fail package smoke`);
|
||||
assert.match(`${result.stdout}\n${result.stderr}`, new RegExp(`dependency metadata: ${field}\\b`));
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
canonicalZipTest('built archives contain the embedded notifier runtime', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-notifier-package-gate-'));
|
||||
try {
|
||||
const archive = path.join(fixture, 'archify.zip');
|
||||
const build = spawnSync(path.join(repoRoot, 'scripts', 'build-zip.sh'), [archive], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(build.status, 0, `${build.stdout}\n${build.stderr}`);
|
||||
|
||||
const listing = spawnSync('unzip', ['-Z1', archive], { encoding: 'utf8' });
|
||||
assert.equal(listing.status, 0, `${listing.stdout}\n${listing.stderr}`);
|
||||
const entries = new Set(listing.stdout.trim().split('\n'));
|
||||
assert.ok(entries.has('archify/skill-release.json'));
|
||||
assert.ok(entries.has('archify/scripts/check-update.mjs'));
|
||||
assert.ok(entries.has('archify/scripts/update-contract.mjs'));
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
canonicalZipTest('archive build excludes untracked files and external symlinks from the live working tree', () => {
|
||||
const marker = `.package-negative-${process.pid}-${Date.now()}`;
|
||||
const untracked = path.join(repoRoot, 'archify', `${marker}.txt`);
|
||||
const externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-package-external-'));
|
||||
const externalTarget = path.join(externalRoot, 'secret.txt');
|
||||
const externalLink = path.join(repoRoot, 'archify', `${marker}.link`);
|
||||
const outputRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-package-negative-'));
|
||||
const archive = path.join(outputRoot, 'archify.zip');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(untracked, 'must not ship\n');
|
||||
fs.writeFileSync(externalTarget, 'external content must not ship\n');
|
||||
fs.symlinkSync(externalTarget, externalLink, 'file');
|
||||
|
||||
const build = spawnSync(path.join(repoRoot, 'scripts', 'build-zip.sh'), [archive], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(build.status, 0, `${build.stdout}\n${build.stderr}`);
|
||||
|
||||
const listing = spawnSync('unzip', ['-Z1', archive], { encoding: 'utf8' });
|
||||
assert.equal(listing.status, 0, `${listing.stdout}\n${listing.stderr}`);
|
||||
assert.doesNotMatch(listing.stdout, new RegExp(marker), 'untracked files and symlinks must not enter the archive');
|
||||
} finally {
|
||||
fs.rmSync(untracked, { force: true });
|
||||
fs.rmSync(externalLink, { force: true });
|
||||
fs.rmSync(externalRoot, { recursive: true, force: true });
|
||||
fs.rmSync(outputRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
canonicalZipTest('archive build rejects an unmerged index and preserves an existing archive', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-package-unmerged-'));
|
||||
const scripts = path.join(fixture, 'scripts');
|
||||
const skill = path.join(fixture, 'archify');
|
||||
const license = path.join(skill, 'LICENSE');
|
||||
const archive = path.join(fixture, 'trusted.zip');
|
||||
const trusted = Buffer.from('trusted archive bytes');
|
||||
const git = (args, options = {}) => spawnSync('git', args, {
|
||||
cwd: fixture,
|
||||
encoding: 'utf8',
|
||||
...options,
|
||||
});
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(skill, 'renderers', 'shared'), { recursive: true });
|
||||
fs.mkdirSync(path.join(skill, 'scripts'), { recursive: true });
|
||||
fs.mkdirSync(scripts);
|
||||
fs.copyFileSync(path.join(repoRoot, 'scripts', 'build-zip.sh'), path.join(scripts, 'build-zip.sh'));
|
||||
fs.copyFileSync(
|
||||
path.join(repoRoot, 'scripts', 'write-deterministic-zip.mjs'),
|
||||
path.join(scripts, 'write-deterministic-zip.mjs'),
|
||||
);
|
||||
fs.copyFileSync(
|
||||
path.join(repoRoot, 'scripts', 'stage-clean-skill.mjs'),
|
||||
path.join(scripts, 'stage-clean-skill.mjs'),
|
||||
);
|
||||
fs.writeFileSync(path.join(skill, 'renderers', 'shared', 'generated-validators.mjs'), 'export default {};\n');
|
||||
fs.writeFileSync(path.join(skill, 'scripts', 'check-update.mjs'), 'export {};\n');
|
||||
fs.writeFileSync(path.join(skill, 'scripts', 'update-contract.mjs'), 'export {};\n');
|
||||
fs.writeFileSync(path.join(skill, 'skill-release.json'), '{}\n');
|
||||
fs.writeFileSync(path.join(skill, 'package.json'), '{"name":"archify"}\n');
|
||||
fs.writeFileSync(license, 'base\n');
|
||||
assert.equal(git(['init']).status, 0);
|
||||
assert.equal(git(['add', '.']).status, 0);
|
||||
|
||||
const base = git(['hash-object', '-w', '--stdin'], { input: 'base\n' });
|
||||
const ours = git(['hash-object', '-w', '--stdin'], { input: 'ours\n' });
|
||||
const theirs = git(['hash-object', '-w', '--stdin'], { input: 'theirs\n' });
|
||||
for (const result of [base, ours, theirs]) assert.equal(result.status, 0, result.stderr);
|
||||
const indexInfo = [
|
||||
`100644 ${base.stdout.trim()} 1\tarchify/LICENSE`,
|
||||
`100644 ${ours.stdout.trim()} 2\tarchify/LICENSE`,
|
||||
`100644 ${theirs.stdout.trim()} 3\tarchify/LICENSE`,
|
||||
'',
|
||||
].join('\n');
|
||||
assert.equal(git(['update-index', '--index-info'], { input: indexInfo }).status, 0);
|
||||
fs.writeFileSync(license, '<<<<<<< ours\n=======\n>>>>>>> theirs\n');
|
||||
fs.writeFileSync(archive, trusted);
|
||||
|
||||
const build = spawnSync('bash', [path.join(scripts, 'build-zip.sh'), archive], {
|
||||
cwd: fixture,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.notEqual(build.status, 0, `${build.stdout}\n${build.stderr}`);
|
||||
assert.match(build.stderr, /refusing to package unmerged index entry/);
|
||||
assert.ok(fs.readFileSync(archive).equals(trusted), 'a failed build must preserve the trusted archive');
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('archive build rejects non-canonical Node versions before publishing output', {
|
||||
skip: currentNodeMajor === canonicalZipNodeMajor
|
||||
? `requires a Node major other than ${canonicalZipNodeMajor}`
|
||||
: false,
|
||||
}, () => {
|
||||
const outputRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-package-node-version-'));
|
||||
try {
|
||||
const archive = path.join(outputRoot, 'archify.zip');
|
||||
const trusted = Buffer.from('existing canonical archive');
|
||||
fs.writeFileSync(archive, trusted);
|
||||
const build = spawnSync(path.join(repoRoot, 'scripts', 'build-zip.sh'), [archive], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.notEqual(build.status, 0, `${build.stdout}\n${build.stderr}`);
|
||||
assert.match(build.stderr, /canonical archify\.zip builds require Node 22/);
|
||||
assert.ok(fs.readFileSync(archive).equals(trusted), 'version rejection must preserve the canonical archive');
|
||||
} finally {
|
||||
fs.rmSync(outputRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
canonicalZipTest('archive build is byte-for-byte reproducible across caller time zones without system zip', () => {
|
||||
const outputRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-package-reproducible-'));
|
||||
const utcArchive = path.join(outputRoot, 'utc.zip');
|
||||
const honoluluArchive = path.join(outputRoot, 'honolulu.zip');
|
||||
|
||||
try {
|
||||
for (const [archive, timezone] of [
|
||||
[utcArchive, 'UTC'],
|
||||
[honoluluArchive, 'Pacific/Honolulu'],
|
||||
]) {
|
||||
const build = spawnSync(path.join(repoRoot, 'scripts', 'build-zip.sh'), [archive], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, TZ: timezone },
|
||||
});
|
||||
assert.equal(build.status, 0, `${build.stdout}\n${build.stderr}`);
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
fs.readFileSync(utcArchive).equals(fs.readFileSync(honoluluArchive)),
|
||||
'identical tracked inputs must produce identical archive bytes',
|
||||
);
|
||||
assert.ok(
|
||||
fs.readFileSync(utcArchive).equals(fs.readFileSync(path.join(repoRoot, 'archify.zip'))),
|
||||
'the canonical archive toolchain must reproduce the committed archive bytes',
|
||||
);
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(outputRoot).sort(),
|
||||
['honolulu.zip', 'utc.zip'],
|
||||
'successful archive publication must not leave temporary files behind',
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(outputRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('CI tests the declared Node floor plus every maintained current lane', () => {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'archify', 'package.json'), 'utf8'));
|
||||
assert.equal(packageJson.engines?.node, '>=18');
|
||||
|
||||
const workflow = fs.readFileSync(path.join(repoRoot, '.github', 'workflows', 'ci.yml'), 'utf8');
|
||||
const testJob = workflowJob(workflow, 'test');
|
||||
const versions = testJob.match(/node-version:\s*\[([^\]]+)\]/)?.[1]
|
||||
.split(',')
|
||||
.map((version) => Number(version.trim()));
|
||||
assert.ok(versions, 'test job must declare an explicit Node version matrix');
|
||||
for (const version of [18, 20, 22, 24]) {
|
||||
assert.ok(versions.includes(version), `test matrix must cover Node ${version}`);
|
||||
}
|
||||
|
||||
const packageSmokeJob = workflowJob(workflow, 'package-smoke');
|
||||
assert.match(packageSmokeJob, /os:\s*\[ubuntu-latest, macos-latest, windows-latest\]/);
|
||||
assert.match(packageSmokeJob, /node-version:\s*22/);
|
||||
});
|
||||
@@ -0,0 +1,435 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-output-checks-'));
|
||||
const checker = path.join(skillRoot, 'scripts/check-render-output.mjs');
|
||||
|
||||
function checkHtml(name, svgBody, profile = 'standard', viewBox = '0 0 240 160') {
|
||||
const htmlPath = path.join(tmp, `${name}.html`);
|
||||
fs.writeFileSync(htmlPath, `<!doctype html><html><body><svg viewBox="${viewBox}" data-quality-profile="${profile}">${svgBody}</svg></body></html>`);
|
||||
try {
|
||||
const stdout = execFileSync('node', [checker, htmlPath], { encoding: 'utf8' });
|
||||
return { code: 0, result: JSON.parse(stdout) };
|
||||
} catch (err) {
|
||||
return { code: err.status ?? 1, result: JSON.parse(String(err.stdout || '{}')) };
|
||||
}
|
||||
}
|
||||
|
||||
test('render output check: showcase rejects node copy that becomes illegible at 1440px', () => {
|
||||
const { code, result } = checkHtml('showcase-desktop-readability', `
|
||||
<g data-node-id="tool-runtime">
|
||||
<rect x="1398" y="266" width="194" height="70" rx="6" class="c-mask"/>
|
||||
<text data-detail-anchor x="1495" y="299" class="t-primary" font-size="11">ToolRuntime</text>
|
||||
<text data-detail="context" x="1495" y="315" class="t-muted" font-size="8.1">permissions and recovery</text>
|
||||
</g>
|
||||
`, 'showcase', '0 0 1994 804');
|
||||
|
||||
assert.notEqual(code, 0);
|
||||
const issue = result.composition.issues.find(
|
||||
(item) => item.code === 'composition/desktop-readability',
|
||||
);
|
||||
assert.equal(issue?.severity, 'error');
|
||||
assert.equal(issue?.viewportWidth, 1440);
|
||||
assert.ok(issue?.projectedFontPx < issue?.minimumProjectedFontPx);
|
||||
});
|
||||
|
||||
test('render output check: compares exact projected size before rounding diagnostics', () => {
|
||||
const sourceFontPx = 8.1;
|
||||
const viewBoxWidth = 1260;
|
||||
assert.ok(sourceFontPx * 930 / viewBoxWidth < 6);
|
||||
|
||||
const { code, result } = checkHtml('showcase-desktop-readability-borderline', `
|
||||
<g data-node-id="tool-runtime">
|
||||
<rect x="100" y="100" width="194" height="70" rx="6" class="c-mask"/>
|
||||
<text data-detail="context" x="197" y="140" class="t-muted" font-size="${sourceFontPx}">permissions and recovery</text>
|
||||
</g>
|
||||
`, 'showcase', `0 0 ${viewBoxWidth} 804`);
|
||||
|
||||
assert.notEqual(code, 0);
|
||||
const issue = result.composition.issues.find(
|
||||
(item) => item.code === 'composition/desktop-readability',
|
||||
);
|
||||
assert.equal(issue?.severity, 'error');
|
||||
assert.ok(issue?.projectedFontPx < issue?.minimumProjectedFontPx);
|
||||
});
|
||||
|
||||
test('render output check: includes primary node labels in desktop readability', () => {
|
||||
const { code, result } = checkHtml('showcase-primary-desktop-readability', `
|
||||
<g data-node-id="compact-node">
|
||||
<rect x="100" y="100" width="120" height="48" rx="6" class="c-mask"/>
|
||||
<text data-node-label x="160" y="126" class="t-primary" font-size="8">Compact node</text>
|
||||
</g>
|
||||
`, 'showcase', '0 0 1300 700');
|
||||
|
||||
assert.notEqual(code, 0);
|
||||
const issue = result.composition.issues.find(
|
||||
(item) => item.code === 'composition/desktop-readability',
|
||||
);
|
||||
assert.equal(issue?.text, 'Compact node');
|
||||
assert.equal(issue?.detail, 'primary');
|
||||
assert.equal(issue?.availableDiagramWidth, 930);
|
||||
assert.ok(issue?.projectedFontPx < issue?.minimumProjectedFontPx);
|
||||
});
|
||||
|
||||
test('render output check: includes semantic boundary labels in desktop readability', () => {
|
||||
const { code, result } = checkHtml('showcase-boundary-desktop-readability', `
|
||||
<g data-graph-role="structural-frame-label">
|
||||
<rect data-graph-role="structural-frame-label-mask" x="100" y="100" width="180" height="16" class="c-mask"/>
|
||||
<text x="104" y="113" class="t-cloud" font-size="8.4" data-boundary-label>Disaster recovery boundary</text>
|
||||
</g>
|
||||
`, 'showcase', '0 0 1376 728');
|
||||
|
||||
assert.notEqual(code, 0);
|
||||
const issue = result.composition.issues.find(
|
||||
(item) => item.code === 'composition/desktop-readability',
|
||||
);
|
||||
assert.equal(issue?.text, 'Disaster recovery boundary');
|
||||
assert.equal(issue?.detail, 'boundary');
|
||||
assert.ok(issue?.projectedFontPx < issue?.minimumProjectedFontPx);
|
||||
});
|
||||
|
||||
test('render output check: accepts orthogonal arrows away from legend', () => {
|
||||
const { code, result } = checkHtml('clean', `
|
||||
<path d="M 20 20 L 120 20 L 120 60" class="a-default" stroke-width="1.4" marker-end="url(#arrowhead)"/>
|
||||
<!-- Legend -->
|
||||
<text x="40" y="120" class="t-primary" font-size="10">Legend</text>
|
||||
<rect x="40" y="132" width="14" height="9" class="c-backend"/>
|
||||
<text x="60" y="140" class="t-muted" font-size="7">Backend</text>
|
||||
`);
|
||||
assert.equal(code, 0);
|
||||
assert.equal(result.ok, true);
|
||||
});
|
||||
|
||||
test('render output check: rejects two-point diagonal arrows', () => {
|
||||
const { code, result } = checkHtml('diagonal', `
|
||||
<path d="M 20 20 L 120 80" class="a-default" stroke-width="1.4" marker-end="url(#arrowhead)"/>
|
||||
<!-- Legend -->
|
||||
<text x="40" y="120" class="t-primary" font-size="10">Legend</text>
|
||||
`);
|
||||
assert.notEqual(code, 0);
|
||||
const check = result.checks.find((item) => item.name === 'orthogonal_arrows');
|
||||
assert.equal(check.ok, false);
|
||||
assert.match(check.details[0], /path 1/);
|
||||
});
|
||||
|
||||
test('render output check: rejects a diagonal segment inside a polyline', () => {
|
||||
const { code, result } = checkHtml('polyline-diagonal', `
|
||||
<path d="M 20 20 L 60 35 L 120 35" class="a-default" stroke-width="1.4" marker-end="url(#arrowhead)"/>
|
||||
<!-- Legend -->
|
||||
<text x="40" y="120" class="t-primary" font-size="10">Legend</text>
|
||||
`);
|
||||
assert.notEqual(code, 0);
|
||||
const check = result.checks.find((item) => item.name === 'orthogonal_arrows');
|
||||
assert.equal(check.ok, false);
|
||||
assert.match(check.details[0], /path 1/);
|
||||
assert.match(check.details[0], /segment 1/);
|
||||
});
|
||||
|
||||
test('render output check: rejects arrows crossing legend text', () => {
|
||||
const { code, result } = checkHtml('legend-crossing', `
|
||||
<path d="M 20 112 L 180 112" class="a-dashed" stroke-width="1.4" marker-end="url(#arrowhead-dashed)"/>
|
||||
<!-- Legend -->
|
||||
<text x="40" y="120" class="t-primary" font-size="10">Legend</text>
|
||||
<rect x="40" y="132" width="14" height="9" class="c-backend"/>
|
||||
<text x="60" y="140" class="t-muted" font-size="7">Backend</text>
|
||||
`);
|
||||
assert.notEqual(code, 0);
|
||||
const check = result.checks.find((item) => item.name === 'legend_clearance');
|
||||
assert.equal(check.ok, false);
|
||||
assert.match(check.details[0], /Legend/);
|
||||
});
|
||||
|
||||
test('render output check: ignores unmarked sequence lifelines near legend', () => {
|
||||
const { code, result } = checkHtml('lifeline-near-legend', `
|
||||
<path d="M 60 20 L 60 126" class="a-default" stroke-width="0.8" stroke-dasharray="3,7"/>
|
||||
<!-- Legend -->
|
||||
<text x="40" y="120" class="t-primary" font-size="10">Legend</text>
|
||||
<path d="M 120 136 L 154 136" class="a-default" stroke-width="1.4" stroke-dasharray="3,5" marker-end="url(#arrowhead)"/>
|
||||
<text x="163" y="139" class="t-muted" font-size="8">return</text>
|
||||
`);
|
||||
assert.equal(code, 0);
|
||||
assert.equal(result.ok, true);
|
||||
});
|
||||
|
||||
test('render output check: standard records a proper X as a composition warning', () => {
|
||||
const { code, result } = checkHtml('standard-crossing', `
|
||||
<path data-edge-from="a" data-edge-to="b" d="M 20 60 L 200 60" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
<path data-edge-from="c" data-edge-to="d" d="M 103 20 L 103 120" class="a-dashed" marker-end="url(#arrowhead-dashed)"/>
|
||||
`);
|
||||
assert.equal(code, 0);
|
||||
assert.equal(result.composition.profile, 'standard');
|
||||
assert.deepEqual(result.composition.summary, { errors: 0, warnings: 1 });
|
||||
assert.equal(result.composition.metrics.properCrossings, 1);
|
||||
assert.equal(result.composition.issues[0].code, 'composition/proper-crossing');
|
||||
assert.equal(result.composition.issues[0].severity, 'warning');
|
||||
});
|
||||
|
||||
test('render output check: showcase rejects a proper X with semantic identities', () => {
|
||||
const { code, result } = checkHtml('showcase-crossing', `
|
||||
<path data-edge-id="left" data-edge-from="a" data-edge-to="b" d="M 20 60 L 200 60" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
<path data-edge-id="right" data-edge-from="c" data-edge-to="d" d="M 100 20 L 100 120" class="a-dashed" marker-end="url(#arrowhead-dashed)"/>
|
||||
`, 'showcase');
|
||||
assert.notEqual(code, 0);
|
||||
const check = result.checks.find((item) => item.name === 'relationship_crossings');
|
||||
assert.equal(check.ok, false);
|
||||
assert.match(check.details[0], /\[composition\/proper-crossing\] showcase/);
|
||||
assert.match(check.details[0], /relationship id "left"/);
|
||||
assert.deepEqual(result.composition.summary, { errors: 1, warnings: 0 });
|
||||
});
|
||||
|
||||
test('render output check: shared endpoints and endpoint touches pass showcase', () => {
|
||||
const { code, result } = checkHtml('showcase-exemptions', `
|
||||
<path data-edge-from="a" data-edge-to="b" d="M 20 60 L 200 60" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
<path data-edge-from="a" data-edge-to="c" d="M 100 20 L 100 90" class="a-dashed" marker-end="url(#arrowhead-dashed)"/>
|
||||
<path data-edge-from="d" data-edge-to="e" d="M 20 100 L 100 100" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
<path data-edge-from="f" data-edge-to="g" d="M 100 100 L 100 140" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
`, 'showcase');
|
||||
assert.equal(code, 0);
|
||||
assert.equal(result.composition.metrics.properCrossings, 0);
|
||||
assert.equal(result.composition.metrics.ambiguousCorridors, 0);
|
||||
});
|
||||
|
||||
test('render output check: relationship labels cannot hide another shared-source route', () => {
|
||||
for (const profile of ['standard', 'showcase']) {
|
||||
const { code, result } = checkHtml(`label-route-${profile}`, `
|
||||
<path data-edge-key="0" data-edge-id="approved" data-edge-from="dlq" data-edge-to="replay" data-composition-points="20,60;200,60" d="M 20 60 L 200 60" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
<path data-edge-key="1" data-edge-id="sample" data-edge-from="dlq" data-edge-to="ops" data-composition-points="70,55;150,55" d="M 70 55 L 150 55" class="a-dashed" marker-end="url(#arrowhead-dashed)"/>
|
||||
<g data-detail="context" data-edge-key="0" data-edge-id="approved" data-edge-from="dlq" data-edge-to="replay" data-edge-label="approved replay">
|
||||
<rect x="80" y="48" width="60" height="14" rx="3" class="c-mask"/>
|
||||
<text x="110" y="58">approved replay</text>
|
||||
</g>
|
||||
`, profile);
|
||||
|
||||
assert.equal(result.composition.metrics.labelRouteClearanceIssues, 1);
|
||||
assert.equal(result.composition.metrics.minLabelRouteClearance, 0);
|
||||
const issue = result.composition.issues.find((item) => item.code === 'composition/label-route-clearance');
|
||||
assert.deepEqual(issue.labelRelationship, { id: 'approved', from: 'dlq', to: 'replay', label: 'approved replay', collectionIndex: 0, artifactIndex: 1 });
|
||||
assert.deepEqual(issue.otherRelationship, { id: 'sample', from: 'dlq', to: 'ops', label: '', collectionIndex: 1, artifactIndex: 2 });
|
||||
assert.equal(issue.segmentIndex, 0);
|
||||
assert.deepEqual(issue.labelRect, { x: 80, y: 48, width: 60, height: 14 });
|
||||
assert.equal(issue.clearance, 0);
|
||||
assert.equal(issue.intersectionLength, 60);
|
||||
assert.equal(issue.threshold, profile === 'showcase' ? 4 : 2);
|
||||
const check = result.checks.find((item) => item.name === 'label_route_clearance');
|
||||
if (profile === 'standard') {
|
||||
assert.equal(code, 0);
|
||||
assert.equal(check.ok, true);
|
||||
assert.equal(issue.severity, 'warning');
|
||||
assert.deepEqual(result.composition.summary, { errors: 0, warnings: 1 });
|
||||
} else {
|
||||
assert.notEqual(code, 0);
|
||||
assert.equal(check.ok, false);
|
||||
assert.match(check.details[0], /approved.*sample/);
|
||||
assert.match(check.details[0], /labelAt.*labelDx.*labelDy.*labelSegment/);
|
||||
assert.equal(issue.severity, 'error');
|
||||
assert.deepEqual(result.composition.summary, { errors: 1, warnings: 0 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('render output check: repeated endpoint messages keep their own stable owner identity', () => {
|
||||
const { code, result } = checkHtml('sequence-repeated-endpoints', `
|
||||
<g data-edge-key="0" data-edge-from="client" data-edge-to="api" data-edge-label="first request">
|
||||
<path data-composition-edge-from="client" data-composition-edge-to="api" data-composition-points="20,20;200,20" d="M 20 20 L 200 20" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
<g data-detail="context"><rect x="70" y="2" width="80" height="16" class="c-mask"/></g>
|
||||
</g>
|
||||
<g data-edge-key="1" data-edge-from="client" data-edge-to="api" data-edge-label="second request">
|
||||
<path data-composition-edge-from="client" data-composition-edge-to="api" data-composition-points="20,60;200,60" d="M 20 60 L 200 60" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
<g data-detail="context"><rect x="70" y="72" width="80" height="16" class="c-mask"/></g>
|
||||
</g>
|
||||
<path data-edge-key="2" data-edge-from="worker" data-edge-to="store" data-composition-points="20,80;200,80" d="M 20 80 L 200 80" class="a-dashed" marker-end="url(#arrowhead-dashed)"/>
|
||||
`, 'showcase');
|
||||
assert.notEqual(code, 0);
|
||||
const issues = result.composition.issues.filter((item) => item.code === 'composition/label-route-clearance');
|
||||
assert.equal(issues.length, 1);
|
||||
assert.equal(issues[0].label, 'second request');
|
||||
assert.equal(issues[0].labelRelationship.collectionIndex, 1);
|
||||
assert.equal(issues[0].otherRelationship.collectionIndex, 2);
|
||||
});
|
||||
|
||||
test('render output check: duplicate fragments of the owning relationship stay exempt', () => {
|
||||
const { code, result } = checkHtml('label-owner-fragments', `
|
||||
<path data-edge-key="0" data-edge-id="approved" data-edge-from="dlq" data-edge-to="replay" data-composition-points="20,60;200,60" d="M 20 60 L 200 60" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
<path data-edge-key="0" data-edge-id="approved" data-edge-from="dlq" data-edge-to="replay" data-composition-points="20,60;200,60" d="M 20 60 L 200 60" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
<g data-detail="context" data-edge-key="0" data-edge-id="approved" data-edge-from="dlq" data-edge-to="replay" data-edge-label="approved replay">
|
||||
<rect x="80" y="48" width="60" height="14" rx="3" class="c-mask"/>
|
||||
<text x="110" y="58">approved replay</text>
|
||||
</g>
|
||||
`, 'showcase');
|
||||
assert.equal(code, 0);
|
||||
assert.equal(result.composition.metrics.labelRouteClearanceIssues, 0);
|
||||
assert.equal(result.composition.metrics.minLabelRouteClearance, null);
|
||||
});
|
||||
|
||||
test('render output check: duplicate fragments of another relationship count once', () => {
|
||||
const { result } = checkHtml('label-other-fragments', `
|
||||
<path data-edge-key="0" data-edge-id="approved" data-edge-from="dlq" data-edge-to="replay" data-composition-points="20,60;200,60" d="M 20 60 L 200 60" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
<path data-edge-key="1" data-edge-id="sample" data-edge-from="dlq" data-edge-to="ops" data-composition-points="70,55;150,55" d="M 70 55 L 150 55" class="a-dashed" marker-end="url(#arrowhead-dashed)"/>
|
||||
<path data-edge-key="1" data-edge-id="sample" data-edge-from="dlq" data-edge-to="ops" data-composition-points="70,55;150,55" d="M 70 55 L 150 55" class="a-dashed" marker-end="url(#arrowhead-dashed)"/>
|
||||
<g data-detail="context" data-edge-key="0" data-edge-id="approved" data-edge-from="dlq" data-edge-to="replay" data-edge-label="approved replay">
|
||||
<rect x="80" y="48" width="60" height="14" rx="3" class="c-mask"/>
|
||||
<text x="110" y="58">approved replay</text>
|
||||
</g>
|
||||
`, 'showcase');
|
||||
assert.equal(result.composition.metrics.labelRouteClearanceIssues, 1);
|
||||
});
|
||||
|
||||
test('render output check: label-route thresholds include exact 2px and 4px boundaries', () => {
|
||||
const body = (otherY) => `
|
||||
<path data-edge-key="0" data-edge-from="a" data-edge-to="b" data-composition-points="20,70;200,70" d="M 20 70 L 200 70" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
<path data-edge-key="1" data-edge-from="c" data-edge-to="d" data-composition-points="70,${otherY};150,${otherY}" d="M 70 ${otherY} L 150 ${otherY}" class="a-dashed" marker-end="url(#arrowhead-dashed)"/>
|
||||
<g data-detail="context" data-edge-key="0" data-edge-from="a" data-edge-to="b" data-edge-label="handoff">
|
||||
<rect x="80" y="48" width="60" height="14" rx="3" class="c-mask"/>
|
||||
<text x="110" y="58">handoff</text>
|
||||
</g>
|
||||
`;
|
||||
const standardAtTwo = checkHtml('label-standard-two', body(64), 'standard');
|
||||
assert.equal(standardAtTwo.code, 0);
|
||||
assert.equal(standardAtTwo.result.composition.metrics.labelRouteClearanceIssues, 0);
|
||||
assert.equal(standardAtTwo.result.composition.metrics.minLabelRouteClearance, 2);
|
||||
|
||||
const standardBelowTwo = checkHtml('label-standard-one-nine', body(63.9), 'standard');
|
||||
assert.equal(standardBelowTwo.code, 0);
|
||||
assert.equal(standardBelowTwo.result.composition.metrics.labelRouteClearanceIssues, 1);
|
||||
assert.equal(standardBelowTwo.result.composition.summary.warnings, 1);
|
||||
|
||||
const showcaseAtTwo = checkHtml('label-showcase-two', body(64), 'showcase');
|
||||
assert.notEqual(showcaseAtTwo.code, 0);
|
||||
assert.equal(showcaseAtTwo.result.composition.metrics.labelRouteClearanceIssues, 1);
|
||||
assert.equal(showcaseAtTwo.result.composition.issues.find((item) => item.code === 'composition/label-route-clearance').threshold, 4);
|
||||
|
||||
const showcaseAtFour = checkHtml('label-showcase-four', body(66), 'showcase');
|
||||
assert.equal(showcaseAtFour.code, 0);
|
||||
assert.equal(showcaseAtFour.result.composition.metrics.labelRouteClearanceIssues, 0);
|
||||
assert.equal(showcaseAtFour.result.composition.metrics.minLabelRouteClearance, 4);
|
||||
|
||||
const showcaseBelowFour = checkHtml('label-showcase-three-nine', body(65.9), 'showcase');
|
||||
assert.notEqual(showcaseBelowFour.code, 0);
|
||||
assert.equal(showcaseBelowFour.result.composition.metrics.labelRouteClearanceIssues, 1);
|
||||
assert.equal(showcaseBelowFour.result.composition.summary.errors, 1);
|
||||
});
|
||||
|
||||
test('render output check: unrelated shared corridors warn in standard and fail showcase', () => {
|
||||
for (const profile of ['standard', 'showcase']) {
|
||||
const { code, result } = checkHtml(`corridor-${profile}`, `
|
||||
<path data-edge-id="first" data-edge-from="a" data-edge-to="b" data-composition-points="20,60;140,60;140,100" d="M 20 60 L 140 60 L 140 100" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
<path data-edge-id="second" data-edge-from="c" data-edge-to="d" data-composition-points="60,60;180,60;180,100" d="M 60 60 L 180 60 L 180 100" class="a-dashed" marker-end="url(#arrowhead-dashed)"/>
|
||||
`, profile);
|
||||
assert.equal(result.composition.metrics.ambiguousCorridors, 1);
|
||||
assert.equal(result.composition.issues[0].code, 'composition/ambiguous-corridor');
|
||||
assert.equal(result.composition.issues[0].overlapLength, 80);
|
||||
assert.deepEqual(result.composition.issues[0].from, [60, 60]);
|
||||
assert.deepEqual(result.composition.issues[0].to, [140, 60]);
|
||||
const check = result.checks.find((item) => item.name === 'relationship_corridors');
|
||||
if (profile === 'standard') {
|
||||
assert.equal(code, 0);
|
||||
assert.equal(check.ok, true);
|
||||
assert.deepEqual(result.composition.summary, { errors: 0, warnings: 1 });
|
||||
assert.equal(result.composition.issues[0].severity, 'warning');
|
||||
} else {
|
||||
assert.notEqual(code, 0);
|
||||
assert.equal(check.ok, false);
|
||||
assert.match(check.details[0], /\[composition\/ambiguous-corridor\] showcase/);
|
||||
assert.match(check.details[0], /relationship id "first".*relationship id "second"/);
|
||||
assert.deepEqual(result.composition.summary, { errors: 1, warnings: 0 });
|
||||
assert.equal(result.composition.issues[0].severity, 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('render output check: visible quadratic crossing is caught in showcase', () => {
|
||||
const { code, result } = checkHtml('showcase-quadratic-crossing', `
|
||||
<path data-edge-from="a" data-edge-to="b" d="M 20 90 Q 100 10 180 90" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
<path data-edge-from="c" data-edge-to="d" d="M 103 20 L 103 120" class="a-dashed" marker-end="url(#arrowhead-dashed)"/>
|
||||
`, 'showcase');
|
||||
assert.notEqual(code, 0);
|
||||
assert.equal(result.composition.metrics.properCrossings, 1);
|
||||
assert.equal(result.composition.status, 'fail');
|
||||
});
|
||||
|
||||
test('render output check: container border runs fail both profiles with frame identity', () => {
|
||||
for (const profile of ['standard', 'showcase']) {
|
||||
const { code, result } = checkHtml(`border-run-${profile}`, `
|
||||
<rect data-composition-frame-kind="stage" data-composition-frame-id="sources" x="40" y="40" width="160" height="80" rx="10"/>
|
||||
<path data-edge-id="events" data-edge-from="web" data-edge-to="edge" data-composition-points="60,40;150,40" d="M 60 40 L 150 40" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
`, profile);
|
||||
assert.notEqual(code, 0);
|
||||
const check = result.checks.find((item) => item.name === 'container_border_runs');
|
||||
assert.equal(check.ok, false);
|
||||
assert.match(check.details[0], /\[composition\/container-border-run\].*relationship id "events"/);
|
||||
assert.match(check.details[0], /stage "sources" top border for 90px/);
|
||||
assert.equal(result.composition.summary.errors, 1);
|
||||
assert.equal(result.composition.metrics.containerBorderRuns, 1);
|
||||
assert.equal(result.composition.issues[0].code, 'composition/container-border-run');
|
||||
}
|
||||
});
|
||||
|
||||
test('render output check: perpendicular crossings, rounded-corner touches, and tangent Q curves pass', () => {
|
||||
const { code, result } = checkHtml('border-run-exemptions', `
|
||||
<rect data-composition-frame-kind="group" data-composition-frame-id="safe" x="40" y="40" width="160" height="80" rx="10"/>
|
||||
<path data-edge-from="a" data-edge-to="b" d="M 100 10 L 100 80" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
<path data-edge-from="c" data-edge-to="d" d="M 40 40 L 49 40" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
<path data-edge-from="e" data-edge-to="f" d="M 20 70 Q 40 40 60 40" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
`, 'showcase');
|
||||
assert.equal(code, 0);
|
||||
assert.equal(result.composition.metrics.containerBorderRuns, 0);
|
||||
});
|
||||
|
||||
test('render output check: a fully collinear quadratic primitive is a border run', () => {
|
||||
const { code, result } = checkHtml('border-run-collinear-q', `
|
||||
<rect data-composition-frame-kind="segment" data-composition-frame-id="retry" x="40" y="40" width="160" height="80" rx="10"/>
|
||||
<path data-edge-from="a" data-edge-to="b" d="M 60 40 Q 100 40 140 40" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
`);
|
||||
assert.notEqual(code, 0);
|
||||
assert.equal(result.composition.metrics.containerBorderRuns, 1);
|
||||
});
|
||||
|
||||
test('render output check: composition receipt records neutral normalized route metrics', () => {
|
||||
const { code, result } = checkHtml('route-metrics', `
|
||||
<path data-edge-from="a" data-edge-to="b" data-composition-points="0,20;10,20;30,20;30,28;50,28;50,50" d="M 0 20 L 30 20 L 30 28 L 50 28 L 50 50" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
`);
|
||||
assert.equal(code, 0);
|
||||
assert.equal(result.composition.metrics.maxBends, 3);
|
||||
assert.equal(result.composition.metrics.routesOverSuggestedBends, 1);
|
||||
assert.equal(result.composition.metrics.minSegmentPx, 8);
|
||||
assert.equal(result.composition.metrics.shortSegmentCount, 1);
|
||||
assert.equal(result.composition.metrics.shortInteriorSegmentCount, 1);
|
||||
assert.equal(result.composition.metrics.shortEndpointSegmentCount, 0);
|
||||
assert.equal(result.composition.metrics.microSegmentCount, 0);
|
||||
assert.deepEqual(result.composition.suggestedLimits, { bendsPerRelationship: 2, stretch: 1.35, segmentPx: 16, microSegmentPx: 8 });
|
||||
});
|
||||
|
||||
test('render output check: endpoint stubs from 8px pass while cramped interior turns are profile-aware', () => {
|
||||
const clean = checkHtml('endpoint-stubs', `
|
||||
<path data-edge-id="lane-hop" data-edge-from="a" data-edge-to="b" data-composition-points="0,20;13,20;13,60;80,60;80,73" d="M 0 20 L 13 20 L 13 60 L 80 60 L 80 73" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
`, 'showcase');
|
||||
assert.equal(clean.code, 0);
|
||||
assert.equal(clean.result.composition.metrics.shortEndpointSegmentCount, 2);
|
||||
assert.equal(clean.result.composition.metrics.shortInteriorSegmentCount, 0);
|
||||
|
||||
const standard = checkHtml('short-turn-standard', `
|
||||
<path data-edge-id="tight" data-edge-from="a" data-edge-to="b" data-composition-points="0,20;24,20;24,29;80,29" d="M 0 20 L 24 20 L 24 29 L 80 29" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
`, 'standard');
|
||||
assert.equal(standard.code, 0);
|
||||
assert.deepEqual(standard.result.composition.summary, { errors: 0, warnings: 1 });
|
||||
assert.equal(standard.result.composition.issues[0].code, 'composition/short-interior-segment');
|
||||
assert.equal(standard.result.checks.find((item) => item.name === 'route_rhythm').ok, true);
|
||||
|
||||
const showcase = checkHtml('short-turn-showcase', `
|
||||
<path data-edge-id="tight" data-edge-from="a" data-edge-to="b" data-composition-points="0,20;24,20;24,29;80,29" d="M 0 20 L 24 20 L 24 29 L 80 29" class="a-default" marker-end="url(#arrowhead)"/>
|
||||
`, 'showcase');
|
||||
assert.notEqual(showcase.code, 0);
|
||||
assert.deepEqual(showcase.result.composition.summary, { errors: 1, warnings: 0 });
|
||||
const rhythm = showcase.result.checks.find((item) => item.name === 'route_rhythm');
|
||||
assert.equal(rhythm.ok, false);
|
||||
assert.match(rhythm.details[0], /\[composition\/short-interior-segment\] showcase relationship id "tight"/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,181 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(here, '..');
|
||||
const cli = path.join(skillRoot, 'bin/archify.mjs');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-repair-receipt-'));
|
||||
|
||||
function run(args) {
|
||||
return spawnSync(process.execPath, [cli, ...args], {
|
||||
cwd: skillRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
function writeFixture(name, source) {
|
||||
const file = path.join(tmp, name);
|
||||
fs.writeFileSync(file, JSON.stringify(source, null, 2));
|
||||
return file;
|
||||
}
|
||||
|
||||
function receipt(result) {
|
||||
assert.doesNotThrow(() => JSON.parse(result.stdout), result.stdout || result.stderr);
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
test('repair receipt: malformed JSON is one clean machine object without a Node stack', () => {
|
||||
const input = path.join(tmp, 'malformed.workflow.json');
|
||||
fs.writeFileSync(input, '{broken json');
|
||||
|
||||
const result = run(['validate', 'workflow', input, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.stderr, '');
|
||||
const failure = receipt(result);
|
||||
assert.equal(failure.schemaVersion, 1);
|
||||
assert.equal(failure.ok, false);
|
||||
assert.equal(failure.command, 'validate');
|
||||
assert.equal(failure.stage, 'input');
|
||||
assert.equal(failure.diagnostics.length, 1);
|
||||
assert.deepEqual(failure.diagnostics[0].subject, { input });
|
||||
assert.equal(failure.diagnostics[0].code, 'input/json-parse');
|
||||
assert.equal(failure.diagnostics[0].severity, 'error');
|
||||
assert.match(failure.diagnostics[0].evidence.reason, /JSON/);
|
||||
assert.deepEqual(failure.diagnostics[0].supportedFixes, ['repair the JSON syntax and run validation again']);
|
||||
assert.doesNotMatch(result.stdout, /\n\s+at\s|file:\/\//);
|
||||
});
|
||||
|
||||
test('repair receipt: all five modes identify schema subjects and supported fixes', () => {
|
||||
const cases = {
|
||||
architecture: ['web-app.architecture.json', 'components'],
|
||||
workflow: ['agent-tool-call.workflow.json', 'nodes'],
|
||||
sequence: ['cache-miss-request.sequence.json', 'participants'],
|
||||
dataflow: ['product-analytics.dataflow.json', 'nodes'],
|
||||
lifecycle: ['agent-run.lifecycle.json', 'states'],
|
||||
};
|
||||
|
||||
for (const [type, [example, collection]] of Object.entries(cases)) {
|
||||
const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', example), 'utf8'));
|
||||
source[collection][0].unexpected = true;
|
||||
const identity = source[collection][0].id;
|
||||
const input = writeFixture(`schema-${type}.json`, source);
|
||||
const result = run(['validate', type, input, '--json']);
|
||||
|
||||
assert.equal(result.status, 1, `${type}: ${result.stderr || result.stdout}`);
|
||||
assert.equal(result.stderr, '', type);
|
||||
const failure = receipt(result);
|
||||
const repair = failure.diagnostics.find((entry) => entry.code === 'schema/additionalProperties');
|
||||
assert.ok(repair, type);
|
||||
assert.deepEqual(repair.subject, {
|
||||
diagramType: type,
|
||||
path: `/${collection}/0`,
|
||||
identity,
|
||||
});
|
||||
assert.equal(repair.evidence.additionalProperty, 'unexpected');
|
||||
assert.deepEqual(repair.supportedFixes, ['remove unsupported property "unexpected"']);
|
||||
}
|
||||
});
|
||||
|
||||
test('repair receipt: human validation formats the same rule without a stack', () => {
|
||||
const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/agent-tool-call.workflow.json'), 'utf8'));
|
||||
source.nodes[0].unexpected = true;
|
||||
const input = writeFixture('human-schema.workflow.json', source);
|
||||
const result = run(['validate', 'workflow', input]);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.match(result.stderr, /\[schema\/additionalProperties\]/);
|
||||
assert.match(result.stderr, /Fix: remove unsupported property "unexpected"/);
|
||||
assert.doesNotMatch(result.stderr, /\n\s+at\s|file:\/\//);
|
||||
});
|
||||
|
||||
test('repair receipt: validate and deliver share exact Clean Flow evidence while delivery preserves the trusted artifact', () => {
|
||||
const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/web-app.architecture.json'), 'utf8'));
|
||||
source.connections[0] = {
|
||||
...source.connections[0],
|
||||
fromSide: 'right',
|
||||
toSide: 'left',
|
||||
via: [[100, 140], [220, 140]],
|
||||
};
|
||||
const input = writeFixture('blocked-route.architecture.json', source);
|
||||
const output = path.join(tmp, 'trusted.html');
|
||||
const trusted = '<!doctype html><title>trusted prior artifact</title>\n';
|
||||
fs.writeFileSync(output, trusted);
|
||||
|
||||
const validated = run(['validate', 'architecture', input, '--quality', 'showcase', '--json']);
|
||||
const delivered = run(['deliver', 'architecture', input, output, '--quality', 'showcase', '--json']);
|
||||
assert.equal(validated.status, 1, validated.stderr || validated.stdout);
|
||||
assert.equal(delivered.status, 1, delivered.stderr || delivered.stdout);
|
||||
assert.equal(validated.stderr, '');
|
||||
assert.equal(delivered.stderr, '');
|
||||
|
||||
const validateRepair = receipt(validated).diagnostics.find((entry) => entry.code === 'clean-flow/edge-through-node');
|
||||
const deliverRepair = receipt(delivered).diagnostics.find((entry) => entry.code === 'clean-flow/edge-through-node');
|
||||
assert.ok(validateRepair);
|
||||
assert.ok(deliverRepair);
|
||||
assert.deepEqual(deliverRepair, validateRepair);
|
||||
assert.equal(validateRepair.subject.id, 'users-to-cdn');
|
||||
assert.equal(validateRepair.evidence.obstacleId, 'auth');
|
||||
assert.equal(validateRepair.evidence.segmentIndex, 0);
|
||||
assert.equal(validateRepair.evidence.clearancePx, 2);
|
||||
assert.ok(validateRepair.supportedFixes.some((fix) => fix.includes('route/via')));
|
||||
assert.equal(fs.readFileSync(output, 'utf8'), trusted);
|
||||
assert.deepEqual(fs.readdirSync(tmp).filter((name) => name.startsWith('.archify-delivery-')), []);
|
||||
});
|
||||
|
||||
test('repair receipt: repository evidence failures retain a stable rule and exact repair', () => {
|
||||
const source = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples/web-app.architecture.json'), 'utf8'));
|
||||
source.meta.repository = {
|
||||
url: 'https://github.com/example/repository',
|
||||
revision: '0123456789abcdef0123456789abcdef01234567',
|
||||
};
|
||||
source.components[0].sources = [{ path: 'src/index.js', line: 1 }];
|
||||
const input = writeFixture('evidence-root-required.architecture.json', source);
|
||||
const result = run(['validate', 'architecture', input, '--json']);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.stderr, '');
|
||||
const repair = receipt(result).diagnostics[0];
|
||||
assert.equal(repair.code, 'repository-evidence/root-required');
|
||||
assert.deepEqual(repair.subject, { surface: 'repository-evidence', path: '/meta/repository' });
|
||||
assert.deepEqual(repair.supportedFixes, ['pass --repo-root with the matching local Git checkout']);
|
||||
});
|
||||
|
||||
test('repair receipt: public validate reports borderline desktop readability with a supported fix', () => {
|
||||
const input = writeFixture('borderline-readability.architecture.json', {
|
||||
schema_version: 1,
|
||||
diagram_type: 'architecture',
|
||||
meta: {
|
||||
title: 'Borderline desktop readability',
|
||||
quality_profile: 'showcase',
|
||||
viewBox: [1826, 804],
|
||||
},
|
||||
components: [{
|
||||
id: 'tool-runtime',
|
||||
type: 'security',
|
||||
label: 'ToolRuntime',
|
||||
sublabel: 'OpenAI · Anthropic · provider gateways',
|
||||
pos: [100, 100],
|
||||
size: [194, 70],
|
||||
}],
|
||||
connections: [],
|
||||
});
|
||||
const result = run(['validate', 'architecture', input, '--quality', 'showcase', '--json']);
|
||||
|
||||
assert.equal(result.status, 1, result.stderr || result.stdout);
|
||||
assert.equal(result.stderr, '');
|
||||
const repair = receipt(result).diagnostics.find(
|
||||
(entry) => entry.code === 'composition/desktop-readability',
|
||||
);
|
||||
assert.ok(repair);
|
||||
assert.deepEqual(repair.subject, { check: 'composition' });
|
||||
assert.ok(repair.evidence.projectedFontPx < repair.evidence.minimumProjectedFontPx);
|
||||
assert.ok(repair.supportedFixes.some((fix) => fix.includes('reduce the viewBox width')));
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,217 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { startPreview } from '../bin/preview.mjs';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(here, '..');
|
||||
const cli = path.join(skillRoot, 'bin', 'archify.mjs');
|
||||
|
||||
function git(repo, ...args) {
|
||||
return execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-evidence-repo-'));
|
||||
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'src', 'router.js'), 'export function route(input) {\n return input.kind;\n}\n');
|
||||
fs.writeFileSync(path.join(root, 'src', 'store.js'), 'export const store = new Map();\n');
|
||||
git(root, 'init');
|
||||
git(root, 'config', 'user.name', 'Archify Tests');
|
||||
git(root, 'config', 'user.email', 'archify@example.test');
|
||||
git(root, 'remote', 'add', 'origin', 'git@github.com:example/evidence-repo.git');
|
||||
git(root, 'add', '.');
|
||||
git(root, 'commit', '-m', 'fixture');
|
||||
const revision = git(root, 'rev-parse', 'HEAD');
|
||||
|
||||
const diagram = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', 'web-app.architecture.json'), 'utf8'));
|
||||
diagram.meta.repository = {
|
||||
url: 'https://github.com/example/evidence-repo',
|
||||
revision,
|
||||
};
|
||||
diagram.components[0].sources = [
|
||||
{ path: 'src/router.js', line: 1, end_line: 3, label: 'Request router' },
|
||||
{ path: 'src/store.js', line: 1 },
|
||||
];
|
||||
const input = path.join(root, 'diagram.architecture.json');
|
||||
fs.writeFileSync(input, JSON.stringify(diagram, null, 2));
|
||||
return { root, revision, diagram, input };
|
||||
}
|
||||
|
||||
function run(args) {
|
||||
return spawnSync(process.execPath, [cli, ...args], {
|
||||
cwd: skillRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
function evidencePayload(html) {
|
||||
const match = html.match(/<script id="archify-source-evidence-data" type="application\/json">([\s\S]*?)<\/script>/);
|
||||
assert.ok(match, 'verified evidence payload missing');
|
||||
return JSON.parse(match[1]);
|
||||
}
|
||||
|
||||
test('repository evidence accepts canonical HTTPS and common SSH remotes', () => {
|
||||
const data = fixture();
|
||||
const output = path.join(data.root, 'remote-form.html');
|
||||
for (const remote of [
|
||||
'https://github.com/example/evidence-repo.git/',
|
||||
'git@github.com:example/evidence-repo.git',
|
||||
'ssh://git@github.com/example/evidence-repo.git',
|
||||
]) {
|
||||
git(data.root, 'remote', 'set-url', 'origin', remote);
|
||||
const result = run(['deliver', 'architecture', data.input, output, '--repo-root', data.root, '--json']);
|
||||
assert.equal(result.status, 0, `${remote}: ${result.stderr || result.stdout}`);
|
||||
}
|
||||
});
|
||||
|
||||
async function waitForState(url, predicate, timeoutMs = 12000) {
|
||||
const started = Date.now();
|
||||
let latest;
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
latest = await (await fetch(new URL('/state', url))).json();
|
||||
if (predicate(latest)) return latest;
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
assert.fail(`preview did not settle; latest state: ${JSON.stringify(latest)}`);
|
||||
}
|
||||
|
||||
test('repository evidence is revision-verified, receipt-backed, searchable, and export-clean', () => {
|
||||
const data = fixture();
|
||||
const output = path.join(data.root, 'verified.html');
|
||||
const result = run(['deliver', 'architecture', data.input, output, '--repo-root', data.root, '--json']);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
|
||||
const receipt = JSON.parse(result.stdout);
|
||||
assert.deepEqual(receipt.evidence, {
|
||||
verified: true,
|
||||
repository: 'https://github.com/example/evidence-repo',
|
||||
revision: data.revision,
|
||||
references: 2,
|
||||
});
|
||||
|
||||
const html = fs.readFileSync(output, 'utf8');
|
||||
const evidence = evidencePayload(html);
|
||||
assert.equal(evidence.verified, true);
|
||||
assert.equal(evidence.repository.shortRevision, data.revision.slice(0, 7));
|
||||
assert.equal(evidence.nodes.users.length, 2);
|
||||
assert.equal(evidence.nodes.users[0].href, `https://github.com/example/evidence-repo/blob/${data.revision}/src/router.js#L1-L3`);
|
||||
assert.match(html, /Verified source/);
|
||||
assert.match(html, /Archify\.sourceEvidence = \(function \(\)/);
|
||||
assert.match(html, /var sourceSearch = sources\.map/);
|
||||
assert.match(html, /renderSourceEvidence\(id\)/);
|
||||
assert.match(html, /referrerPolicy = 'no-referrer'/);
|
||||
assert.match(html, /classList\.add\('source-evidence-beacon'\)/);
|
||||
assert.match(html, /text\.textContent = viewerText\('viewer\.passport\.sourceMarker'\) \+ ' ' \+ count/);
|
||||
assert.match(html, /Archify\.sourceEvidence\.installBeacons\(\)/);
|
||||
assert.match(html, /querySelectorAll\('\[data-source-evidence-beacon\]'\)/);
|
||||
assert.match(html, /data-source-evidence-original-label/);
|
||||
|
||||
const svg = html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
assert.doesNotMatch(svg, /src\/router\.js|github\.com\/example\/evidence-repo|source-evidence/);
|
||||
});
|
||||
|
||||
test('repository evidence is opt-in and never appears in ordinary artifacts', () => {
|
||||
const output = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'archify-no-evidence-')), 'plain.html');
|
||||
const input = path.join(skillRoot, 'examples', 'web-app.architecture.json');
|
||||
const result = run(['render', 'architecture', input, output]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const html = fs.readFileSync(output, 'utf8');
|
||||
assert.doesNotMatch(html, /id="archify-source-evidence-data"/);
|
||||
assert.match(html, /id="focus-evidence" hidden/);
|
||||
const svg = html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
assert.doesNotMatch(svg, /source-evidence-beacon|data-source-evidence-count/);
|
||||
});
|
||||
|
||||
test('evidence fails closed without a root, on wrong origin, missing blobs, or impossible lines', () => {
|
||||
const data = fixture();
|
||||
const output = path.join(data.root, 'must-stay.html');
|
||||
fs.writeFileSync(output, 'trusted previous artifact');
|
||||
|
||||
let result = run(['deliver', 'architecture', data.input, output, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(JSON.parse(result.stdout).stage, 'render');
|
||||
assert.match(JSON.parse(result.stdout).error, /Pass --repo-root/);
|
||||
assert.equal(fs.readFileSync(output, 'utf8'), 'trusted previous artifact');
|
||||
|
||||
git(data.root, 'remote', 'set-url', 'origin', 'https://github.com/example/other-repo.git');
|
||||
result = run(['deliver', 'architecture', data.input, output, '--repo-root', data.root, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(JSON.parse(result.stdout).error, /does not match/);
|
||||
git(data.root, 'remote', 'set-url', 'origin', 'git@github.com:example/evidence-repo.git');
|
||||
|
||||
data.diagram.components[0].sources = [{ path: '../outside.js' }];
|
||||
fs.writeFileSync(data.input, JSON.stringify(data.diagram));
|
||||
result = run(['deliver', 'architecture', data.input, output, '--repo-root', data.root, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(JSON.parse(result.stdout).error, /must stay inside the repository/);
|
||||
|
||||
data.diagram.components[0].sources = [{ path: 'src/router.js\n' }];
|
||||
fs.writeFileSync(data.input, JSON.stringify(data.diagram));
|
||||
result = run(['deliver', 'architecture', data.input, output, '--repo-root', data.root, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(JSON.parse(result.stdout).error, /repo-relative POSIX path/);
|
||||
|
||||
data.diagram.components[0].sources = [{ path: 'src/missing.js' }];
|
||||
fs.writeFileSync(data.input, JSON.stringify(data.diagram));
|
||||
result = run(['deliver', 'architecture', data.input, output, '--repo-root', data.root, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(JSON.parse(result.stdout).error, /does not identify a file/);
|
||||
|
||||
data.diagram.components[0].sources = [{ path: 'src/router.js', line: 99 }];
|
||||
fs.writeFileSync(data.input, JSON.stringify(data.diagram));
|
||||
result = run(['deliver', 'architecture', data.input, output, '--repo-root', data.root, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(JSON.parse(result.stdout).error, /requests line 99/);
|
||||
|
||||
data.diagram.components[0].sources = [{ path: 'src/router.js', line: 4 }];
|
||||
fs.writeFileSync(data.input, JSON.stringify(data.diagram));
|
||||
result = run(['deliver', 'architecture', data.input, output, '--repo-root', data.root, '--json']);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(JSON.parse(result.stdout).error, /has 3 lines/);
|
||||
assert.equal(fs.readFileSync(output, 'utf8'), 'trusted previous artifact');
|
||||
});
|
||||
|
||||
test('--repo-root stays bounded to architecture and schema limits evidence shape', () => {
|
||||
const data = fixture();
|
||||
let result = run(['render', 'workflow', path.join(skillRoot, 'examples', 'agent-tool-call.workflow.json'), '--repo-root', data.root]);
|
||||
assert.equal(result.status, 2);
|
||||
assert.match(result.stderr, /architecture diagrams only/);
|
||||
|
||||
data.diagram.components[0].sources = [
|
||||
{ path: 'src/router.js' },
|
||||
{ path: 'src/router.js' },
|
||||
{ path: 'src/router.js' },
|
||||
{ path: 'src/router.js' },
|
||||
];
|
||||
fs.writeFileSync(data.input, JSON.stringify(data.diagram));
|
||||
result = run(['validate', 'architecture', data.input, '--repo-root', data.root]);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /must NOT have more than 3 items/);
|
||||
});
|
||||
|
||||
test('live preview forwards repo-root and publishes only verified evidence', { timeout: 20000 }, async () => {
|
||||
const data = fixture();
|
||||
const output = path.join(data.root, 'preview.html');
|
||||
const preview = await startPreview({
|
||||
type: 'architecture',
|
||||
input: data.input,
|
||||
output,
|
||||
repoRoot: data.root,
|
||||
open: false,
|
||||
debounceMs: 30,
|
||||
pollMs: 60,
|
||||
});
|
||||
try {
|
||||
const state = await waitForState(preview.url, (candidate) => candidate.status === 'verified');
|
||||
assert.equal(state.revision, 1);
|
||||
const html = await (await fetch(new URL('/artifact.html', preview.url))).text();
|
||||
assert.equal(evidencePayload(html).repository.revision, data.revision);
|
||||
} finally {
|
||||
await preview.stop();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
|
||||
function linguistGenerated(relativePath) {
|
||||
const output = execFileSync(
|
||||
'git',
|
||||
['check-attr', 'linguist-generated', '--', relativePath],
|
||||
{ cwd: repoRoot, encoding: 'utf8' },
|
||||
).trim();
|
||||
return output.slice(output.lastIndexOf(':') + 1).trim();
|
||||
}
|
||||
|
||||
test('repository language metadata separates generated artifacts from implementation source', () => {
|
||||
for (const generatedPath of [
|
||||
'archify/examples/web-app-rendered.html',
|
||||
'examples/web-app.html',
|
||||
'docs/cases/mco-runtime.architecture.html',
|
||||
'docs/gallery.html',
|
||||
'docs/gallery/artifacts/web-app.architecture.html',
|
||||
'docs/guide.html',
|
||||
'docs/start.html',
|
||||
'experiments/mco-showcase/mco-runtime.html',
|
||||
'archify/renderers/shared/generated-brand-marks.mjs',
|
||||
'archify/renderers/shared/generated-validators.mjs',
|
||||
]) {
|
||||
assert.equal(
|
||||
linguistGenerated(generatedPath),
|
||||
'true',
|
||||
`${generatedPath} must be excluded from GitHub language statistics`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const sourcePath of [
|
||||
'archify/assets/template.html',
|
||||
'scripts/gallery-template.html',
|
||||
'scripts/guide-template.html',
|
||||
'scripts/start-template.html',
|
||||
'docs/index.html',
|
||||
'archify/renderers/shared/geometry.mjs',
|
||||
]) {
|
||||
assert.equal(
|
||||
linguistGenerated(sourcePath),
|
||||
'unspecified',
|
||||
`${sourcePath} must remain visible as implementation source`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets', 'template.html'), 'utf8');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-route-journey-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const input = path.join(skillRoot, 'examples', example);
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
const result = spawnSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
input,
|
||||
output,
|
||||
], { encoding: 'utf8' });
|
||||
return { result, html: fs.existsSync(output) ? fs.readFileSync(output, 'utf8') : '' };
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all five renderers inherit native Route Journey controls outside canonical SVG', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const { result, html } = render(mode, example);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(html, /id="route-journey-controls" hidden role="group" aria-label="Route journey controls"/i, mode);
|
||||
assert.match(html, /id="route-journey-prev"[^>]+aria-label="Previous route position"/i, mode);
|
||||
assert.match(html, /id="route-journey-play"[^>]+aria-label="Play route journey"[^>]+aria-pressed="false"/i, mode);
|
||||
assert.match(html, /id="route-journey-next"[^>]+aria-label="Next route position"/i, mode);
|
||||
assert.match(html, /id="route-journey-overview"[^>]+aria-label="Show complete route overview"/i, mode);
|
||||
assert.match(html, /document\.createElement\(options\.interactive === true \? 'button' : 'span'\)/, mode);
|
||||
assert.doesNotMatch(canonicalSvg(html), /data-route-journey|route-journey-(?:flow|overlay)/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('a position owns its exact ordered incoming edge while the full route remains authored truth', () => {
|
||||
assert.match(template, /activeNodeIds = result\.nodes\.slice\(\)/);
|
||||
assert.match(template, /activeEdges = result\.edges\.slice\(\)/);
|
||||
assert.match(template, /activeEdges\.forEach\(function \(edge, step\) \{[\s\S]*?var destination = step \+ 1/);
|
||||
assert.match(template, /destination === journeyIndex \? 'current' : 'future'/);
|
||||
assert.match(template, /edge\.setAttribute\('data-route-journey-current', ''\)/);
|
||||
assert.match(template, /journeyIndex > 0\) renderJourneyPulse\(activeEdges\[journeyIndex - 1\]\)/);
|
||||
assert.match(template, /node\.setAttribute\('data-route-match', ''\)/);
|
||||
assert.match(template, /edge\.setAttribute\('data-route-match', ''\)/);
|
||||
assert.match(template, /svg\.setAttribute\('data-route-journey', \(journeyIndex \+ 1\) \+ '\/' \+ activeNodeIds\.length\)/);
|
||||
assert.doesNotMatch(template, /renderJourneyPulse\([\s\S]{0,120}querySelector\(.*data-edge-from/);
|
||||
});
|
||||
|
||||
test('route chips provide one roving tab stop, native activation, and manual ownership', () => {
|
||||
assert.match(template, /item\.setAttribute\('data-route-journey-index', String\(index\)\)/);
|
||||
assert.match(template, /item\.setAttribute\('tabindex', index === 0 \? '0' : '-1'\)/);
|
||||
assert.match(template, /item\.setAttribute\('aria-label', viewerText\('viewer\.route\.position'/);
|
||||
assert.match(template, /path\.addEventListener\('focusin'[\s\S]*?pauseJourney\(\{ preserveElapsed: true \}\)/);
|
||||
assert.match(template, /path\.addEventListener\('keydown'[\s\S]*?event\.key === 'ArrowRight'/);
|
||||
assert.match(template, /else if \(event\.key === 'Home'\) next = 0/);
|
||||
assert.match(template, /else if \(event\.key === 'End'\) next = activeNodeIds\.length - 1/);
|
||||
assert.match(template, /event\.key === 'Enter' \|\| event\.key === ' '/);
|
||||
assert.match(template, /selectJourneyIndex\(Number\(button\.getAttribute\('data-route-journey-index'\)\)\)/);
|
||||
assert.match(template, /button\.setAttribute\('aria-current', 'step'\)/);
|
||||
});
|
||||
|
||||
test('playback is explicit, finite, resumable, and never leaks position into the route URL', () => {
|
||||
assert.match(template, /var JOURNEY_DWELL_MS = 1100/);
|
||||
assert.match(template, /journeyGeneration \+= 1/);
|
||||
assert.match(template, /generation !== journeyGeneration \|\| !journeyPlaying/);
|
||||
assert.match(template, /JOURNEY_DWELL_MS - journeyElapsedMs/);
|
||||
assert.match(template, /preserveElapsed: options\.complete !== true/);
|
||||
assert.match(template, /journeyIndex >= activeNodeIds\.length - 1[\s\S]*?pauseJourney\(\{ complete: true/);
|
||||
assert.match(template, /applyJourneyState\(journeyIndex \+ 1[\s\S]*?journeyElapsedMs = 0;[\s\S]*?scheduleJourney\(\)/);
|
||||
assert.doesNotMatch(template, /journeyTimer\s*=\s*(?:window\.)?setInterval/);
|
||||
assert.match(template, /function playJourney\(\)[\s\S]*?if \(journeyIndex < 0\) applyJourneyState\(0/);
|
||||
assert.match(template, /function syncFromHash\(\)[\s\S]*?choose\(parts\[1\], \{ updateUrl: false \}\)/);
|
||||
assert.match(template, /'#route=' \+ encodeURIComponent\(startId\) \+ '~' \+ encodeURIComponent\(endId\)/);
|
||||
assert.doesNotMatch(template, /#route=[^'\n]*journey/);
|
||||
});
|
||||
|
||||
test('motion, camera, layered Escape, mobile, print, and embed boundaries stay explicit', () => {
|
||||
assert.match(template, /Archify\.motionGovernor\.capable === true[\s\S]*?!Archify\.motionGovernor\.isPaused\(\)/);
|
||||
assert.match(template, /Archify\.motionGovernor\.claim\('route'/);
|
||||
assert.match(template, /reason: 'route-journey',[\s\S]*?maxScale: 1\.65,[\s\S]*?padding: 64,[\s\S]*?duration: 360/);
|
||||
assert.match(template, /Archify\.routeProbe\.pauseJourney\(\{ preserveElapsed: true, reason: reason \|\| 'manual' \}\)/);
|
||||
assert.match(template, /event\.target\.closest\('\.diagram-nav, \.focus-chip, \.node-finder, \.diagram-guide, \.overview-map, \.route-probe, \.semantic-lens'\)/);
|
||||
assert.match(template, /reason: 'guide'/);
|
||||
assert.match(template, /window\.addEventListener\('beforeprint'[\s\S]*?pauseJourney/);
|
||||
assert.match(template, /function escapeRoute\(options\)[\s\S]*?return 'paused'[\s\S]*?return 'overview'[\s\S]*?return 'cleared'/);
|
||||
assert.match(template, /Archify\.routeProbe\.escape\(\{ restoreFocus: true \}\)/);
|
||||
assert.match(template, /\.route-probe\[data-route-dock="top"\] \{\s*top: 1rem;\s*bottom: auto;/);
|
||||
assert.match(template, /function updateDocking\(\) \{\s*if \(panel\.hidden\)/);
|
||||
assert.doesNotMatch(template, /if \(panel\.hidden \|\| window\.innerWidth > 720\)/);
|
||||
assert.match(template, /@media \(max-width: 720px\)[\s\S]*?\.route-probe-node \{ min-height: 2\.75rem !important; \}/);
|
||||
assert.match(template, /\.route-journey-controls button \{ min-height: 2\.75rem; \}/);
|
||||
assert.match(template, /@media print[\s\S]*?\.route-probe-overlay, \.route-journey-overlay \{ display: none !important; \}/);
|
||||
assert.match(template, /svg\[data-route-active\] \[data-node-id\],[\s\S]*?filter: none !important/);
|
||||
assert.match(template, /html\[data-embed="true"\][\s\S]*?\.route-probe/);
|
||||
assert.match(template, /html\[data-motion="still"\] \.route-journey-flow/);
|
||||
assert.match(template, /@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.route-journey-overlay/);
|
||||
});
|
||||
|
||||
test('standalone export strips every journey attribute and transient pulse', () => {
|
||||
assert.match(template, /clone\.removeAttribute\('data-route-journey'\)/);
|
||||
assert.match(template, /clone\.querySelectorAll\('\[data-route-journey-overlay\]'\)/);
|
||||
assert.match(template, /el\.removeAttribute\('data-route-journey-state'\)/);
|
||||
assert.match(template, /el\.removeAttribute\('data-route-journey-current'\)/);
|
||||
assert.match(template, /!clone\.hasAttribute\('data-route-journey'\)/);
|
||||
assert.match(template, /canonicalStateClean[\s\S]*?\[data-route-journey-overlay\][\s\S]*?\[data-route-journey-current\]/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,114 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-route-probe-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all typed renderers inherit one viewer-only Route Probe', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /id="route-probe" hidden role="region" aria-labelledby="route-probe-title"/, mode);
|
||||
assert.match(html, /id="btn-route-probe"[^>]+aria-label="Trace a directed route"[^>]+aria-pressed="false"[^>]+aria-controls="route-probe"/, mode);
|
||||
assert.match(html, /Archify\.routeProbe = \(function \(\)/, mode);
|
||||
assert.match(html, /Route Probe — shortest directed path over compiled semantics/, mode);
|
||||
assert.equal((html.match(/<svg\b/g) || []).length, 1, `${mode} keeps one static canonical SVG`);
|
||||
assert.doesNotMatch(canonicalSvg(html), /data-route-|route-probe-flow/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('Route Probe uses deterministic authored-direction BFS and exposes reachability first', () => {
|
||||
const html = render('workflow', CASES.workflow);
|
||||
assert.match(html, /function outgoingByNode\(\)/);
|
||||
assert.match(html, /var from = edge\.getAttribute\('data-edge-from'\)/);
|
||||
assert.match(html, /var to = edge\.getAttribute\('data-edge-to'\)/);
|
||||
assert.match(html, /if \(!byId\[from\] \|\| !byId\[to\] \|\| from === to\) return/);
|
||||
assert.match(html, /outgoing\[from\]\.push\(\{ edge: edge, to: to \}\)/);
|
||||
assert.match(html, /function reachableFrom\(source\)/);
|
||||
assert.match(html, /data-route-candidate/);
|
||||
assert.match(html, /function shortestDirectedPath\(source, target\)/);
|
||||
assert.match(html, /var queue = \[source\]/);
|
||||
assert.match(html, /previous\[link\.to\] = \{ from: queue\[cursor\], edge: link\.edge \}/);
|
||||
assert.match(html, /routeEdges\.unshift\(step\.edge\)/);
|
||||
assert.match(html, /nodeIds\.unshift\(step\.from\)/);
|
||||
});
|
||||
|
||||
test('Route Probe turns a two-node question into a readable route receipt and stable link', () => {
|
||||
const html = render('architecture', CASES.architecture);
|
||||
assert.match(html, /svg\.setAttribute\('data-route-picking', 'target'\)/);
|
||||
assert.match(html, /svg\.setAttribute\('data-route-active', startId \+ '~' \+ endId\)/);
|
||||
assert.match(html, /node\.setAttribute\('data-route-step', String\(step\)\)/);
|
||||
assert.match(html, /edge\.setAttribute\('data-route-match', ''\)/);
|
||||
assert.match(html, /clone\.setAttribute\('pathLength', '1'\)/);
|
||||
assert.match(html, /clone\.style\.setProperty\('--route-step', String\(step\)\)/);
|
||||
assert.match(html, /#route=' \+ encodeURIComponent\(startId\) \+ '~' \+ encodeURIComponent\(endId\)/);
|
||||
assert.match(html, /new URLSearchParams\(location\.hash\.replace/);
|
||||
assert.match(html, /Archify\.view\.reveal\(result\.nodes, \{ includeNeighbors: false, reason: 'route' \}\)/);
|
||||
assert.match(html, /shortest authored route/);
|
||||
});
|
||||
|
||||
test('Route Probe hands large-diagram endpoint selection to a reachability-aware Finder', () => {
|
||||
const html = render('dataflow', CASES.dataflow);
|
||||
assert.match(html, /id="route-probe-find"[^>]+aria-label="Find a route start"[^>]+data-node-finder-trigger/);
|
||||
assert.match(html, /function hopDistancesFrom\(source\)/);
|
||||
assert.match(html, /kind: 'route-source'/);
|
||||
assert.match(html, /outgoing\[id\] && outgoing\[id\]\.length/);
|
||||
assert.match(html, /kind: 'route-target'/);
|
||||
assert.match(html, /Object\.keys\(distances\)\.filter/);
|
||||
assert.match(html, /targetBadges\[id\] = viewerCount\('viewer\.route\.hop', distances\[id\]\)/);
|
||||
assert.match(html, /Archify\.finder\.open\(\{ context: context \}\)/);
|
||||
assert.match(html, /findBtn\.textContent = viewerText\('viewer\.route\.destination\.find'\)/);
|
||||
assert.match(html, /panel\.setAttribute\('data-finder-open', 'true'\)/);
|
||||
assert.match(html, /\.route-probe\[data-finder-open="true"\]/);
|
||||
});
|
||||
|
||||
test('Route Probe keeps pointer, keyboard, motion, embed, and export boundaries explicit', () => {
|
||||
const html = render('sequence', CASES.sequence);
|
||||
assert.match(html, /svg\.addEventListener\('click', interceptSelection, true\)/);
|
||||
assert.match(html, /svg\.addEventListener\('keydown', interceptSelection, true\)/);
|
||||
assert.match(html, /event\.key !== 'Enter' && event\.key !== ' '/);
|
||||
assert.match(html, /e\.key === 'r' \|\| e\.key === 'R'/);
|
||||
assert.match(html, /e\.key === 'Escape' && Archify\.routeProbe\.active\(\)/);
|
||||
assert.match(html, /html\[data-embed="true"\] \.route-probe/);
|
||||
assert.match(html, /html\.getAttribute\('data-embed'\) === 'true'/);
|
||||
assert.match(html, /\.route-probe\[data-route-dock="top"\]/);
|
||||
assert.match(html, /function overlapArea\(a, b\)/);
|
||||
assert.match(html, /score\(topCandidate\) <= score\(bottomCandidate\)/);
|
||||
assert.match(html, /container\.addEventListener\('scroll', updateDocking, \{ passive: true \}\)/);
|
||||
assert.match(html, /@keyframes archify-route-probe-flow/);
|
||||
assert.match(html, /@media \(prefers-reduced-motion: reduce\)[\s\S]+\.route-probe-flow \{[\s\S]+animation: none !important/);
|
||||
assert.match(html, /clone\.removeAttribute\('data-route-picking'\)/);
|
||||
assert.match(html, /clone\.removeAttribute\('data-route-active'\)/);
|
||||
assert.match(html, /clone\.querySelectorAll\('\[data-route-probe-overlay\]'\)/);
|
||||
assert.match(html, /!clone\.hasAttribute\('data-route-active'\)/);
|
||||
assert.doesNotMatch(canonicalSvg(html), /data-route-|route-probe-flow/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,163 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(skillRoot, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-route-share-card-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all five renderers inherit one resolved-only Route Share Card Export item', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /data-action="route-share-card"[^>]*hidden disabled[^>]*>[\s\S]*?Route Share Card[\s\S]*?1200(?:×|×)630 PNG/, mode);
|
||||
assert.match(html, /function syncRouteShareItem\(\)/, mode);
|
||||
assert.match(html, /routeShareItem\.hidden = !snapshot;/, mode);
|
||||
assert.match(html, /routeShareItem\.disabled = !snapshot;/, mode);
|
||||
assert.match(html, /\.toolbar \.export-menu button\[hidden\] \{ display: none; \}/, mode);
|
||||
assert.doesNotMatch(html, /id="route-probe-share"|class="route-probe-share"/, mode);
|
||||
assert.doesNotMatch(canonicalSvg(html), /data-share-route(?:-|=)/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('Route Share Card menu lifecycle excludes hidden state from keyboard navigation', () => {
|
||||
const html = render('workflow', CASES.workflow);
|
||||
assert.match(html, /function open\(focusLast\)[\s\S]*?syncRouteShareItem\(\);/);
|
||||
assert.match(html, /items\(\)\.filter\(function \(i\) \{ return !i\.hidden && !i\.disabled; \}\)/);
|
||||
assert.match(html, /function clear\(options\)[\s\S]*?Archify\.exportMenu\.syncRouteShare\(\)/);
|
||||
assert.match(html, /function showResult\(result, options\)[\s\S]*?Archify\.exportMenu\.syncRouteShare\(\)/);
|
||||
assert.match(html, /html\[data-embed="true"\] \.toolbar/);
|
||||
assert.match(html, /@media print[\s\S]*?\.toolbar/);
|
||||
});
|
||||
|
||||
test('Route Share Card snapshots copy exact resolved node and relationship identity without rerouting', () => {
|
||||
const html = render('architecture', CASES.architecture);
|
||||
const snapshotBlock = html.match(/function exportSnapshot\(\) \{[\s\S]*?\n \}/)?.[0] || '';
|
||||
const geometryBlock = html.match(/function hasDrawableGeometry\(element\) \{[\s\S]*?\n \}/)?.[0] || '';
|
||||
assert.match(snapshotBlock, /nodeIds: activeNodeIds\.slice\(\)/);
|
||||
assert.match(snapshotBlock, /hops: activeEdges\.length/);
|
||||
assert.match(snapshotBlock, /edges: activeEdges\.map\(function \(edge\) \{/);
|
||||
assert.match(snapshotBlock, /key: edge\.getAttribute\('data-edge-key'\)/);
|
||||
assert.match(snapshotBlock, /seenNodeIds = Object\.create\(null\)/);
|
||||
assert.match(snapshotBlock, /seenEdgeKeys = Object\.create\(null\)/);
|
||||
assert.match(snapshotBlock, /fragment\.getAttribute\('data-edge-from'\) === activeNodeIds\[index\]/);
|
||||
assert.match(snapshotBlock, /drawableFragments = fragments\.filter\(hasDrawableGeometry\)/);
|
||||
assert.match(snapshotBlock, /drawableFragments\.length !== 1/);
|
||||
assert.match(snapshotBlock, /drawableFragments\[0\] !== edge/);
|
||||
assert.match(geometryBlock, /geometry\.getTotalLength/);
|
||||
assert.match(geometryBlock, /Number\.isFinite\(length\) && length > 0/);
|
||||
assert.match(geometryBlock, /nan\|infinity/i);
|
||||
assert.match(html, /exportSnapshot: exportSnapshot/);
|
||||
assert.doesNotMatch(snapshotBlock, /shortestDirectedPath|outgoingByNode|reachableFrom|labelAt|nearest/i);
|
||||
});
|
||||
|
||||
test('Route Share Card snapshot fails closed when the resolved DOM becomes stale or conflicting', () => {
|
||||
const html = render('workflow', CASES.workflow);
|
||||
const snapshotBlock = html.match(/function exportSnapshot\(\) \{[\s\S]*?\n \}/)?.[0] || '';
|
||||
assert.match(snapshotBlock, /activeNodeIds\.length < 2/);
|
||||
assert.match(snapshotBlock, /activeEdges\.length !== activeNodeIds\.length - 1/);
|
||||
assert.match(snapshotBlock, /allNodes\.filter\(function \(node\)/);
|
||||
assert.match(snapshotBlock, /!edge \|\| !svg\.contains\(edge\)/);
|
||||
assert.match(snapshotBlock, /!edgeKey \|\| seenEdgeKeys\[edgeKey\]/);
|
||||
assert.match(snapshotBlock, /!fragments\.every/);
|
||||
assert.match(snapshotBlock, /return null/);
|
||||
});
|
||||
|
||||
test('Route variant decorates only a finite canonical clone with dedicated static attributes', () => {
|
||||
const html = render('architecture', CASES.architecture);
|
||||
const applyBlock = html.match(/function applyRouteSnapshot\(clone, snapshot\) \{[\s\S]*?\n \}/)?.[0] || '';
|
||||
assert.match(applyBlock, /snapshot\.edges\.length !== snapshot\.nodeIds\.length - 1/);
|
||||
assert.match(applyBlock, /snapshot\.hops !== snapshot\.edges\.length/);
|
||||
assert.match(applyBlock, /matchedNodes\.length !== 1/);
|
||||
assert.match(applyBlock, /matchedEdges\.every/);
|
||||
assert.match(applyBlock, /drawableMatches = matchedEdges\.filter\(hasDrawableGeometry\)/);
|
||||
assert.match(applyBlock, /drawableMatches\.length !== 1/);
|
||||
assert.match(applyBlock, /data-share-route-match/);
|
||||
assert.match(applyBlock, /data-share-route-start/);
|
||||
assert.match(applyBlock, /data-share-route-middle/);
|
||||
assert.match(applyBlock, /data-share-route-end/);
|
||||
assert.match(applyBlock, /clone\.removeAttribute\('data-animation'\)/);
|
||||
assert.doesNotMatch(applyBlock, /setAttribute\('data-route-(?:match|step|start|end|active|journey)/);
|
||||
assert.match(html, /canonicalStateClean && finiteSvgDimensions && applyRouteSnapshot\(clone, opts\.routeSnapshot\)/);
|
||||
assert.match(html, /Number\.isFinite\(vb\.width\)[\s\S]*?vb\.width > 0 && vb\.height > 0/);
|
||||
assert.ok(html.indexOf('var canonicalStateClean =') < html.indexOf('applyRouteSnapshot(clone, opts.routeSnapshot)'), 'canonical cleanup must precede route decoration');
|
||||
});
|
||||
|
||||
test('clone-only Route styling retains context and distinguishes start, middle, and end without motion', () => {
|
||||
const html = render('dataflow', CASES.dataflow);
|
||||
assert.match(html, /svg\[data-share-route\] \[data-node-id\], svg\[data-share-route\] \[data-edge-from\] \{ opacity: 0\.18; \}/);
|
||||
assert.match(html, /svg\[data-share-route\] \[data-share-route-match\] \{ opacity: 1; \}/);
|
||||
assert.match(html, /data-share-route-start[\s\S]*?stroke-dasharray: 5 3/);
|
||||
assert.match(html, /data-share-route-middle[\s\S]*?stroke-width: 2\.2/);
|
||||
assert.match(html, /data-share-route-end[\s\S]*?stroke-width: 3\.4/);
|
||||
assert.doesNotMatch(html.match(/if \(opts\.routeSnapshot\) \{[\s\S]*?\n \}/)?.[0] || '', /display:\s*none|animation:|filter:|transform:/);
|
||||
});
|
||||
|
||||
test('Route Share Card reuses one 1200x630 variant seam and publishes a truthful receipt', () => {
|
||||
const html = render('architecture', CASES.architecture);
|
||||
assert.match(html, /function rasterizeShareCard\(options\)/);
|
||||
assert.match(html, /options\.variant !== 'route'/);
|
||||
assert.match(html, /var snapshot = Archify\.routeProbe && Archify\.routeProbe\.exportSnapshot\(\)/);
|
||||
assert.match(html, /renderShareCard\(\{ routeSnapshot: snapshot \}\)/);
|
||||
assert.doesNotMatch(html, /function rasterizeRouteShareCard|routeShareCard:/);
|
||||
assert.match(html, /var title = titleNode \? titleNode\.textContent : document\.title;/);
|
||||
assert.match(html, /viewerCount\('viewer\.export\.card\.routeSummary', routeSnapshot\.hops/);
|
||||
assert.match(html, /source: routeSnapshot\.source\.label/);
|
||||
assert.match(html, /target: routeSnapshot\.target\.label/);
|
||||
assert.match(html, /recordExportReceipt\('share-card', blob, false, \{ width: SHARE_CARD_WIDTH, height: SHARE_CARD_HEIGHT \}, 'route', true\)/);
|
||||
assert.match(html, /diagramFilename\(\) \+ '-route-share-card\.png'/);
|
||||
assert.match(html, /data-last-export-variant/);
|
||||
assert.match(html, /data-last-export-route-state-clean/);
|
||||
assert.match(html, /clearExportReceipt\(\);[\s\S]*?var snapshot = Archify\.routeProbe/);
|
||||
assert.match(html, /function runExport\(format\)[\s\S]*?clearExportReceipt\(\);/);
|
||||
assert.match(html, /var ctx = canvas2dOrThrow\(canvas, viewerText\('viewer\.export\.shareCard'\)\)/);
|
||||
});
|
||||
|
||||
test('skill and READMEs describe the optional Export variant and show one real card without changing the hero', () => {
|
||||
const viewer = fs.readFileSync(path.join(skillRoot, 'references', 'viewer-runtime.md'), 'utf8');
|
||||
assert.match(viewer, /Export → Route Share Card/);
|
||||
assert.match(viewer, /format=share-card/);
|
||||
assert.match(viewer, /variant=route/);
|
||||
assert.match(viewer, /data-share-route-\*/);
|
||||
assert.match(viewer, /download-only/i);
|
||||
|
||||
for (const readme of ['README.md', 'README_EN.md', 'README_ZH.md']) {
|
||||
const text = fs.readFileSync(path.join(repoRoot, readme), 'utf8');
|
||||
assert.match(text, /Export → Route Share Card/, readme);
|
||||
assert.match(text, /docs\/assets\/archify-route-share-card\.png/, readme);
|
||||
}
|
||||
|
||||
const png = fs.readFileSync(path.join(repoRoot, 'docs/assets/archify-route-share-card.png'));
|
||||
assert.equal(png.subarray(0, 8).toString('hex'), '89504e470d0a1a0a');
|
||||
assert.equal(png.readUInt32BE(16), 1200);
|
||||
assert.equal(png.readUInt32BE(20), 630);
|
||||
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-semantic-camera-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function svg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all typed renderers ship the same geometry-neutral semantic camera', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /function frameDesktop\(ids, options\)/, mode);
|
||||
assert.match(html, /function semanticIds\(ids, includeNeighbors\)/, mode);
|
||||
assert.match(html, /if \(seeds\[from\] \|\| seeds\[to\]\) \{ wanted\[from\] = true; wanted\[to\] = true; \}/, mode);
|
||||
assert.match(html, /contentScale = Math\.min\(svgWidth \/ viewBox\.width, svgHeight \/ viewBox\.height\)/, mode);
|
||||
assert.match(html, /targetScale = Math\.max\(1, Math\.min\(maxScale, targetScale\)\)/, mode);
|
||||
assert.match(html, /visibleTop = Math\.max\(0, -containerRect\.top\)/, mode);
|
||||
assert.match(html, /visibleBottom - visibleTop >= 240/, mode);
|
||||
assert.match(html, /data-camera-mode/, mode);
|
||||
assert.match(html, /data-camera-indicator/, mode);
|
||||
assert.match(html, /var resolvedLevel = semantic \? viewerText\('viewer\.nav\.level\.auto'\) : levelLabel/, mode);
|
||||
assert.match(html, /is-camera-moving/, mode);
|
||||
assert.match(html, /cubic-bezier\(0\.22, 1, 0\.36, 1\)/, mode);
|
||||
assert.doesNotMatch(svg(html), /data-camera-mode|is-camera-moving|AUTO /, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('semantic camera follows reader intent but yields to manual navigation', () => {
|
||||
const html = render('workflow', CASES.workflow);
|
||||
assert.match(html, /beginHandoff\(previousIndex, index, previous, view, outgoingBeatIndex, options\.playback === true \? 'playback' : 'guided'\)/);
|
||||
assert.match(html, /reveal\(\[id\], \{ includeNeighbors: true, reason: 'focus' \}\)/);
|
||||
assert.match(html, /reveal\(\[id\], \{ includeNeighbors: true, reason: 'relationship' \}\)/);
|
||||
assert.match(html, /reveal\(\[id\], \{ includeNeighbors: true, reason: 'finder' \}\)/);
|
||||
assert.match(html, /function interruptCamera\(reason\)/);
|
||||
assert.match(html, /Archify\.guidedViews\.pause\(\)/);
|
||||
assert.match(html, /container\.addEventListener\('pointerdown',[\s\S]+interruptCamera\(\)/);
|
||||
assert.match(html, /\.overview-map, \.route-probe, \.semantic-lens/);
|
||||
assert.match(html, /window\.innerWidth <= 720 && container\.hasAttribute\('data-wide-diagram'\) && Date\.now\(\) > autoScrollUntil/);
|
||||
assert.match(html, /reset\(\{ automatic: true \}\)/);
|
||||
assert.match(html, /routeReceipt\.hasAttribute\('data-route-journey'\)/);
|
||||
assert.match(html, /receiptBottom \+ 24/);
|
||||
});
|
||||
|
||||
test('semantic camera keeps mobile on its contained scroll model and respects reduced motion', () => {
|
||||
const html = render('sequence', CASES.sequence);
|
||||
assert.match(html, /if \(window\.innerWidth > 720\) return frameDesktop\(ids, options\)/);
|
||||
assert.match(html, /if \(!container\.hasAttribute\('data-wide-diagram'\)\) \{[\s\S]+cameraReceipt\(\{ scale: 1, x: 0, y: 0, mode: 'semantic' \}/);
|
||||
assert.match(html, /state\.scale = 1;[\s\S]+state\.x = 0;[\s\S]+state\.y = 0;[\s\S]+state\.mode = 'semantic';[\s\S]+apply\(\)/);
|
||||
assert.match(html, /autoScrollUntil = Date\.now\(\) \+ \(instant \? 50 : 470\)/);
|
||||
assert.match(html, /behavior: instant \? 'auto' : 'smooth'/);
|
||||
assert.match(html, /svg \[data-node-id\], svg \[data-edge-from\], svg \[data-detail\], svg \[data-detail-anchor\], svg \[data-legend-hit\], svg \{ transition: none !important; \}/);
|
||||
});
|
||||
|
||||
test('semantic camera remains outside canonical SVG export state', () => {
|
||||
const html = render('architecture', CASES.architecture);
|
||||
assert.match(html, /clone\.style\.removeProperty\('transform'\)/);
|
||||
assert.match(html, /clone\.removeAttribute\('data-view-scale'\)/);
|
||||
assert.match(html, /!clone\.style\.getPropertyValue\('transform'\)/);
|
||||
assert.doesNotMatch(svg(html), /style="[^"]*transform|data-view-scale|data-camera-mode/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,90 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-semantic-flow-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all typed renderers inherit one selection-triggered Semantic Flow signal', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /function renderFlowOverlay\(entries\)/, mode);
|
||||
assert.match(html, /var MAX_LENS_FLOW_EDGES = 24/, mode);
|
||||
assert.match(html, /setAttribute\('class', 'semantic-lens-flow'\)/, mode);
|
||||
assert.match(html, /data-semantic-lens-overlay/, mode);
|
||||
assert.doesNotMatch(canonicalSvg(html), /semantic-lens-flow|semantic-lens-overlay|data-lens-flow/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('Semantic Flow clones only exact matched authored geometry and preserves direction', () => {
|
||||
const html = render('architecture', CASES.architecture);
|
||||
assert.match(html, /var matchedFlow = \[\]/);
|
||||
assert.match(html, /direction = fromKind === selectedKinds\[0\] \? 'forward' : 'reverse'/);
|
||||
assert.match(html, /fromKind === selectedKinds\[0\] && toKind !== selectedKinds\[0\] \? 'out'/);
|
||||
assert.match(html, /toKind === selectedKinds\[0\] && fromKind !== selectedKinds\[0\] \? 'in'/);
|
||||
assert.match(html, /matchedFlow\.push\(\{ edge: edge, direction: direction \}\)/);
|
||||
assert.match(html, /clone\.removeAttribute\('marker-end'\)/);
|
||||
assert.match(html, /clone\.removeAttribute\('data-edge-key'\)/);
|
||||
assert.match(html, /clone\.setAttribute\('pathLength', '1'\)/);
|
||||
assert.match(html, /wrapper\.setAttribute\('transform', entry\.edge\.members\[0\]\.getAttribute\('transform'\)\)/);
|
||||
assert.match(html, /svg\.setAttribute\('data-lens-flow-count', String\(entries\.length\)\)/);
|
||||
assert.match(html, /svg\.insertBefore\(overlay, firstNode\)/);
|
||||
});
|
||||
|
||||
test('Semantic Flow has preset identities and motion-safe density boundaries', () => {
|
||||
const html = render('workflow', CASES.workflow);
|
||||
assert.match(html, /\.semantic-lens-flow\[data-direction="out"\],[\s\S]+var\(--frontend-stroke\)/);
|
||||
assert.match(html, /\.semantic-lens-flow\[data-direction="in"\],[\s\S]+var\(--database-stroke\)/);
|
||||
assert.match(html, /\.semantic-lens-flow\[data-direction="within"\][\s\S]+var\(--messagebus-stroke\)/);
|
||||
assert.match(html, /svg\[data-preset="signal-flow"\] \.semantic-lens-flow/);
|
||||
assert.match(html, /svg\[data-preset="blueprint"\] \.semantic-lens-flow/);
|
||||
assert.match(html, /@keyframes archify-semantic-lens-flow/);
|
||||
assert.match(html, /animation: archify-semantic-lens-flow 1\.35s linear 1 both/);
|
||||
assert.match(html, /entries\.length > MAX_LENS_FLOW_EDGES/);
|
||||
assert.match(html, /data-lens-flow-density', 'quiet'/);
|
||||
assert.match(html, /html\[data-embed="true"\] \.semantic-lens-overlay/);
|
||||
assert.match(html, /@media print \{[\s\S]+\.semantic-lens-overlay \{ display: none !important; \}/);
|
||||
assert.match(html, /@media \(prefers-reduced-motion: reduce\)[\s\S]+\.semantic-lens-flow \{[\s\S]+animation: none !important/);
|
||||
});
|
||||
|
||||
test('Semantic Flow cleanup and exports remain canonical', () => {
|
||||
const html = render('dataflow', CASES.dataflow);
|
||||
assert.match(html, /function removeFlowOverlay\(\)/);
|
||||
assert.match(html, /svg\.querySelectorAll\('\[data-semantic-lens-overlay\]'\)/);
|
||||
assert.match(html, /svg\.removeAttribute\('data-lens-flow-count'\)/);
|
||||
assert.match(html, /svg\.removeAttribute\('data-lens-flow-density'\)/);
|
||||
assert.match(html, /clone\.removeAttribute\('data-lens-flow-count'\)/);
|
||||
assert.match(html, /clone\.removeAttribute\('data-lens-flow-density'\)/);
|
||||
assert.match(html, /clone\.querySelectorAll\('\[data-semantic-lens-overlay\]'\)/);
|
||||
assert.match(html, /\[data-semantic-lens-overlay\],[^']*\[data-lens-match\]/);
|
||||
assert.doesNotMatch(canonicalSvg(html), /semantic-lens-flow|semantic-lens-overlay|data-lens-flow/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,156 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-semantic-legend-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, mutate) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
let input = path.join(skillRoot, 'examples', CASES[mode]);
|
||||
if (mutate) {
|
||||
const document = JSON.parse(fs.readFileSync(input, 'utf8'));
|
||||
mutate(document);
|
||||
input = path.join(tmp, `${mode}.json`);
|
||||
fs.writeFileSync(input, JSON.stringify(document));
|
||||
}
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
input,
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
function values(svg, attribute) {
|
||||
const pattern = new RegExp(`${attribute}="([^"]+)"`, 'g');
|
||||
return [...svg.matchAll(pattern)].map((match) => match[1]);
|
||||
}
|
||||
|
||||
test('only legends with an exact node-kind meaning publish bridge entries', () => {
|
||||
const architecture = canonicalSvg(render('architecture'));
|
||||
const architectureKinds = new Set(values(architecture, 'data-node-kind'));
|
||||
assert.deepEqual(new Set(values(architecture, 'data-legend-kind')), architectureKinds);
|
||||
assert.equal((architecture.match(/data-legend-bridge=""/g) || []).length, 1);
|
||||
|
||||
const workflow = canonicalSvg(render('workflow', (document) => {
|
||||
// The production fixture intentionally fills its legend band with authored
|
||||
// routes, so legacy implicit-auto correctly hides that legend. Keep this
|
||||
// semantic bridge contract focused by rendering the same typed nodes with
|
||||
// an explicit full legend and no relationship geometry in the band.
|
||||
document.meta.legend = { mode: 'all' };
|
||||
delete document.meta.viewBox;
|
||||
document.edges = [];
|
||||
delete document.mainPath;
|
||||
}));
|
||||
assert.deepEqual(values(workflow, 'data-legend-kind'), [
|
||||
'frontend', 'backend', 'security', 'messagebus', 'database', 'cloud', 'external',
|
||||
]);
|
||||
|
||||
const lifecycle = canonicalSvg(render('lifecycle'));
|
||||
assert.deepEqual(values(lifecycle, 'data-legend-kind'), [
|
||||
'start', 'active', 'waiting', 'decision', 'success', 'failure',
|
||||
]);
|
||||
|
||||
const sequence = canonicalSvg(render('sequence'));
|
||||
assert.deepEqual(values(sequence, 'data-legend-semantic-kind'), [
|
||||
'emphasis', 'return', 'security', 'dashed', 'default',
|
||||
]);
|
||||
assert.doesNotMatch(sequence, /data-legend-bridge|data-legend-kind=/);
|
||||
assert.match(sequence, />Legend</);
|
||||
|
||||
const dataflow = canonicalSvg(render('dataflow'));
|
||||
assert.deepEqual(values(dataflow, 'data-legend-semantic-kind'), [
|
||||
'emphasis', 'security', 'dashed', 'database', 'default',
|
||||
]);
|
||||
assert.deepEqual(values(dataflow, 'data-legend-kind'), ['database']);
|
||||
assert.equal((dataflow.match(/data-legend-bridge=""/g) || []).length, 1);
|
||||
assert.ok(values(dataflow, 'data-node-kind').includes('database'));
|
||||
assert.match(dataflow, />Legend</);
|
||||
});
|
||||
|
||||
test('runtime decoration derives counts from compiled node facts and stays viewer-only', () => {
|
||||
const html = render('architecture');
|
||||
const svg = canonicalSvg(html);
|
||||
assert.match(html, /collectKinds\(\)\.forEach\(function \(kind\) \{ facts\[kind\.id\] = kind; \}\)/);
|
||||
assert.match(html, /var count = fact \? fact\.nodes\.length : 0/);
|
||||
assert.match(html, /data-legend-bridge-runtime/);
|
||||
assert.match(html, /data-legend-count-badge/);
|
||||
assert.match(html, /entry\.setAttribute\('role', 'button'\)/);
|
||||
assert.match(html, /legendBridge\.setAttribute\('role', legendEntries\.length >= 3 \? 'toolbar' : 'group'\)/);
|
||||
assert.match(html, /var visibleLabel = entry\.getAttribute\('data-legend-label'\) \|\| fact\.label/);
|
||||
assert.match(html, /entry\.setAttribute\('aria-label', viewerCount\('viewer\.lens\.legend\.inspect', count/);
|
||||
assert.match(html, /if \(!legendBridge \|\| html\.getAttribute\('data-embed'\) === 'true'\) return false/);
|
||||
assert.doesNotMatch(svg, /data-legend-bridge-runtime|data-legend-count=|role="toolbar"/);
|
||||
assert.doesNotMatch(svg, /data-legend-kind="[^"]+"[^>]+(?:role=|aria-pressed=)/);
|
||||
});
|
||||
|
||||
test('preview is soft, input-aware, and yields to stronger exploration owners', () => {
|
||||
const html = render('workflow');
|
||||
const preview = html.slice(
|
||||
html.indexOf('function previewLegendKind'),
|
||||
html.indexOf('function syncLegendPreview'),
|
||||
);
|
||||
assert.match(html, /window\.matchMedia\('\(hover: hover\) and \(pointer: fine\)'\)/);
|
||||
assert.match(html, /event\.pointerType === 'touch' \|\| \(finePointerQuery && !finePointerQuery\.matches\)/);
|
||||
assert.match(html, /legendBridge\.addEventListener\('focusin'/);
|
||||
assert.match(html, /legendBridge\.addEventListener\('focusout'/);
|
||||
assert.match(preview, /data-legend-preview-match/);
|
||||
assert.match(preview, /data-legend-preview-peer/);
|
||||
assert.doesNotMatch(preview, /renderFlowOverlay|data-semantic-lens-overlay/);
|
||||
assert.match(html, /selectedKinds\.length > 0 \|\| !panel\.hidden \|\| html\.getAttribute\('data-present'\) === 'true'/);
|
||||
assert.match(html, /data-focus-active.*data-intent-trace-active/s);
|
||||
assert.match(html, /data-route-picking.*data-story-active.*data-relationship-preview-active/s);
|
||||
assert.match(html, /svg\[data-legend-preview-active\] \[data-node-id\]/);
|
||||
});
|
||||
|
||||
test('activation delegates to Semantic Lens and supports roving keyboard navigation', () => {
|
||||
const html = render('lifecycle');
|
||||
const activation = html.slice(
|
||||
html.indexOf('function activateLegendEntry'),
|
||||
html.indexOf('function removeFlowOverlay'),
|
||||
);
|
||||
assert.match(activation, /select\(entry\.getAttribute\('data-legend-kind'\)\)/);
|
||||
assert.match(activation, /open\(\{ opener: entry \}\)/);
|
||||
assert.match(html, /event\.key === 'Enter' \|\| event\.key === ' '/);
|
||||
assert.match(html, /event\.key === 'ArrowRight'/);
|
||||
assert.match(html, /event\.key === 'ArrowLeft'/);
|
||||
assert.match(html, /event\.key === 'Home'/);
|
||||
assert.match(html, /event\.key === 'End'/);
|
||||
assert.match(html, /lensOpener\.focus\(\)/);
|
||||
assert.match(html, /entry\.setAttribute\('aria-pressed', selected \? 'true' : 'false'\)/);
|
||||
});
|
||||
|
||||
test('bridge state is print-safe, reduced-motion-safe, and absent from canonical export', () => {
|
||||
const html = render('architecture');
|
||||
assert.match(html, /\[data-legend-bridge-runtime\] \{ display: none !important; \}/);
|
||||
assert.match(html, /html:not\(\[data-embed="true"\]\) \.diagram-container \{\s*padding: 0\.75rem 0\.75rem 4\.25rem;/);
|
||||
assert.match(html, /data-legend-hit[^}]*transition/s);
|
||||
assert.match(html, /prefers-reduced-motion: reduce[\s\S]*\[data-legend-hit\]/);
|
||||
assert.match(html, /clone\.removeAttribute\('data-legend-preview-active'\)/);
|
||||
assert.match(html, /clone\.querySelectorAll\('\[data-legend-bridge-runtime\]'\)/);
|
||||
assert.match(html, /el\.removeAttribute\('data-legend-kind'\)/);
|
||||
assert.match(html, /el\.removeAttribute\('data-legend-label'\)/);
|
||||
assert.match(html, /el\.removeAttribute\('data-legend-bridge'\)/);
|
||||
assert.match(html, /\[data-legend-preview-match\], \[data-legend-preview-selected\], \[data-legend-preview-peer\]/);
|
||||
assert.match(html, /\[data-legend-bridge\], \[data-legend-kind\], \[data-legend-bridge-runtime\]/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,103 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-semantic-lens-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all typed renderers inherit one viewer-only Semantic Lens', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /id="semantic-lens" hidden role="dialog" aria-modal="false" aria-labelledby="semantic-lens-title"/, mode);
|
||||
assert.match(html, /id="btn-semantic-lens"[^>]+aria-label="Open semantic lens"[^>]+aria-expanded="false"[^>]+aria-controls="semantic-lens"/, mode);
|
||||
assert.match(html, /Archify\.semanticLens = \(function \(\)/, mode);
|
||||
assert.match(html, /svg\.querySelectorAll\('\[data-node-id\]\[data-node-kind\]'\)/, mode);
|
||||
assert.doesNotMatch(canonicalSvg(html), /semantic-lens-overlay|data-lens-active|data-lens-match/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('Semantic Lens derives honest kind counts and compares at most two roles', () => {
|
||||
const html = render('workflow', CASES.workflow);
|
||||
assert.match(html, /function collectKinds\(\)/);
|
||||
assert.match(html, /kind\.nodes\.push\(node\)/);
|
||||
assert.match(html, /Choose up to two semantic kinds/);
|
||||
assert.match(html, /if \(selectedKinds\.length >= 2\) return false/);
|
||||
assert.match(html, /var crossKind = selectedKinds\.length === 2/);
|
||||
assert.match(html, /fromKind === selectedKinds\[0\] && toKind === selectedKinds\[1\]/);
|
||||
assert.match(html, /fromKind === selectedKinds\[1\] && toKind === selectedKinds\[0\]/);
|
||||
assert.match(html, /direct relationship/);
|
||||
assert.match(html, /data-lens-peer/);
|
||||
assert.match(html, /data-lens-selected/);
|
||||
});
|
||||
|
||||
test('Semantic Lens is shareable and yields cleanly to stronger reader intent', () => {
|
||||
const html = render('architecture', CASES.architecture);
|
||||
assert.match(html, /#lens=/);
|
||||
assert.match(html, /params\.get\('lens'\)/);
|
||||
assert.match(html, /window\.addEventListener\('hashchange', syncFromHash\)/);
|
||||
assert.match(html, /event\.composedPath\(\)/);
|
||||
assert.match(html, /eventPath\.indexOf\(panel\) >= 0/);
|
||||
assert.match(html, /Archify\.semanticLens\.clear\(\{ updateUrl: false/);
|
||||
assert.match(html, /Archify\.focus\.clear\(\{ updateUrl: false, preserveView: true \}\)/);
|
||||
assert.match(html, /Archify\.routeProbe\.clear\(\{ updateUrl: false, restoreFocus: false \}\)/);
|
||||
assert.match(html, /Archify\.guidedViews\.showAll\(\{ clearFocus: false, updateUrl: false \}\)/);
|
||||
assert.match(html, /if \(action === 'lens'\) return Archify\.semanticLens\.open\(\)/);
|
||||
assert.match(html, /e\.key === 'l' \|\| e\.key === 'L'/);
|
||||
assert.match(html, /e\.key === 'Escape' && Archify\.semanticLens\.isOpen\(\)/);
|
||||
assert.match(html, /e\.key === 'Escape' && Archify\.semanticLens\.active\(\)/);
|
||||
});
|
||||
|
||||
test('Semantic Lens preserves Reading Depth, mobile containment, print, embed, and export boundaries', () => {
|
||||
const html = render('dataflow', CASES.dataflow);
|
||||
assert.match(html, /svg\[data-lens-active\] \[data-lens-match\] \[data-detail\]/);
|
||||
assert.match(html, /svg\[data-lens-active\] \[data-lens-match\] \[data-detail-anchor\]/);
|
||||
assert.match(html, /html\[data-embed="true"\] \.semantic-lens/);
|
||||
assert.match(html, /data-wide-diagram="true"\] \.semantic-lens/);
|
||||
assert.match(html, /@media print \{[\s\S]+svg\[data-lens-active\] \[data-node-id\][\s\S]+opacity: 1 !important/);
|
||||
assert.match(html, /clone\.removeAttribute\('data-lens-active'\)/);
|
||||
assert.match(html, /\[data-lens-match\], \[data-lens-selected\], \[data-lens-peer\]/);
|
||||
assert.match(html, /clone\.querySelectorAll\('[^']*\[data-lens-match\][^']*\[data-lens-selected\][^']*\[data-lens-peer\][^']*'\)\.length === 0/);
|
||||
assert.match(html, /class="semantic-lens no-print"/);
|
||||
assert.doesNotMatch(canonicalSvg(html), /data-lens-active|data-lens-match|data-lens-selected|data-lens-peer/);
|
||||
});
|
||||
|
||||
test('Semantic Lens docks away from selected nodes without breaking mobile containment', () => {
|
||||
const html = render('architecture', CASES.architecture);
|
||||
assert.match(html, /function overlapArea\(a, b\)/);
|
||||
assert.match(html, /function dockPanel\(byId\)/);
|
||||
assert.match(html, /selectedKinds\.indexOf\(byId\[id\]\.getAttribute\('data-node-kind'\)/);
|
||||
assert.match(html, /var side = leftScore < rightScore \? 'left' : 'right'/);
|
||||
assert.match(html, /panel\.setAttribute\('data-dock-side', side\)/);
|
||||
assert.match(html, /\.semantic-lens\[data-dock-side="left"\]/);
|
||||
assert.match(html, /@media \(max-width: 720px\)[\s\S]+\.semantic-lens\[data-dock-side\] \{ left: auto; right: 0\.5rem; \}/);
|
||||
assert.match(html, /window\.addEventListener\('resize'/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,99 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-semantic-passport-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function svg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all typed renderers emit details-on-demand metadata and native SVG titles', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
const diagram = svg(html);
|
||||
assert.match(diagram, /data-node-kind="[^"]+"/, mode);
|
||||
assert.match(diagram, /data-node-sublabel="[^"]+"/, mode);
|
||||
assert.match(diagram, /data-node-context="[^"]+"/, mode);
|
||||
assert.match(diagram, /<g id="node-[^"]+"[\s\S]*?<title>[^<]+ · [^<]+<\/title>/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('renderer-owned structure supplies truthful Semantic Passport context', () => {
|
||||
const architecture = render('architecture', CASES.architecture);
|
||||
const workflow = render('workflow', CASES.workflow);
|
||||
const sequence = render('sequence', CASES.sequence);
|
||||
const dataflow = render('dataflow', CASES.dataflow);
|
||||
const lifecycle = render('lifecycle', CASES.lifecycle);
|
||||
|
||||
assert.match(architecture, /data-node-id="api"[^>]+data-node-kind="backend"[^>]+data-node-context="AWS Region: us-west-2 › sg-api :443\/:8000"/);
|
||||
assert.match(workflow, /data-node-id="approval"[^>]+data-node-kind="security"[^>]+data-node-context="Policy & Recovery › Human or policy stop › Plan \+ route"/);
|
||||
assert.match(sequence, /data-node-id="redis"[^>]+data-node-kind="database"[^>]+data-node-context="Sequence participant"/);
|
||||
assert.match(dataflow, /data-node-id="warehouse"[^>]+data-node-kind="database"[^>]+data-node-context="04 \/ Store"/);
|
||||
assert.match(lifecycle, /data-node-id="executing"[^>]+data-node-kind="active"[^>]+data-node-context="Lifecycle phases"/);
|
||||
});
|
||||
|
||||
test('Relationship Lens renders one Semantic Passport and copyable stable focus link', () => {
|
||||
const html = render('workflow', CASES.workflow);
|
||||
assert.match(html, /<span class="relationship-lens-eyebrow">Semantic passport<\/span>/);
|
||||
assert.match(html, /id="focus-detail" hidden/);
|
||||
assert.match(html, /id="focus-kind" data-passport="kind"/);
|
||||
assert.match(html, /id="focus-context" data-passport="context" hidden/);
|
||||
assert.match(html, /id="focus-tag" data-passport="tag" hidden/);
|
||||
assert.match(html, /id="focus-id" data-passport="id"/);
|
||||
assert.match(html, /id="btn-focus-clear"[^>]+aria-label="Close semantic passport"[^>]+title="Close">×<\/button>/);
|
||||
assert.match(html, /id="btn-focus-copy"[^>]+aria-label="Copy link to focused node"/);
|
||||
assert.match(html, /id="btn-focus-relations"[^>]+aria-expanded="false"[^>]+aria-controls="relationship-lens-list"/);
|
||||
assert.match(html, /function renderPassport\(id, node\)/);
|
||||
assert.match(html, /var relationId = record && record\.id/);
|
||||
assert.match(html, /\? '#relation=' \+ encodeURIComponent\(relationId\)/);
|
||||
assert.match(html, /: '#focus=' \+ encodeURIComponent\(activeIds\[0\]\)/);
|
||||
assert.match(html, /navigator\.clipboard\.writeText\(value\)/);
|
||||
assert.match(html, /document\.execCommand\('copy'\)/);
|
||||
assert.match(html, /copyLink: copyFocusLink/);
|
||||
assert.match(html, /compactOnMobile = mobile && chip\.getAttribute\('data-relations-expanded'\) !== 'true'/);
|
||||
assert.match(html, /nodeTop - chip\.offsetHeight - gap/);
|
||||
assert.match(html, /focus-chip:not\(\[data-relations-expanded="true"\]\) \.relationship-lens-list \{ display: none; \}/);
|
||||
assert.match(html, /clearBtn\.addEventListener\('click', function \(\) \{ clear\(\{ restoreFocus: true \}\); \}\)/);
|
||||
assert.match(html, /chip\.hidden \|\| !target \|\| typeof target\.closest !== 'function' \|\| chip\.contains\(target\)/);
|
||||
assert.match(html, /target\.closest\('\[data-node-id\], \[data-relationship-hit-key\], \.overview-map'\)/);
|
||||
assert.match(html, /document\.addEventListener\('click',[\s\S]+?clear\(\);\s+\}, true\);/);
|
||||
assert.match(html, /Archify\.focus\.clear\(\{ restoreFocus: true \}\)/);
|
||||
});
|
||||
|
||||
test('Node Finder searches and presents the same passport facts', () => {
|
||||
const html = render('dataflow', CASES.dataflow);
|
||||
assert.match(html, /var authored = node\.getAttribute\('data-node-kind'\)/);
|
||||
assert.match(html, /var sublabel = node\.getAttribute\('data-node-sublabel'\) \|\| ''/);
|
||||
assert.match(html, /var context = node\.getAttribute\('data-node-context'\) \|\| ''/);
|
||||
assert.match(html, /var tag = node\.getAttribute\('data-node-tag'\) \|\| ''/);
|
||||
assert.match(html, /search: \(id \+ ' ' \+ label \+ ' ' \+ type \+ ' ' \+ sublabel \+ ' ' \+ context \+ ' ' \+ tag \+ ' ' \+ sourceSearch \+ ' ' \+ text\)\.toLowerCase\(\)/);
|
||||
assert.match(html, /\[viewerKindLabel\(item\.type\), item\.id, item\.sublabel, item\.tag\]\.filter\(Boolean\)\.join\(' \\u00b7 '\)/);
|
||||
assert.match(html, /meta\.title = \[viewerKindLabel\(item\.type\), item\.id, item\.context, item\.sublabel, item\.tag\]\.filter\(Boolean\)\.join\(' \\u00b7 '\)/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,575 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import { ChromeVisualBrowser, findChrome } from '../bin/visual-check.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-semantic-radar-'));
|
||||
const chromePath = process.env.ARCHIFY_CHROME ? findChrome() : null;
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
async function evaluate(browser, sessionId, expression, awaitPromise = false) {
|
||||
const response = await browser.cdp.send('Runtime.evaluate', {
|
||||
expression,
|
||||
awaitPromise,
|
||||
returnByValue: true,
|
||||
}, sessionId);
|
||||
if (response.exceptionDetails) {
|
||||
throw new Error(response.exceptionDetails.exception?.description
|
||||
|| response.exceptionDetails.text
|
||||
|| 'Runtime.evaluate failed');
|
||||
}
|
||||
return response.result?.value;
|
||||
}
|
||||
|
||||
async function loadArtifact(browser, artifactPath, { width = 1440, height = 900 } = {}) {
|
||||
const sessionId = await browser.sessionPromise;
|
||||
await browser.cdp.send('Emulation.setDeviceMetricsOverride', {
|
||||
width,
|
||||
height,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
}, sessionId);
|
||||
const loaded = browser.cdp.waitFor('Page.loadEventFired', sessionId);
|
||||
const navigation = await browser.cdp.send('Page.navigate', {
|
||||
url: pathToFileURL(artifactPath).href,
|
||||
}, sessionId);
|
||||
if (navigation.errorText) throw new Error(`Chrome navigation failed: ${navigation.errorText}`);
|
||||
await loaded;
|
||||
await evaluate(browser, sessionId, `(function () {
|
||||
document.documentElement.setAttribute('data-motion', 'still');
|
||||
var fontsReady = document.fonts && document.fonts.ready
|
||||
? document.fonts.ready.catch(function () {})
|
||||
: Promise.resolve();
|
||||
return fontsReady.then(function () {
|
||||
return new Promise(function (resolve) {
|
||||
requestAnimationFrame(function () { requestAnimationFrame(resolve); });
|
||||
});
|
||||
});
|
||||
})()`, true);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
async function radarRects(browser, sessionId, setup) {
|
||||
return evaluate(browser, sessionId, `(function () {
|
||||
${setup}
|
||||
return new Promise(function (resolve) {
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
var radar = document.getElementById('overview-map').getBoundingClientRect();
|
||||
var controls = document.querySelector('.diagram-nav').getBoundingClientRect();
|
||||
var passport = document.getElementById('focus-chip');
|
||||
var passportRect = passport && !passport.hidden ? passport.getBoundingClientRect() : null;
|
||||
resolve({
|
||||
radar: { left: radar.left, top: radar.top, right: radar.right, bottom: radar.bottom },
|
||||
controls: { left: controls.left, top: controls.top, right: controls.right, bottom: controls.bottom },
|
||||
passport: passportRect ? {
|
||||
left: passportRect.left,
|
||||
top: passportRect.top,
|
||||
right: passportRect.right,
|
||||
bottom: passportRect.bottom
|
||||
} : null
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
})()`, true);
|
||||
}
|
||||
|
||||
async function dragMouse(browser, sessionId, from, to) {
|
||||
await browser.cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed',
|
||||
x: from.x,
|
||||
y: from.y,
|
||||
button: 'left',
|
||||
buttons: 1,
|
||||
clickCount: 1,
|
||||
}, sessionId);
|
||||
await browser.cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mouseMoved',
|
||||
x: to.x,
|
||||
y: to.y,
|
||||
button: 'left',
|
||||
buttons: 1,
|
||||
}, sessionId);
|
||||
await browser.cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased',
|
||||
x: to.x,
|
||||
y: to.y,
|
||||
button: 'left',
|
||||
buttons: 0,
|
||||
clickCount: 1,
|
||||
}, sessionId);
|
||||
await evaluate(browser, sessionId, `new Promise(function (resolve) {
|
||||
requestAnimationFrame(function () { requestAnimationFrame(resolve); });
|
||||
})`, true);
|
||||
}
|
||||
|
||||
function overlaps(a, b, gap = 0) {
|
||||
return a.left < b.right + gap
|
||||
&& a.right > b.left - gap
|
||||
&& a.top < b.bottom + gap
|
||||
&& a.bottom > b.top - gap;
|
||||
}
|
||||
|
||||
test('all typed renderers inherit one viewer-only Semantic Radar', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /id="overview-map" hidden role="region" aria-labelledby="overview-map-title"/, mode);
|
||||
assert.match(html, /id="overview-map-surface" tabindex="0" role="group"/, mode);
|
||||
assert.match(html, /id="overview-map-expand"[^>]+aria-label="Open full semantic radar"/, mode);
|
||||
assert.match(html, /id="overview-map-feedback" role="status" aria-live="polite" hidden/, mode);
|
||||
assert.match(html, /id="btn-overview-map"[^>]+aria-label="Open semantic radar"[^>]+aria-expanded="false"[^>]+aria-controls="overview-map"/, mode);
|
||||
assert.match(html, /Archify\.radar = \(function \(\)/, mode);
|
||||
assert.match(html, /document\.createElementNS\(namespace, 'svg'\)/, mode);
|
||||
assert.match(html, /mapSvg\.setAttribute\('aria-label', viewerText\('viewer\.radar\.nodes'\)\)/, mode);
|
||||
assert.match(html, /diagram\.querySelectorAll\('\[data-node-id\]'\)/, mode);
|
||||
assert.equal((html.match(/<svg\b/g) || []).length, 1, `${mode} keeps one static canonical SVG`);
|
||||
assert.doesNotMatch(canonicalSvg(html), /overview-map|Semantic radar|data-radar-node-id/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('Semantic Radar derives semantic node bounds and focuses stable IDs', () => {
|
||||
const html = render('workflow', CASES.workflow);
|
||||
assert.match(html, /box = node\.getBBox\(\)/);
|
||||
assert.match(html, /rect\.setAttribute\('data-radar-node-id', id\)/);
|
||||
assert.match(html, /rect\.setAttribute\('data-kind', node\.getAttribute\('data-node-kind'\) \|\| 'neutral'\)/);
|
||||
assert.match(html, /rect\.setAttribute\('aria-label', viewerText\('viewer\.radar\.focus'/);
|
||||
assert.match(html, /Archify\.focus\.set\(id, \{ toggle: false \}\)/);
|
||||
assert.match(html, /Archify\.view\.reveal\(\[id\], \{ includeNeighbors: true, reason: 'radar' \}\)/);
|
||||
assert.match(html, /function bringNodeIntoWindow\(node\)/);
|
||||
assert.match(html, /window\.scrollY \+ rect\.top \+ rect\.height \/ 2 - window\.innerHeight \/ 2/);
|
||||
assert.match(html, /data-radar-active/);
|
||||
});
|
||||
|
||||
test('Semantic Radar tracks desktop camera and mobile contained scroll', () => {
|
||||
const html = render('sequence', CASES.sequence);
|
||||
assert.match(html, /function logicalViewport\(\)/);
|
||||
assert.match(html, /x = viewBox\.x \+ container\.scrollLeft \/ metrics\.scale/);
|
||||
assert.match(html, /x = viewBox\.x \+ \(\(-state\.x \/ state\.scale\) - metrics\.offsetX\) \/ metrics\.scale/);
|
||||
assert.match(html, /viewport\.setAttribute\('width', String\(visible\.width\)\)/);
|
||||
assert.match(html, /viewerText\('viewer\.radar\.viewport\.width'/);
|
||||
assert.match(html, /function centerAt\(logicalX, logicalY, options\)/);
|
||||
assert.match(html, /minimumScale: 1\.5, instant: true/);
|
||||
assert.match(html, /container\.scrollTo\(\{ left: mobileTarget, behavior: options\.instant \? 'auto' : 'smooth' \}\)/);
|
||||
assert.match(html, /data-wide-diagram="true"\] \.overview-map/);
|
||||
assert.match(html, /function updateDocking\(\)/);
|
||||
assert.match(html, /chip\.style\.top = Math\.round\(top\) \+ 'px';[\s\S]+Archify\.radar\.sync\(\)/);
|
||||
assert.match(html, /var navigation = container\.querySelector\('\.diagram-nav'\)/);
|
||||
assert.match(html, /if \(controlRect\) bottom = Math\.min\(bottom, controlRect\.top - placementGap\)/);
|
||||
assert.match(html, /hardBlockers: \[lensRect, controlRect, legendRect\]\.filter\(Boolean\)/);
|
||||
assert.match(html, /function cornerCandidates\(context\)/);
|
||||
assert.match(html, /function nearbyCandidates\(context, reference\)/);
|
||||
assert.match(html, /nearbyCandidates\(context, reference\)\.concat\(cornerCandidates\(context\)\)/);
|
||||
assert.match(html, /var placementOptions = \{ softWeight: manualPosition \? 0 : 100 \}/);
|
||||
assert.match(html, /manualPosition && positionIsValid\(manualPosition, context\)/);
|
||||
assert.match(html, /panelHead\.addEventListener\('pointerdown', beginPanelDrag\)/);
|
||||
assert.match(html, /surface\.addEventListener\('pointerdown',[\s\S]+viewportDrag = \{ pointerId: event\.pointerId \}/);
|
||||
assert.match(html, /target\.closest\('\[data-node-id\], \[data-relationship-hit-key\], \.overview-map'\)/);
|
||||
assert.match(html, /--archify-radar-top/);
|
||||
assert.match(html, /\.overview-map\[data-docked="true"\]/);
|
||||
});
|
||||
|
||||
test('Semantic Radar keeps redundant accessible navigation and clean exports', () => {
|
||||
const html = render('architecture', CASES.architecture);
|
||||
assert.match(html, /Semantic radar \(M\)/);
|
||||
assert.match(html, /e\.key === 'm' \|\| e\.key === 'M'/);
|
||||
assert.match(html, /e\.key === 'Escape' && Archify\.radar\.isOpen\(\)/);
|
||||
assert.match(html, /event\.key === 'ArrowLeft'[\s\S]+event\.key === 'ArrowRight'[\s\S]+event\.key === 'ArrowUp'[\s\S]+event\.key === 'ArrowDown'/);
|
||||
assert.match(html, /node && \(event\.key === 'Enter' \|\| event\.key === ' '\)/);
|
||||
assert.match(html, /\.overview-map-viewport \{[\s\S]*?pointer-events: none;/);
|
||||
assert.match(html, /html\[data-embed="true"\] \.overview-map/);
|
||||
assert.match(html, /class="overview-map no-print"/);
|
||||
assert.match(html, /The radar is built at runtime so the checked artifact still contains[\s\S]+one canonical SVG block/);
|
||||
assert.doesNotMatch(canonicalSvg(html), /overview-map-node|overview-map-viewport/);
|
||||
});
|
||||
|
||||
test('Semantic Radar stays above the measured MAP control strip', {
|
||||
skip: chromePath ? false : 'Set ARCHIFY_CHROME to run the real browser regression.',
|
||||
}, async () => {
|
||||
const artifact = path.join(tmp, 'radar-control-clearance.html');
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, 'renderers/architecture/render-architecture.mjs'),
|
||||
path.join(skillRoot, 'examples', CASES.architecture),
|
||||
artifact,
|
||||
]);
|
||||
const browser = new ChromeVisualBrowser(chromePath);
|
||||
try {
|
||||
const sessionId = await loadArtifact(browser, artifact, { width: 1440, height: 900 });
|
||||
const rects = await radarRects(browser, sessionId, `
|
||||
var container = document.querySelector('.diagram-container');
|
||||
window.scrollTo(0, Math.max(0, container.offsetTop + container.offsetHeight - window.innerHeight + 8));
|
||||
Archify.radar.open();
|
||||
`);
|
||||
const controlGap = rects.controls.top - rects.radar.bottom;
|
||||
assert.ok(controlGap >= 15, JSON.stringify({ ...rects, controlGap }, null, 2));
|
||||
assert.ok(
|
||||
rects.radar.left < rects.controls.left,
|
||||
`automatic placement should prefer the lower-left corner: ${JSON.stringify(rects, null, 2)}`,
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Semantic Radar avoids an expanded mobile Passport without hiding a collision', {
|
||||
skip: chromePath ? false : 'Set ARCHIFY_CHROME to run the real browser regression.',
|
||||
}, async () => {
|
||||
const artifact = path.join(tmp, 'radar-mobile-passport.html');
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, 'renderers/architecture/render-architecture.mjs'),
|
||||
path.join(skillRoot, 'examples', CASES.architecture),
|
||||
artifact,
|
||||
]);
|
||||
const browser = new ChromeVisualBrowser(chromePath);
|
||||
try {
|
||||
const sessionId = await loadArtifact(browser, artifact, { width: 390, height: 600 });
|
||||
const state = await evaluate(browser, sessionId, `(function () {
|
||||
var container = document.querySelector('.diagram-container');
|
||||
window.scrollTo(0, Math.max(0, container.offsetTop));
|
||||
Archify.focus.set('lb', { toggle: false });
|
||||
document.getElementById('btn-focus-relations').click();
|
||||
Archify.radar.open();
|
||||
return new Promise(function (resolve) {
|
||||
setTimeout(function () {
|
||||
var radar = document.getElementById('overview-map');
|
||||
var radarRect = radar.getBoundingClientRect();
|
||||
var passportRect = document.getElementById('focus-chip').getBoundingClientRect();
|
||||
var containerRect = document.querySelector('.diagram-container').getBoundingClientRect();
|
||||
var controlsRect = document.querySelector('.diagram-nav').getBoundingClientRect();
|
||||
var legendRect = document.querySelector('[data-legend]').getBoundingClientRect();
|
||||
resolve({
|
||||
radar: { left: radarRect.left, top: radarRect.top, right: radarRect.right, bottom: radarRect.bottom },
|
||||
passport: { left: passportRect.left, top: passportRect.top, right: passportRect.right, bottom: passportRect.bottom },
|
||||
container: { left: containerRect.left, top: containerRect.top, right: containerRect.right, bottom: containerRect.bottom },
|
||||
controls: { left: controlsRect.left, top: controlsRect.top, right: controlsRect.right, bottom: controlsRect.bottom },
|
||||
legend: { left: legendRect.left, top: legendRect.top, right: legendRect.right, bottom: legendRect.bottom },
|
||||
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||
invalid: radar.getAttribute('data-placement-invalid'),
|
||||
compact: radar.getAttribute('data-compact'),
|
||||
unavailable: radar.getAttribute('data-placement-unavailable')
|
||||
});
|
||||
}, 180);
|
||||
});
|
||||
})()`, true);
|
||||
assert.equal(overlaps(state.radar, state.passport, 10), false, JSON.stringify(state, null, 2));
|
||||
assert.notEqual(state.invalid, 'true', JSON.stringify(state, null, 2));
|
||||
assert.equal(state.compact, 'true', JSON.stringify(state, null, 2));
|
||||
|
||||
const expanded = await evaluate(browser, sessionId, `(function () {
|
||||
document.getElementById('overview-map-expand').click();
|
||||
return new Promise(function (resolve) {
|
||||
setTimeout(function () {
|
||||
var radar = document.getElementById('overview-map');
|
||||
var rect = radar.getBoundingClientRect();
|
||||
var passport = document.getElementById('focus-chip');
|
||||
resolve({
|
||||
compact: radar.getAttribute('data-compact'),
|
||||
height: rect.height,
|
||||
surfaceVisible: getComputedStyle(document.getElementById('overview-map-surface')).display !== 'none',
|
||||
passportYielded: passport.getAttribute('data-radar-yielded'),
|
||||
passportVisible: getComputedStyle(passport).display !== 'none'
|
||||
});
|
||||
}, 120);
|
||||
});
|
||||
})()`, true);
|
||||
assert.equal(expanded.compact, null, JSON.stringify(expanded, null, 2));
|
||||
assert.equal(expanded.surfaceVisible, true, JSON.stringify(expanded, null, 2));
|
||||
assert.equal(expanded.passportYielded, 'true', JSON.stringify(expanded, null, 2));
|
||||
assert.equal(expanded.passportVisible, false, JSON.stringify(expanded, null, 2));
|
||||
assert.ok(expanded.height > state.radar.bottom - state.radar.top, JSON.stringify({ state, expanded }, null, 2));
|
||||
|
||||
const closed = await evaluate(browser, sessionId, `(function () {
|
||||
document.getElementById('overview-map-close').click();
|
||||
var passport = document.getElementById('focus-chip');
|
||||
return {
|
||||
radarHidden: document.getElementById('overview-map').hidden,
|
||||
passportYielded: passport.getAttribute('data-radar-yielded'),
|
||||
passportVisible: getComputedStyle(passport).display !== 'none'
|
||||
};
|
||||
})()`);
|
||||
assert.equal(closed.radarHidden, true, JSON.stringify(closed, null, 2));
|
||||
assert.equal(closed.passportYielded, null, JSON.stringify(closed, null, 2));
|
||||
assert.equal(closed.passportVisible, true, JSON.stringify(closed, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Semantic Radar reports a consistent unavailable state and recovers when space returns', {
|
||||
skip: chromePath ? false : 'Set ARCHIFY_CHROME to run the real browser regression.',
|
||||
}, async () => {
|
||||
const artifact = path.join(tmp, 'radar-unavailable.html');
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, 'renderers/architecture/render-architecture.mjs'),
|
||||
path.join(skillRoot, 'examples', CASES.architecture),
|
||||
artifact,
|
||||
]);
|
||||
const browser = new ChromeVisualBrowser(chromePath);
|
||||
try {
|
||||
const sessionId = await loadArtifact(browser, artifact, { width: 390, height: 300 });
|
||||
const unavailable = await evaluate(browser, sessionId, `(function () {
|
||||
var container = document.querySelector('.diagram-container');
|
||||
window.scrollTo(0, Math.max(0, container.offsetTop));
|
||||
Archify.focus.set('lb', { toggle: false });
|
||||
document.getElementById('btn-focus-relations').click();
|
||||
Archify.radar.open();
|
||||
return new Promise(function (resolve) {
|
||||
setTimeout(function () {
|
||||
var panel = document.getElementById('overview-map');
|
||||
var trigger = document.getElementById('btn-overview-map');
|
||||
var feedback = document.getElementById('overview-map-feedback');
|
||||
resolve({
|
||||
panelHidden: panel.hidden,
|
||||
expanded: trigger.getAttribute('aria-expanded'),
|
||||
limited: trigger.getAttribute('data-radar-space-limited'),
|
||||
feedbackHidden: feedback.hidden,
|
||||
feedback: feedback.textContent.trim()
|
||||
});
|
||||
}, 260);
|
||||
});
|
||||
})()`, true);
|
||||
assert.equal(unavailable.panelHidden, true, JSON.stringify(unavailable, null, 2));
|
||||
assert.equal(unavailable.expanded, 'false', JSON.stringify(unavailable, null, 2));
|
||||
assert.equal(unavailable.limited, 'true', JSON.stringify(unavailable, null, 2));
|
||||
assert.equal(unavailable.feedbackHidden, false, JSON.stringify(unavailable, null, 2));
|
||||
assert.match(unavailable.feedback, /space/i);
|
||||
|
||||
await browser.cdp.send('Emulation.setDeviceMetricsOverride', {
|
||||
width: 390,
|
||||
height: 600,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
}, sessionId);
|
||||
const recovered = await evaluate(browser, sessionId, `new Promise(function (resolve) {
|
||||
setTimeout(function () {
|
||||
var panel = document.getElementById('overview-map');
|
||||
var trigger = document.getElementById('btn-overview-map');
|
||||
var feedback = document.getElementById('overview-map-feedback');
|
||||
resolve({
|
||||
panelHidden: panel.hidden,
|
||||
expanded: trigger.getAttribute('aria-expanded'),
|
||||
feedbackHidden: feedback.hidden
|
||||
});
|
||||
}, 260);
|
||||
})`, true);
|
||||
assert.equal(recovered.panelHidden, false, JSON.stringify(recovered, null, 2));
|
||||
assert.equal(recovered.expanded, 'true', JSON.stringify(recovered, null, 2));
|
||||
assert.equal(recovered.feedbackHidden, true, JSON.stringify(recovered, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Semantic Radar automatically avoids a tall Semantic Passport', {
|
||||
skip: chromePath ? false : 'Set ARCHIFY_CHROME to run the real browser regression.',
|
||||
}, async () => {
|
||||
const input = path.join(tmp, 'tall-passport.architecture.json');
|
||||
const artifact = path.join(tmp, 'tall-passport.html');
|
||||
const peers = Array.from({ length: 12 }, (_, index) => ({
|
||||
id: `peer-${index + 1}`,
|
||||
type: index % 2 ? 'backend' : 'database',
|
||||
label: `Peer ${index + 1}`,
|
||||
sublabel: 'Connected system',
|
||||
pos: [80, 40 + index * 90],
|
||||
size: [130, 60],
|
||||
}));
|
||||
fs.writeFileSync(input, JSON.stringify({
|
||||
schema_version: 1,
|
||||
diagram_type: 'architecture',
|
||||
meta: { title: 'Tall Passport Radar Regression', output: artifact },
|
||||
components: [
|
||||
...peers,
|
||||
{ id: 'hub', type: 'security', label: 'Relationship Hub', sublabel: 'Many authored links', pos: [900, 500], size: [150, 70] },
|
||||
],
|
||||
boundaries: [],
|
||||
connections: peers.map((peer, index) => ({
|
||||
id: `hub-to-${peer.id}`,
|
||||
from: 'hub',
|
||||
to: peer.id,
|
||||
fromSide: 'left',
|
||||
toSide: 'right',
|
||||
via: [[840, 535], [840, peer.pos[1] + 30]],
|
||||
})),
|
||||
cards: [],
|
||||
}, null, 2));
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, 'renderers/architecture/render-architecture.mjs'),
|
||||
input,
|
||||
artifact,
|
||||
]);
|
||||
const browser = new ChromeVisualBrowser(chromePath);
|
||||
try {
|
||||
const sessionId = await loadArtifact(browser, artifact, { width: 1200, height: 700 });
|
||||
const rects = await radarRects(browser, sessionId, `
|
||||
var container = document.querySelector('.diagram-container');
|
||||
window.scrollTo(0, Math.max(0, container.offsetTop));
|
||||
Archify.focus.set('hub', { toggle: false });
|
||||
Archify.radar.open();
|
||||
`);
|
||||
assert.ok(rects.passport, JSON.stringify(rects, null, 2));
|
||||
assert.equal(overlaps(rects.radar, rects.passport, 10), false, JSON.stringify(rects, null, 2));
|
||||
|
||||
const dragGeometry = await evaluate(browser, sessionId, `(function () {
|
||||
var radar = document.getElementById('overview-map').getBoundingClientRect();
|
||||
var head = document.querySelector('.overview-map-head').getBoundingClientRect();
|
||||
var passport = document.getElementById('focus-chip').getBoundingClientRect();
|
||||
var active = document.querySelector('[data-focus-selected]');
|
||||
var nearestLeft = passport.right + 16;
|
||||
active.getBoundingClientRect = function () {
|
||||
return {
|
||||
left: nearestLeft,
|
||||
top: radar.top,
|
||||
right: nearestLeft + radar.width,
|
||||
bottom: radar.top + radar.height,
|
||||
width: radar.width,
|
||||
height: radar.height
|
||||
};
|
||||
};
|
||||
return {
|
||||
radar: { left: radar.left, top: radar.top },
|
||||
head: { left: head.left, top: head.top, height: head.height },
|
||||
requested: { left: passport.right + 8, top: radar.top },
|
||||
nearest: { left: nearestLeft, top: radar.top }
|
||||
};
|
||||
})()`);
|
||||
await dragMouse(browser, sessionId, {
|
||||
x: dragGeometry.head.left + 48,
|
||||
y: dragGeometry.head.top + dragGeometry.head.height / 2,
|
||||
}, {
|
||||
x: dragGeometry.head.left + 48 + dragGeometry.requested.left - dragGeometry.radar.left,
|
||||
y: dragGeometry.head.top + dragGeometry.head.height / 2 + dragGeometry.requested.top - dragGeometry.radar.top,
|
||||
});
|
||||
const snappedRects = await radarRects(browser, sessionId, '');
|
||||
assert.equal(overlaps(snappedRects.radar, snappedRects.passport, 10), false, JSON.stringify(snappedRects, null, 2));
|
||||
assert.ok(Math.abs(snappedRects.radar.left - dragGeometry.nearest.left) <= 2, JSON.stringify({ dragGeometry, snappedRects }, null, 2));
|
||||
assert.ok(Math.abs(snappedRects.radar.top - dragGeometry.nearest.top) <= 2, JSON.stringify({ dragGeometry, snappedRects }, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Semantic Radar titlebar drag persists while surface drag still pans the diagram', {
|
||||
skip: chromePath ? false : 'Set ARCHIFY_CHROME to run the real browser regression.',
|
||||
}, async () => {
|
||||
const artifact = path.join(tmp, 'radar-dragging.html');
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, 'renderers/architecture/render-architecture.mjs'),
|
||||
path.join(skillRoot, 'examples', CASES.architecture),
|
||||
artifact,
|
||||
]);
|
||||
const browser = new ChromeVisualBrowser(chromePath);
|
||||
try {
|
||||
const sessionId = await loadArtifact(browser, artifact, { width: 1440, height: 900 });
|
||||
const geometry = await evaluate(browser, sessionId, `(function () {
|
||||
var container = document.querySelector('.diagram-container');
|
||||
window.scrollTo(0, Math.max(0, container.offsetTop + container.offsetHeight - window.innerHeight + 8));
|
||||
Archify.radar.open();
|
||||
var radar = document.getElementById('overview-map').getBoundingClientRect();
|
||||
var head = document.querySelector('.overview-map-head').getBoundingClientRect();
|
||||
var containerRect = container.getBoundingClientRect();
|
||||
return {
|
||||
radar: { left: radar.left, top: radar.top, width: radar.width, height: radar.height },
|
||||
head: { left: head.left, top: head.top, width: head.width, height: head.height },
|
||||
state: Archify.view.state(),
|
||||
target: {
|
||||
left: Math.max(24, containerRect.left + 360),
|
||||
top: Math.max(24, containerRect.top + 20)
|
||||
}
|
||||
};
|
||||
})()`);
|
||||
const titleStart = {
|
||||
x: geometry.head.left + 48,
|
||||
y: geometry.head.top + geometry.head.height / 2,
|
||||
};
|
||||
const titleTarget = {
|
||||
x: titleStart.x + geometry.target.left - geometry.radar.left,
|
||||
y: titleStart.y + geometry.target.top - geometry.radar.top,
|
||||
};
|
||||
await dragMouse(browser, sessionId, titleStart, titleTarget);
|
||||
|
||||
const manuallyPlaced = await evaluate(browser, sessionId, `(function () {
|
||||
Archify.radar.sync();
|
||||
var radar = document.getElementById('overview-map').getBoundingClientRect();
|
||||
return { left: radar.left, top: radar.top, state: Archify.view.state() };
|
||||
})()`);
|
||||
assert.ok(Math.abs(manuallyPlaced.left - geometry.target.left) <= 2, JSON.stringify({ geometry, manuallyPlaced }, null, 2));
|
||||
assert.ok(Math.abs(manuallyPlaced.top - geometry.target.top) <= 2, JSON.stringify({ geometry, manuallyPlaced }, null, 2));
|
||||
assert.deepEqual(manuallyPlaced.state, geometry.state);
|
||||
|
||||
const surfaceState = await evaluate(browser, sessionId, `(function () {
|
||||
var radar = document.getElementById('overview-map').getBoundingClientRect();
|
||||
var surface = document.getElementById('overview-map-surface').getBoundingClientRect();
|
||||
return {
|
||||
radar: { left: radar.left, top: radar.top },
|
||||
state: Archify.view.state(),
|
||||
start: { x: surface.left + 8, y: surface.top + 8 },
|
||||
end: { x: surface.right - 8, y: surface.bottom - 8 }
|
||||
};
|
||||
})()`);
|
||||
await dragMouse(browser, sessionId, surfaceState.start, surfaceState.end);
|
||||
const afterSurfaceDrag = await evaluate(browser, sessionId, `(function () {
|
||||
var radar = document.getElementById('overview-map').getBoundingClientRect();
|
||||
return {
|
||||
radar: { left: radar.left, top: radar.top },
|
||||
state: Archify.view.state()
|
||||
};
|
||||
})()`);
|
||||
assert.deepEqual(afterSurfaceDrag.radar, surfaceState.radar);
|
||||
assert.notDeepEqual(afterSurfaceDrag.state, surfaceState.state);
|
||||
|
||||
await browser.cdp.send('Emulation.setDeviceMetricsOverride', {
|
||||
width: 640,
|
||||
height: 700,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
}, sessionId);
|
||||
const afterResize = await evaluate(browser, sessionId, `new Promise(function (resolve) {
|
||||
setTimeout(function () {
|
||||
var radar = document.getElementById('overview-map').getBoundingClientRect();
|
||||
var controls = document.querySelector('.diagram-nav').getBoundingClientRect();
|
||||
var container = document.querySelector('.diagram-container').getBoundingClientRect();
|
||||
resolve({
|
||||
radar: { left: radar.left, top: radar.top, right: radar.right, bottom: radar.bottom },
|
||||
controls: { left: controls.left, top: controls.top, right: controls.right, bottom: controls.bottom },
|
||||
container: { left: container.left, top: container.top, right: container.right, bottom: container.bottom },
|
||||
viewport: { width: window.innerWidth, height: window.innerHeight }
|
||||
});
|
||||
}, 120);
|
||||
})`, true);
|
||||
assert.ok(afterResize.radar.left >= Math.max(0, afterResize.container.left), JSON.stringify(afterResize, null, 2));
|
||||
assert.ok(afterResize.radar.right <= Math.min(afterResize.viewport.width, afterResize.container.right), JSON.stringify(afterResize, null, 2));
|
||||
assert.equal(overlaps(afterResize.radar, afterResize.controls, 10), false, JSON.stringify(afterResize, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-semantic-zoom-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function svg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all typed renderers emit explicit context and fine reading-depth semantics', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /data-detail="context"/, mode);
|
||||
assert.match(html, /data-detail="fine"/, mode);
|
||||
assert.match(html, /data-detail-anchor/, mode);
|
||||
assert.doesNotMatch(html, /<text[^>]*data-detail="(?:context|fine)"[^>]*class="t-primary"/, mode);
|
||||
assert.match(html, /class="diagram-container" data-detail-level="read"/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('semantic zoom exposes MAP, READ, and FULL at deterministic thresholds', () => {
|
||||
const html = render('workflow', CASES.workflow);
|
||||
assert.match(html, /function detailLevel\(\)/);
|
||||
assert.match(html, /if \(state\.mode === 'semantic'\) return 'full'/);
|
||||
assert.match(html, /if \(state\.scale >= 1\.75\) return 'full'/);
|
||||
assert.match(html, /if \(state\.scale >= 1\) return 'read'/);
|
||||
assert.match(html, /return 'map'/);
|
||||
assert.match(html, /container\.setAttribute\('data-detail-level', detail\)/);
|
||||
assert.match(html, /var levelLabel = viewerText\('viewer\.nav\.level\.' \+ detail\)/);
|
||||
assert.match(html, /var resolvedLevel = semantic \? viewerText\('viewer\.nav\.level\.auto'\) : levelLabel/);
|
||||
assert.match(html, /Zoom in to reveal relationship labels and node context/);
|
||||
assert.match(html, /Zoom in again to reveal tags and annotations/);
|
||||
assert.match(html, /Full diagram detail/);
|
||||
});
|
||||
|
||||
test('reading depth stays quiet at overview and yields to semantic intent', () => {
|
||||
const html = render('architecture', CASES.architecture);
|
||||
assert.match(html, /\.diagram-container\[data-detail-level="map"\] svg \[data-detail="context"\]/);
|
||||
assert.match(html, /\.diagram-container\[data-detail-level="map"\] svg \[data-detail="fine"\]/);
|
||||
assert.match(html, /\.diagram-container\[data-detail-level="read"\] svg \[data-detail="fine"\]/);
|
||||
assert.match(html, /\.diagram-container\[data-detail-level="map"\] svg \[data-detail-anchor\]/);
|
||||
assert.match(html, /svg\[data-focus-active\] \[data-focus-match\] \[data-detail\]/);
|
||||
assert.match(html, /svg\[data-intent-trace-active\] \[data-intent-trace-match\] \[data-detail\]/);
|
||||
assert.match(html, /svg\[data-route-active\] \[data-route-match\] \[data-detail\]/);
|
||||
assert.match(html, /svg\[data-story-active\] \[data-story-step\] \[data-detail\]/);
|
||||
assert.match(html, /svg\[data-relationship-preview-active\] \[data-relationship-preview\] \[data-detail\]/);
|
||||
});
|
||||
|
||||
test('semantic zoom is motion-safe and full-fidelity in print and export', () => {
|
||||
const html = render('dataflow', CASES.dataflow);
|
||||
assert.match(html, /\[data-detail\] \{ transition: opacity 160ms ease/);
|
||||
assert.match(html, /@media print \{[\s\S]+\.diagram-container svg \[data-detail\] \{[\s\S]+opacity: 1 !important/);
|
||||
assert.match(html, /@media \(prefers-reduced-motion: reduce\)[\s\S]+svg \[data-detail\]/);
|
||||
assert.match(html, /clone\.querySelectorAll\('\[data-detail\], \[data-detail-anchor\]'\)/);
|
||||
assert.match(html, /el\.removeAttribute\('data-detail'\)/);
|
||||
assert.match(html, /el\.removeAttribute\('data-detail-anchor'\)/);
|
||||
assert.match(html, /clone\.querySelectorAll\('[^']*\[data-detail\][^']*\[data-detail-anchor\][^']*'\)\.length === 0/);
|
||||
assert.doesNotMatch(svg(html), /data-detail-level=/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,137 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { textUnits } from '../renderers/shared/utils.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function renderOutcome(doc) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-column-fit-'));
|
||||
const input = path.join(tmp, 'input.json');
|
||||
const output = path.join(tmp, 'output.html');
|
||||
fs.writeFileSync(input, JSON.stringify(doc));
|
||||
try {
|
||||
execFileSync('node', [
|
||||
path.join(skillRoot, 'renderers/sequence/render-sequence.mjs'),
|
||||
input,
|
||||
output,
|
||||
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
return { code: 0, stderr: '', html: fs.readFileSync(output, 'utf8') };
|
||||
} catch (err) {
|
||||
return { code: err.status ?? 1, stderr: String(err.stderr || ''), html: '' };
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function render(doc) {
|
||||
const outcome = renderOutcome(doc);
|
||||
assert.equal(outcome.code, 0, outcome.stderr);
|
||||
return outcome.html;
|
||||
}
|
||||
|
||||
function participantBoxes(html) {
|
||||
return [...html.matchAll(/<rect x="([\d.]+)" y="72" width="([\d.]+)" height="54"/g)]
|
||||
.map(([, x, width]) => ({ x: Number(x), width: Number(width) }))
|
||||
.filter((box, index, all) => all.findIndex((other) => other.x === box.x) === index)
|
||||
.sort((left, right) => left.x - right.x);
|
||||
}
|
||||
|
||||
function wideSequence(columnFit) {
|
||||
const meta = { title: 'Column fit', viewBox: [1320, 620] };
|
||||
if (columnFit) meta.column_fit = columnFit;
|
||||
return {
|
||||
schema_version: 1,
|
||||
diagram_type: 'sequence',
|
||||
meta,
|
||||
participants: [
|
||||
{ id: 'browser', type: 'frontend', label: 'Browser' },
|
||||
{ id: 'gateway', type: 'backend', label: 'Gateway' },
|
||||
{ id: 'idp', type: 'security', label: 'IdP' },
|
||||
{ id: 'api', type: 'backend', label: 'API' },
|
||||
{ id: 'store', type: 'database', label: 'Store' }
|
||||
],
|
||||
messages: [
|
||||
{ from: 'browser', to: 'gateway', y: 200, label: 'request' },
|
||||
{ from: 'gateway', to: 'idp', y: 260, label: 'authorize' },
|
||||
{ from: 'idp', to: 'api', y: 320, label: 'token' },
|
||||
{ from: 'api', to: 'store', y: 380, label: 'read' }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
test('fixed column fit keeps the historical 108px gap regardless of viewBox width', () => {
|
||||
const boxes = participantBoxes(render(wideSequence()));
|
||||
assert.equal(boxes.length, 5);
|
||||
assert.equal(boxes[0].width, 86);
|
||||
assert.equal(boxes[1].x - boxes[0].x, 108);
|
||||
assert.equal(boxes.at(-1).x + boxes.at(-1).width < 600, true,
|
||||
'fixed lanes stay packed on the left, leaving the wide canvas unused');
|
||||
});
|
||||
|
||||
test('spread column fit uses the viewBox width and stays inside it', () => {
|
||||
const boxes = participantBoxes(render(wideSequence('spread')));
|
||||
assert.equal(boxes.length, 5);
|
||||
assert.ok(boxes[0].width > 86, 'participant boxes widen with the available room');
|
||||
assert.ok(boxes[1].x - boxes[0].x > 108, 'columns spread past the fixed gap');
|
||||
assert.equal(boxes[0].x, 62, 'first lane keeps the side margin');
|
||||
assert.ok(boxes.at(-1).x + boxes.at(-1).width <= 1320 - 40,
|
||||
'last lane stays inside the viewBox with the reserved margin');
|
||||
});
|
||||
|
||||
test('spread column fit is opt-in, so an unset value renders like fixed', () => {
|
||||
assert.equal(render(wideSequence()), render(wideSequence('fixed')));
|
||||
});
|
||||
|
||||
const wideLabel = 'Payment Gateway Service';
|
||||
|
||||
function labelledSequence(columnFit) {
|
||||
const doc = wideSequence(columnFit);
|
||||
doc.participants[1].label = wideLabel;
|
||||
return doc;
|
||||
}
|
||||
|
||||
test('a label the fixed box rejects fits the spread box on the same viewBox', () => {
|
||||
const estimatedLabelW = textUnits(wideLabel) * 6.8;
|
||||
assert.ok(estimatedLabelW > 86 + 6, 'the fixture label must actually exceed the fixed box');
|
||||
|
||||
const fixed = renderOutcome(labelledSequence());
|
||||
assert.notEqual(fixed.code, 0, 'the fixed box still rejects a label it cannot hold');
|
||||
assert.ok(fixed.stderr.includes(`Label "${wideLabel}"`), `expected the label in stderr:\n${fixed.stderr}`);
|
||||
assert.ok(fixed.stderr.includes('86px participant box'), `expected the fixed box width in stderr:\n${fixed.stderr}`);
|
||||
|
||||
const spread = renderOutcome(labelledSequence('spread'));
|
||||
assert.equal(spread.code, 0, spread.stderr);
|
||||
const box = participantBoxes(spread.html)[1];
|
||||
assert.ok(estimatedLabelW <= box.width + 6, `label ~${estimatedLabelW}px must fit the ${box.width}px spread box`);
|
||||
assert.ok(spread.html.includes(`>${wideLabel}</text>`), 'the label renders unshortened');
|
||||
});
|
||||
|
||||
test('the sublabel diagnostic reports the width in force, not the historical constant', () => {
|
||||
const unrescuable = 'Payment authorization gateway detail text that stays far too long to shrink';
|
||||
const doc = wideSequence('spread');
|
||||
doc.participants[0].sublabel = unrescuable;
|
||||
|
||||
const { code, stderr } = renderOutcome(doc);
|
||||
assert.notEqual(code, 0, 'a sublabel past the legible minimum is still rejected');
|
||||
assert.match(stderr, /participant boxes are 190px for this viewBox width and 5 participants/);
|
||||
assert.doesNotMatch(stderr, /boxes are a fixed/, 'spread must not quote the fixed layout');
|
||||
});
|
||||
|
||||
test('the fast authoring path explains when to opt into spread', () => {
|
||||
const schema = JSON.parse(fs.readFileSync(path.join(skillRoot, 'schemas/sequence.schema.json'), 'utf8'));
|
||||
const description = schema.properties.meta.properties.column_fit.description;
|
||||
const skill = fs.readFileSync(path.join(skillRoot, 'SKILL.md'), 'utf8');
|
||||
const rendererReadme = fs.readFileSync(path.join(skillRoot, 'renderers/sequence/README.md'), 'utf8');
|
||||
|
||||
assert.match(description, /wide viewBox/);
|
||||
assert.match(description, /meaningful participant labels/);
|
||||
assert.match(skill, /do not shorten semantic labels before trying `spread`/);
|
||||
assert.match(rendererReadme, /Use `"spread"` when a wide/);
|
||||
assert.match(rendererReadme, /try `meta\.column_fit: "spread"` before shortening/);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { animateAttr } from '../renderers/shared/cli.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets', 'template.html'), 'utf8');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-settled-flow-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const doc = JSON.parse(fs.readFileSync(path.join(skillRoot, 'examples', example), 'utf8'));
|
||||
doc.meta = { ...doc.meta, animation: 'trace' };
|
||||
const input = path.join(tmp, `${mode}.json`);
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
fs.writeFileSync(input, JSON.stringify(doc));
|
||||
execFileSync('node', [path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`), input, output], {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
test('all five renderers inherit one finite running-to-settled ambient contract', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /data-ambient-motion/, mode);
|
||||
assert.match(html, /function startAmbient\(\)/, mode);
|
||||
assert.match(html, /function settleAmbient\(reason\)/, mode);
|
||||
assert.match(html, /animation: archify-edge-flow [^;]+ 1;/, mode);
|
||||
assert.match(html, /animation: archify-node-pulse [^;]+ 1;/, mode);
|
||||
assert.doesNotMatch(html, /animation: archify-edge-flow [^;]+ infinite/, mode);
|
||||
assert.doesNotMatch(html, /animation: archify-node-pulse [^;]+ infinite/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('settled flow restores authored security and async dash semantics', () => {
|
||||
assert.match(template, /\.a-security\s*\{[^}]*stroke-dasharray:\s*5,5/);
|
||||
assert.match(template, /\.a-dashed\s*\{[^}]*stroke-dasharray:\s*4,4/);
|
||||
assert.match(template, /@keyframes archify-edge-flow\s*\{[\s\S]*?stroke-dasharray:\s*10 8/);
|
||||
assert.match(template, /100%\s*\{\s*stroke-dashoffset:\s*0;\s*opacity:\s*1;\s*\}/);
|
||||
const runningRule = template.match(/html\[data-ambient-motion="running"\][^{]+\[data-animate="edge"\][^{]*\{([^}]*)\}/)?.[1] || '';
|
||||
assert.ok(runningRule, 'running edge rule missing');
|
||||
assert.doesNotMatch(runningRule, /stroke-dasharray|stroke-dashoffset/);
|
||||
});
|
||||
|
||||
test('ambient ownership is generation-bounded and cannot replay after settle', () => {
|
||||
assert.match(template, /var ambientStarted = false/);
|
||||
assert.match(template, /var ambientPending = new Set\(\)/);
|
||||
assert.match(template, /if \(ambientStarted \|\| !capable\) return false/);
|
||||
assert.match(template, /ambientStarted = true;[\s\S]*?html\.setAttribute\('data-ambient-motion', 'running'\)/);
|
||||
assert.match(template, /ambientPending\.delete\(event\.target\)/);
|
||||
assert.match(template, /if \(!ambientPending\.size\) settleAmbient\('complete'\)/);
|
||||
assert.match(template, /svg\.addEventListener\('animationend', onAmbientBoundary, true\)/);
|
||||
assert.match(template, /svg\.addEventListener\('animationcancel', onAmbientBoundary, true\)/);
|
||||
assert.match(template, /if \(paused \|\| owner \|\| html\.hasAttribute\('data-embed'\)/);
|
||||
assert.doesNotMatch(template, /setInterval\([^)]*ambient|addEventListener\('scroll'[^)]*ambient/);
|
||||
});
|
||||
|
||||
test('animation delay is capped without changing normal authored order', () => {
|
||||
assert.equal(animateAttr({ animation: 'trace' }, 'edge', 0), ' data-animate="edge" style="--step:0"');
|
||||
assert.equal(animateAttr({ animation: 'trace' }, 'node', 8), ' data-animate="node" style="--step:8"');
|
||||
assert.equal(animateAttr({ animation: 'trace' }, 'edge', 99), ' data-animate="edge" style="--step:12"');
|
||||
assert.equal(animateAttr({}, 'edge', 99), '');
|
||||
});
|
||||
|
||||
test('only the WebM canvas scene opts into a repeatable finite motion timeline', () => {
|
||||
assert.match(template, /var motionScene = createMotionScene\(svg\)/);
|
||||
assert.match(template, /drawMotionFrame\(ctx, backgroundImage, motionScene, elapsed\)/);
|
||||
assert.match(template, /var data = serializeSvg\(scale\);/);
|
||||
assert.match(template, /getPointAtLength/);
|
||||
assert.doesNotMatch(template, /serializeSvg\(1, \{ autoTheme: true, motion: true \}\)/);
|
||||
});
|
||||
|
||||
test('Still, reduced motion, embed, share, hidden state, and stronger intent settle ambient flow', () => {
|
||||
assert.match(template, /reducedMotion\(\)/);
|
||||
assert.match(template, /html\.hasAttribute\('data-embed'\)/);
|
||||
assert.match(template, /html\.hasAttribute\('data-share-playback'\)/);
|
||||
assert.match(template, /html\.hasAttribute\('data-document-hidden'\)/);
|
||||
assert.match(template, /paused \|\| owner/);
|
||||
assert.match(template, /settleAmbient\('suppressed'\)/);
|
||||
assert.match(template, /html\[data-motion="still"\] svg\[data-animation="trace"\] \[data-animate\]/);
|
||||
assert.match(template, /@media \(prefers-reduced-motion: reduce\)/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,132 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(here, '..');
|
||||
const repoRoot = path.resolve(skillRoot, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-share-card-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', CASES[mode]),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function svgBlock(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all five renderers expose one explicit 1200x630 Share Card export', () => {
|
||||
for (const mode of Object.keys(CASES)) {
|
||||
const html = render(mode);
|
||||
assert.match(html, /data-format="share-card"/, mode);
|
||||
assert.match(html, /Share Card[\s\S]*?1200(?:×|×)630 PNG/, mode);
|
||||
assert.match(html, /var SHARE_CARD_WIDTH = 1200;/, mode);
|
||||
assert.match(html, /var SHARE_CARD_HEIGHT = 630;/, mode);
|
||||
assert.match(html, /function rasterizeShareCard\(options\)/, mode);
|
||||
assert.match(html, /format === 'share-card'/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('Share Card uses contain-only canonical geometry with fixed safe areas', () => {
|
||||
const html = render('architecture');
|
||||
assert.match(html, /var availableWidth = SHARE_CARD_WIDTH - SHARE_CARD_PADDING \* 2;/);
|
||||
assert.match(html, /var availableHeight = SHARE_CARD_HEIGHT - SHARE_CARD_HEADER - SHARE_CARD_PADDING;/);
|
||||
assert.match(html, /var fit = Math\.min\(availableWidth \/ data\.width, availableHeight \/ data\.height\);/);
|
||||
assert.match(html, /ctx\.drawImage\(img, drawX, drawY, drawWidth, drawHeight\);/);
|
||||
assert.match(html, /function canvas2dOrThrow\(canvas, label\)/);
|
||||
assert.match(html, /throw exportError\('viewer\.export\.error\.contextUnavailable'/);
|
||||
assert.match(html, /throw exportError\('viewer\.export\.error\.toBlobUnavailable'/);
|
||||
assert.match(html, /img\.onload = function \(\) \{\s*try \{/);
|
||||
assert.match(html, /function rasterizeShareCard\(options\)[\s\S]*?if \(!options\.variant\) return renderShareCard\(\);/);
|
||||
assert.match(html, /function renderShareCard\(options\)[\s\S]*?serializeSvg\(sourceScale, \{ routeSnapshot: routeSnapshot, reachSnapshot: reachSnapshot \}\)/);
|
||||
assert.match(html, /fitCanvasText\(ctx, title, [^)]+\)/);
|
||||
assert.match(html, /ARCHIFY ·/);
|
||||
assert.doesNotMatch(svgBlock(html), /share-card|Share Card|ARCHIFY ·/);
|
||||
});
|
||||
|
||||
test('Share Card is a canonical PNG with exact receipt dimensions and filename', () => {
|
||||
const html = render('workflow');
|
||||
assert.match(html, /recordExportReceipt\('share-card', blob, true, \{ width: SHARE_CARD_WIDTH, height: SHARE_CARD_HEIGHT \}\)/);
|
||||
assert.match(html, /base \+ '-share-card\.png'/);
|
||||
assert.match(html, /data-last-export-width/);
|
||||
assert.match(html, /data-last-export-height/);
|
||||
assert.match(html, /shareCard: rasterizeShareCard/);
|
||||
assert.match(html, /if \(format === 'share-card'\) return true;/);
|
||||
});
|
||||
|
||||
test('Copy Share Card reuses one canonical card blob and writes only PNG to the clipboard', () => {
|
||||
const html = render('architecture');
|
||||
assert.match(html, /data-action="copy-share-card"/);
|
||||
assert.match(html, /Copy Share Card[\s\S]*?<small class="hint">PNG to clipboard<\/small>/);
|
||||
assert.match(html, /function runCopyShareCard\(\)[\s\S]*?var blobPromise = rasterizeShareCard\(\);/);
|
||||
const copyBlock = html.match(/function runCopyShareCard\(\) \{[\s\S]*?\n \}/)?.[0] || '';
|
||||
assert.equal((copyBlock.match(/rasterizeShareCard\(\)/g) || []).length, 1);
|
||||
assert.match(copyBlock, /writePngToClipboard\(blobPromise\)/);
|
||||
assert.match(html, /new ClipboardItem\(\{ 'image\/png': blobPromise \}\)/);
|
||||
assert.match(copyBlock, /recordExportReceipt\('share-card', blob, true, \{ width: SHARE_CARD_WIDTH, height: SHARE_CARD_HEIGHT \}\)/);
|
||||
assert.match(copyBlock, /toast\(viewerText\('viewer\.export\.copiedShare'\)\)/);
|
||||
assert.match(html, /copyShareCard: runCopyShareCard/);
|
||||
});
|
||||
|
||||
test('Copy Share Card fails closed when image clipboard writing is unavailable', () => {
|
||||
const html = render('workflow');
|
||||
assert.match(html, /it\.dataset\.action === 'copy-share-card'[\s\S]*?!canCopyImage\(\)/);
|
||||
assert.match(html, /function runCopyShareCard\(\)[\s\S]*?if \(!canCopyImage\(\)\)/);
|
||||
assert.match(html, /Clipboard image write not supported by this browser/);
|
||||
assert.match(html, /button\[data-action="copy-share-card"\]/);
|
||||
assert.match(html, /document\.documentElement\.removeAttribute\('data-last-export-format'\)/);
|
||||
assert.match(html, /data-last-export-error-format', 'share-card'/);
|
||||
});
|
||||
|
||||
test('ordinary Copy PNG keeps its existing full-diagram raster path', () => {
|
||||
const html = render('sequence');
|
||||
assert.match(html, /function runCopy\(\)[\s\S]*?var blobPromise = rasterize\('png'\);/);
|
||||
assert.match(html, /runCopy\(\)[\s\S]*?writePngToClipboard\(blobPromise\)/);
|
||||
assert.doesNotMatch(svgBlock(html), /copy-share-card|Copy Share Card/);
|
||||
});
|
||||
|
||||
test('Share Card stays viewer-only and reuses export cleanup instead of source state', () => {
|
||||
const html = render('sequence');
|
||||
assert.match(html, /html\[data-embed="true"\] \.toolbar/);
|
||||
assert.match(html, /@media print[\s\S]*?\.toolbar/);
|
||||
assert.match(html, /function rasterizeShareCard\(options\)[\s\S]*?if \(!options\.variant\) return renderShareCard\(\);/);
|
||||
assert.match(html, /function renderShareCard\(options\)[\s\S]*?serializeSvg\(sourceScale, \{ routeSnapshot: routeSnapshot, reachSnapshot: reachSnapshot \}\)/);
|
||||
assert.match(html, /if \(!data\.canonicalStateClean\) return Promise\.reject\(exportError\('viewer\.export\.error\.viewerState'\)\);/);
|
||||
assert.match(html, /canonicalStateClean/);
|
||||
assert.doesNotMatch(svgBlock(html), /data-last-export-|data-format="share-card"/);
|
||||
});
|
||||
|
||||
test('the skill and every README make the optional Share Card discoverable', () => {
|
||||
const viewer = fs.readFileSync(path.join(skillRoot, 'references', 'viewer-runtime.md'), 'utf8');
|
||||
assert.match(viewer, /optional 1200(?:×|x)630 Share Card PNG/i);
|
||||
assert.match(viewer, /current theme and visual preset/i);
|
||||
assert.match(viewer, /never claim(?:s|ing)? validation/i);
|
||||
assert.match(viewer, /Copy Share Card/i);
|
||||
|
||||
for (const readme of ['README.md', 'README_EN.md', 'README_ZH.md']) {
|
||||
const text = fs.readFileSync(path.join(repoRoot, readme), 'utf8');
|
||||
assert.match(text, /Share Card/i, readme);
|
||||
assert.match(text, /1200(?:×|x)630/, readme);
|
||||
assert.match(text, /copy|复制/i, readme);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,562 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { ChromeVisualBrowser, findChrome } from '../bin/visual-check.mjs';
|
||||
import { DIAGRAM_TYPES, DIAGRAM_TYPE_LABELS } from '../../scripts/site-copy.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '../..');
|
||||
const runtimePath = path.join(repoRoot, 'docs/assets/site-language.js');
|
||||
const navigationPath = path.join(repoRoot, 'docs/assets/site-navigation.css');
|
||||
const integrationEnabled = process.env.ARCHIFY_SITE_INTEGRATION === '1';
|
||||
const chromePath = integrationEnabled && process.env.ARCHIFY_CHROME ? findChrome() : null;
|
||||
|
||||
function loadRuntime({
|
||||
url = 'https://example.test/',
|
||||
values = new Map(),
|
||||
storageError = false,
|
||||
historyError = false,
|
||||
source = runtimePath,
|
||||
} = {}) {
|
||||
const localStorage = {
|
||||
getItem(key) {
|
||||
if (storageError) throw new Error('storage unavailable');
|
||||
return values.has(key) ? values.get(key) : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
if (storageError) throw new Error('storage unavailable');
|
||||
values.set(key, String(value));
|
||||
},
|
||||
};
|
||||
let currentUrl = new URL(url);
|
||||
const location = {};
|
||||
function syncLocation() {
|
||||
location.href = currentUrl.href;
|
||||
location.search = currentUrl.search;
|
||||
location.pathname = currentUrl.pathname;
|
||||
location.hash = currentUrl.hash;
|
||||
}
|
||||
syncLocation();
|
||||
const window = {
|
||||
location,
|
||||
history: {
|
||||
replaceState(_state, _title, next) {
|
||||
if (historyError) throw new Error('history unavailable');
|
||||
currentUrl = new URL(next, currentUrl);
|
||||
syncLocation();
|
||||
},
|
||||
},
|
||||
localStorage,
|
||||
};
|
||||
vm.runInNewContext(fs.readFileSync(source, 'utf8'), { window, URL, URLSearchParams });
|
||||
return { language: window.ArchifySiteLanguage, values, url: () => new URL(currentUrl) };
|
||||
}
|
||||
|
||||
async function evaluate(browser, sessionId, expression) {
|
||||
const response = await browser.cdp.send('Runtime.evaluate', {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
}, sessionId);
|
||||
if (response.exceptionDetails) {
|
||||
throw new Error(response.exceptionDetails.exception?.description
|
||||
|| response.exceptionDetails.text
|
||||
|| 'Runtime.evaluate failed');
|
||||
}
|
||||
return response.result?.value;
|
||||
}
|
||||
|
||||
async function navigate(browser, sessionId, url) {
|
||||
const loaded = browser.cdp.waitFor('Page.loadEventFired', sessionId);
|
||||
const navigation = await browser.cdp.send('Page.navigate', { url }, sessionId);
|
||||
if (navigation.errorText) throw new Error(`Chrome navigation failed: ${navigation.errorText}`);
|
||||
await loaded;
|
||||
}
|
||||
|
||||
async function clickAndNavigate(browser, sessionId, selector) {
|
||||
const loaded = browser.cdp.waitFor('Page.loadEventFired', sessionId);
|
||||
await evaluate(browser, sessionId, `(function () {
|
||||
var link = document.querySelector(${JSON.stringify(selector)});
|
||||
if (!link) throw new Error('Missing navigation link: ' + ${JSON.stringify(selector)});
|
||||
link.click();
|
||||
})()`);
|
||||
await loaded;
|
||||
}
|
||||
|
||||
function startStaticServer(root) {
|
||||
const server = http.createServer((request, response) => {
|
||||
const requestUrl = new URL(request.url || '/', 'http://127.0.0.1');
|
||||
const relative = decodeURIComponent(requestUrl.pathname).replace(/^\/+/, '') || 'index.html';
|
||||
const requestedPath = path.resolve(root, relative);
|
||||
if (!requestedPath.startsWith(`${path.resolve(root)}${path.sep}`)) {
|
||||
response.writeHead(403).end('Forbidden');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = fs.readFileSync(requestedPath);
|
||||
const contentType = requestedPath.endsWith('.css') ? 'text/css'
|
||||
: requestedPath.endsWith('.js') ? 'text/javascript'
|
||||
: requestedPath.endsWith('.json') ? 'application/json'
|
||||
: 'text/html';
|
||||
response.writeHead(200, { 'content-type': `${contentType}; charset=utf-8` });
|
||||
response.end(body);
|
||||
} catch (_) {
|
||||
response.writeHead(404).end('Not found');
|
||||
}
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
test('site language runtime normalizes one entry parameter into one durable preference', () => {
|
||||
const canonical = loadRuntime({ values: new Map([['archify-lang', 'zh']]) });
|
||||
assert.equal(canonical.language.read(), 'zh');
|
||||
|
||||
for (const legacyKey of ['archify-gallery-language', 'archify-guide-language']) {
|
||||
const legacy = loadRuntime({ values: new Map([[legacyKey, 'zh']]) });
|
||||
assert.equal(legacy.language.read(), 'zh', `${legacyKey} must remain readable during migration`);
|
||||
assert.equal(legacy.values.get('archify-lang'), 'zh', `${legacyKey} must migrate to the canonical key`);
|
||||
}
|
||||
|
||||
const canonicalWins = loadRuntime({
|
||||
values: new Map([
|
||||
['archify-lang', 'en'],
|
||||
['archify-gallery-language', 'zh'],
|
||||
['archify-guide-language', 'zh'],
|
||||
]),
|
||||
});
|
||||
assert.equal(canonicalWins.language.read(), 'en');
|
||||
|
||||
const secondLegacyFallback = loadRuntime({
|
||||
values: new Map([
|
||||
['archify-gallery-language', 'fr'],
|
||||
['archify-guide-language', 'zh'],
|
||||
]),
|
||||
});
|
||||
assert.equal(secondLegacyFallback.language.read(), 'zh');
|
||||
assert.equal(secondLegacyFallback.values.get('archify-lang'), 'zh');
|
||||
|
||||
const conflictingLegacy = loadRuntime({
|
||||
values: new Map([
|
||||
['archify-gallery-language', 'zh'],
|
||||
['archify-guide-language', 'en'],
|
||||
]),
|
||||
});
|
||||
assert.equal(conflictingLegacy.language.read(), 'zh');
|
||||
assert.equal(conflictingLegacy.values.get('archify-lang'), 'zh');
|
||||
conflictingLegacy.values.set('archify-gallery-language', 'en');
|
||||
const migrated = loadRuntime({ values: conflictingLegacy.values });
|
||||
assert.equal(migrated.language.read(), 'zh', 'the canonical migration must win on later page loads');
|
||||
|
||||
const explicit = loadRuntime({
|
||||
url: 'https://example.test/guide.html?lang=en&type=workflow#chooser',
|
||||
values: new Map([['archify-lang', 'zh']]),
|
||||
});
|
||||
assert.equal(explicit.language.read(), 'en');
|
||||
assert.equal(explicit.values.get('archify-lang'), 'en');
|
||||
assert.equal(explicit.url().searchParams.has('lang'), false);
|
||||
assert.equal(explicit.url().searchParams.get('type'), 'workflow');
|
||||
assert.equal(explicit.url().hash, '#chooser');
|
||||
|
||||
const historyBlocked = loadRuntime({
|
||||
url: 'https://example.test/guide.html?lang=zh&type=workflow#chooser',
|
||||
values: new Map([['archify-lang', 'en']]),
|
||||
historyError: true,
|
||||
});
|
||||
assert.equal(historyBlocked.language.read(), 'zh');
|
||||
assert.equal(historyBlocked.values.get('archify-lang'), 'zh');
|
||||
assert.equal(historyBlocked.url().searchParams.get('lang'), 'zh');
|
||||
|
||||
assert.equal(explicit.language.write('zh'), 'zh');
|
||||
const refreshed = loadRuntime({ url: explicit.url().href, values: explicit.values });
|
||||
assert.equal(refreshed.language.read(), 'zh');
|
||||
|
||||
const unsupported = loadRuntime({
|
||||
url: 'https://example.test/?lang=fr',
|
||||
values: new Map([['archify-lang', 'zh']]),
|
||||
});
|
||||
assert.equal(unsupported.language.read(), 'zh');
|
||||
assert.equal(unsupported.url().searchParams.has('lang'), false);
|
||||
|
||||
const defaultLanguage = loadRuntime();
|
||||
assert.equal(defaultLanguage.language.read(), 'en');
|
||||
|
||||
const blocked = loadRuntime({ storageError: true });
|
||||
assert.equal(blocked.language.read(), 'en');
|
||||
assert.equal(blocked.language.write('zh'), 'zh');
|
||||
|
||||
const source = fs.readFileSync(runtimePath, 'utf8');
|
||||
assert.match(source, /archify-gallery-language/);
|
||||
assert.match(source, /archify-guide-language/);
|
||||
assert.doesNotMatch(source, /navigator\.language|detectBrowserLanguage|select\s*:/);
|
||||
});
|
||||
|
||||
test('custom site builders emit every shared site asset and preserve entry, navigation, selection, and refresh state', {
|
||||
skip: integrationEnabled ? false : 'Run through the serialized site integration gate.',
|
||||
}, () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-site-language-'));
|
||||
try {
|
||||
const builds = [
|
||||
{ script: 'build-start.mjs', args: [path.join(tmp, 'start-site/start.html')], root: 'start-site' },
|
||||
{ script: 'build-guide.mjs', args: [path.join(tmp, 'guide-site/guide.html')], root: 'guide-site' },
|
||||
{ script: 'build-gallery.mjs', args: [path.join(tmp, 'gallery-site')], root: 'gallery-site' },
|
||||
];
|
||||
|
||||
for (const build of builds) {
|
||||
execFileSync(process.execPath, [path.join(repoRoot, 'scripts', build.script), ...build.args]);
|
||||
for (const asset of ['site-language.js', 'site-navigation.css']) {
|
||||
const emitted = path.join(tmp, build.root, 'assets', asset);
|
||||
const canonicalAsset = path.join(repoRoot, 'docs/assets', asset);
|
||||
assert.ok(fs.existsSync(emitted), `${build.script}: ${asset} missing from custom output`);
|
||||
assert.equal(fs.readFileSync(emitted, 'utf8'), fs.readFileSync(canonicalAsset, 'utf8'));
|
||||
}
|
||||
}
|
||||
|
||||
const emittedRuntime = path.join(tmp, 'start-site/assets/site-language.js');
|
||||
const values = new Map();
|
||||
|
||||
const landing = loadRuntime({
|
||||
url: 'https://example.test/?lang=zh&utm_source=readme#proof',
|
||||
values,
|
||||
source: emittedRuntime,
|
||||
});
|
||||
assert.equal(landing.language.read(), 'zh');
|
||||
assert.equal(values.get('archify-lang'), 'zh');
|
||||
assert.equal(landing.url().searchParams.has('lang'), false);
|
||||
assert.equal(landing.url().searchParams.get('utm_source'), 'readme');
|
||||
assert.equal(landing.url().hash, '#proof');
|
||||
|
||||
for (const page of ['gallery.html', 'guide.html', 'start.html']) {
|
||||
const navigation = loadRuntime({ url: `https://example.test/${page}`, values, source: emittedRuntime });
|
||||
assert.equal(navigation.language.read(), 'zh', page);
|
||||
}
|
||||
|
||||
const explicitEnglish = loadRuntime({
|
||||
url: 'https://example.test/guide.html?lang=en#recipes',
|
||||
values,
|
||||
source: emittedRuntime,
|
||||
});
|
||||
assert.equal(explicitEnglish.language.read(), 'en');
|
||||
assert.equal(values.get('archify-lang'), 'en');
|
||||
assert.equal(explicitEnglish.url().searchParams.has('lang'), false);
|
||||
|
||||
explicitEnglish.language.write('zh');
|
||||
assert.equal(explicitEnglish.url().searchParams.has('lang'), false);
|
||||
assert.equal(explicitEnglish.url().hash, '#recipes');
|
||||
|
||||
const refreshed = loadRuntime({ url: explicitEnglish.url().href, values, source: emittedRuntime });
|
||||
assert.equal(refreshed.language.read(), 'zh');
|
||||
|
||||
const nextPage = loadRuntime({ url: 'https://example.test/gallery.html', values, source: emittedRuntime });
|
||||
assert.equal(nextPage.language.read(), 'zh');
|
||||
|
||||
nextPage.language.write('en');
|
||||
const refreshedEnglish = loadRuntime({ url: nextPage.url().href, values, source: emittedRuntime });
|
||||
assert.equal(refreshedEnglish.language.read(), 'en');
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('all site pages consume one language runtime and one navigation contract', () => {
|
||||
const pages = [
|
||||
'docs/index.html',
|
||||
'scripts/gallery-template.html',
|
||||
'scripts/guide-template.html',
|
||||
'scripts/start-template.html',
|
||||
'docs/gallery.html',
|
||||
'docs/guide.html',
|
||||
'docs/start.html',
|
||||
];
|
||||
|
||||
for (const relative of pages) {
|
||||
const html = fs.readFileSync(path.join(repoRoot, relative), 'utf8');
|
||||
assert.match(html, /<script src="assets\/site-language\.js"><\/script>/, `${relative}: shared runtime missing`);
|
||||
assert.match(html, /<link rel="stylesheet" href="assets\/site-navigation\.css">/, `${relative}: shared navigation missing`);
|
||||
assert.match(html, /<nav class="site-nav" aria-label="Primary navigation">/, `${relative}: canonical navigation root missing`);
|
||||
assert.match(html, /ArchifySiteLanguage\.read\(/, `${relative}: shared language read missing`);
|
||||
assert.match(html, /ArchifySiteLanguage\.write\(/, `${relative}: shared language write missing`);
|
||||
assert.match(html, /href="guide\.html"/, `${relative}: Guide navigation missing`);
|
||||
assert.match(html, /href="gallery\.html"/, `${relative}: Proof Lab navigation missing`);
|
||||
assert.match(html, /href="start\.html"/, `${relative}: Start navigation missing`);
|
||||
assert.match(html, /class="btn btn-primary nav-cta"/, `${relative}: install action missing`);
|
||||
assert.doesNotMatch(html, /(?:^|\s)nav\s*\{/, `${relative}: inline navigation layout bypasses the shared contract`);
|
||||
assert.doesNotMatch(html, /\.nav-right\s*\{/, `${relative}: inline navigation actions bypass the shared contract`);
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
/localStorage\.setItem\(['"]archify-(?:lang|gallery-language|guide-language)['"]/,
|
||||
`${relative}: page bypasses the shared language writer`,
|
||||
);
|
||||
}
|
||||
|
||||
const navigation = fs.readFileSync(navigationPath, 'utf8');
|
||||
assert.match(navigation, /\.site-nav\s*\{/);
|
||||
assert.match(navigation, /\.site-nav \.nav-right\s*\{/);
|
||||
assert.match(navigation, /@media \(max-width: 640px\)/);
|
||||
});
|
||||
|
||||
test('site page identity paths localize with the selected language', () => {
|
||||
const pages = [
|
||||
{ paths: ['scripts/guide-template.html', 'docs/guide.html'], en: '/ guide', zh: '/ 场景指南' },
|
||||
{ paths: ['scripts/gallery-template.html', 'docs/gallery.html'], en: '/ proof lab', zh: '/ 验证作品集' },
|
||||
{ paths: ['scripts/start-template.html', 'docs/start.html'], en: '/ start', zh: '/ 快速上手' },
|
||||
];
|
||||
|
||||
for (const page of pages) {
|
||||
for (const relative of page.paths) {
|
||||
const html = fs.readFileSync(path.join(repoRoot, relative), 'utf8');
|
||||
assert.ok(
|
||||
html.includes(`<span class="nav-logo-path" data-en="${page.en}" data-zh="${page.zh}">${page.en}</span>`),
|
||||
`${relative}: page identity path must expose matching English and Chinese copy`,
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/querySelectorAll\('\[data-en\]\[data-zh\]'\)/,
|
||||
`${relative}: language changes must update bilingual page identity copy`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('proof gallery type filters localize with the selected language', () => {
|
||||
const filters = DIAGRAM_TYPES.map((type) => ({
|
||||
type,
|
||||
en: DIAGRAM_TYPE_LABELS.en[type],
|
||||
zh: DIAGRAM_TYPE_LABELS.zh[type],
|
||||
}));
|
||||
|
||||
const template = fs.readFileSync(path.join(repoRoot, 'scripts/gallery-template.html'), 'utf8');
|
||||
for (const filter of filters) {
|
||||
const placeholder = filter.type.toUpperCase();
|
||||
assert.ok(
|
||||
template.includes(`data-filter="${filter.type}" aria-pressed="false" data-en="[[DIAGRAM_TYPE_${placeholder}_EN]]" data-zh="[[DIAGRAM_TYPE_${placeholder}_ZH]]"`),
|
||||
`gallery template: ${filter.type} filter must consume the shared copy source`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const relative of ['docs/gallery.html']) {
|
||||
const html = fs.readFileSync(path.join(repoRoot, relative), 'utf8');
|
||||
assert.match(
|
||||
html,
|
||||
/<button(?=[^>]*data-filter="all")(?=[^>]*data-en="All \/ [^"]+")(?=[^>]*data-zh="全部配方 \/ [^"]+")[^>]*>All \/ [^<]+<\/button>/,
|
||||
`${relative}: all filter must expose English and Chinese copy`,
|
||||
);
|
||||
for (const filter of filters) {
|
||||
const bilingualFilter = new RegExp(
|
||||
`<button(?=[^>]*data-filter="${filter.type}")(?=[^>]*data-en="${filter.en}")(?=[^>]*data-zh="${filter.zh}")[^>]*>${filter.en}<\\/button>`,
|
||||
);
|
||||
assert.match(html, bilingualFilter, `${relative}: ${filter.type} filter must expose English and Chinese copy`);
|
||||
}
|
||||
assert.match(
|
||||
html,
|
||||
/querySelectorAll\('\[data-en\]\[data-zh\]'\)/,
|
||||
`${relative}: language changes must update bilingual gallery filters`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('scenario guide type filters use consistent Chinese diagram names', () => {
|
||||
const template = fs.readFileSync(path.join(repoRoot, 'scripts/guide-template.html'), 'utf8');
|
||||
assert.match(template, /var types = \[\[DIAGRAM_TYPES_JSON\]\];/);
|
||||
assert.match(template, /var labels = \[\[DIAGRAM_TYPE_LABELS_JSON\]\];/);
|
||||
|
||||
const html = fs.readFileSync(path.join(repoRoot, 'docs/guide.html'), 'utf8');
|
||||
assert.ok(
|
||||
html.includes(`var labels = ${JSON.stringify(DIAGRAM_TYPE_LABELS)};`),
|
||||
'docs/guide.html: Guide filters must use the shared Chinese diagram names',
|
||||
);
|
||||
});
|
||||
|
||||
test('real Chrome preserves language through entry, navigation, selection, refresh, and consistent navigation chrome', {
|
||||
skip: chromePath ? false : 'Set ARCHIFY_CHROME to run the real site regression.',
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const docsRoot = path.join(repoRoot, 'docs');
|
||||
const server = startStaticServer(docsRoot);
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
const browser = new ChromeVisualBrowser(chromePath);
|
||||
|
||||
try {
|
||||
const sessionId = await browser.sessionPromise;
|
||||
await browser.cdp.send('Emulation.setDeviceMetricsOverride', {
|
||||
width: 1440,
|
||||
height: 900,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
}, sessionId);
|
||||
|
||||
await navigate(browser, sessionId, `${baseUrl}/index.html`);
|
||||
await evaluate(browser, sessionId, 'localStorage.clear()');
|
||||
|
||||
await evaluate(browser, sessionId, "localStorage.setItem('archify-guide-language', 'zh')");
|
||||
await navigate(browser, sessionId, `${baseUrl}/index.html`);
|
||||
assert.deepEqual(await evaluate(browser, sessionId, `({
|
||||
language: document.documentElement.lang,
|
||||
stored: localStorage.getItem('archify-lang')
|
||||
})`), { language: 'zh-CN', stored: 'zh' });
|
||||
|
||||
await evaluate(browser, sessionId, 'localStorage.clear()');
|
||||
await navigate(browser, sessionId, `${baseUrl}/index.html?lang=zh&utm_source=browser-test#proof`);
|
||||
|
||||
let state = await evaluate(browser, sessionId, `({
|
||||
language: document.documentElement.lang,
|
||||
stored: localStorage.getItem('archify-lang'),
|
||||
langQuery: new URL(location.href).searchParams.get('lang'),
|
||||
campaign: new URL(location.href).searchParams.get('utm_source'),
|
||||
hash: location.hash
|
||||
})`);
|
||||
assert.deepEqual(state, {
|
||||
language: 'zh-CN', stored: 'zh', langQuery: null, campaign: 'browser-test', hash: '#proof',
|
||||
});
|
||||
|
||||
await clickAndNavigate(browser, sessionId, '.site-nav a[href="gallery.html"]');
|
||||
assert.equal(await evaluate(browser, sessionId, 'document.documentElement.lang'), 'zh-CN');
|
||||
assert.equal(await evaluate(browser, sessionId, 'document.querySelector(".nav-logo-path").textContent'), '/ 验证作品集');
|
||||
assert.deepEqual(await evaluate(browser, sessionId, `Array.from(document.querySelectorAll('[data-filter]')).map(function (button) {
|
||||
return button.textContent;
|
||||
})`), ['全部配方 / 11', '架构图', '工作流', '时序图', '数据流', '生命周期']);
|
||||
|
||||
await evaluate(browser, sessionId, 'document.querySelector(\'[data-filter="architecture"]\').click()');
|
||||
state = await evaluate(browser, sessionId, `({
|
||||
language: document.documentElement.lang,
|
||||
selected: document.querySelector('[data-filter="architecture"]').getAttribute('aria-pressed'),
|
||||
typeQuery: new URL(location.href).searchParams.get('type'),
|
||||
visibleCount: document.querySelectorAll('.showcase-card:not([hidden])').length,
|
||||
onlyArchitecture: Array.from(document.querySelectorAll('.showcase-card:not([hidden])')).every(function (card) {
|
||||
return card.getAttribute('data-type') === 'architecture';
|
||||
})
|
||||
})`);
|
||||
assert.deepEqual(state, {
|
||||
language: 'zh-CN', selected: 'true', typeQuery: 'architecture', visibleCount: 2, onlyArchitecture: true,
|
||||
});
|
||||
|
||||
let loaded = browser.cdp.waitFor('Page.loadEventFired', sessionId);
|
||||
await browser.cdp.send('Page.reload', {}, sessionId);
|
||||
await loaded;
|
||||
assert.deepEqual(await evaluate(browser, sessionId, `({
|
||||
language: document.documentElement.lang,
|
||||
selected: document.querySelector('[data-filter="architecture"]').getAttribute('aria-pressed'),
|
||||
visibleCount: document.querySelectorAll('.showcase-card:not([hidden])').length
|
||||
})`), { language: 'zh-CN', selected: 'true', visibleCount: 2 });
|
||||
|
||||
await evaluate(browser, sessionId, 'document.getElementById("language").click()');
|
||||
assert.equal(await evaluate(browser, sessionId, 'document.documentElement.lang'), 'en');
|
||||
assert.equal(await evaluate(browser, sessionId, 'document.querySelector(".nav-logo-path").textContent'), '/ proof lab');
|
||||
assert.deepEqual(await evaluate(browser, sessionId, `Array.from(document.querySelectorAll('[data-filter]')).map(function (button) {
|
||||
return button.textContent;
|
||||
})`), ['All / 11', 'Architecture', 'Workflow', 'Sequence', 'Data flow', 'Lifecycle']);
|
||||
|
||||
loaded = browser.cdp.waitFor('Page.loadEventFired', sessionId);
|
||||
await browser.cdp.send('Page.reload', {}, sessionId);
|
||||
await loaded;
|
||||
assert.equal(await evaluate(browser, sessionId, 'document.documentElement.lang'), 'en');
|
||||
|
||||
await clickAndNavigate(browser, sessionId, '.site-nav a[href="guide.html"]');
|
||||
assert.equal(await evaluate(browser, sessionId, 'document.documentElement.lang'), 'en');
|
||||
await navigate(browser, sessionId, `${baseUrl}/guide.html?lang=zh#recipes`);
|
||||
state = await evaluate(browser, sessionId, `({
|
||||
language: document.documentElement.lang,
|
||||
stored: localStorage.getItem('archify-lang'),
|
||||
langQuery: new URL(location.href).searchParams.get('lang'),
|
||||
hash: location.hash
|
||||
})`);
|
||||
assert.deepEqual(state, { language: 'zh-CN', stored: 'zh', langQuery: null, hash: '#recipes' });
|
||||
assert.equal(await evaluate(browser, sessionId, 'document.querySelector(".nav-logo-path").textContent'), '/ 场景指南');
|
||||
assert.deepEqual(await evaluate(browser, sessionId, `Array.from(document.querySelectorAll('#filters [data-filter]')).map(function (button) {
|
||||
return button.textContent;
|
||||
})`), ['全部配方', '架构图', '工作流', '时序图', '数据流', '生命周期']);
|
||||
|
||||
await evaluate(browser, sessionId, 'document.querySelector(\'#filters [data-filter="sequence"]\').click()');
|
||||
state = await evaluate(browser, sessionId, `({
|
||||
language: document.documentElement.lang,
|
||||
selected: document.querySelector('#filters [data-filter="sequence"]').classList.contains('active'),
|
||||
visibleCount: document.querySelectorAll('#cards .card').length,
|
||||
onlySequence: Array.from(document.querySelectorAll('#cards .card .card-type')).every(function (label) {
|
||||
return label.textContent === 'sequence';
|
||||
}),
|
||||
labels: Array.from(document.querySelectorAll('#filters [data-filter]')).map(function (button) {
|
||||
return button.textContent;
|
||||
})
|
||||
})`);
|
||||
assert.deepEqual(state, {
|
||||
language: 'zh-CN',
|
||||
selected: true,
|
||||
visibleCount: 2,
|
||||
onlySequence: true,
|
||||
labels: ['全部配方', '架构图', '工作流', '时序图', '数据流', '生命周期'],
|
||||
});
|
||||
|
||||
await clickAndNavigate(browser, sessionId, '.site-nav a[href="start.html"]');
|
||||
assert.equal(await evaluate(browser, sessionId, 'document.documentElement.lang'), 'zh-CN');
|
||||
assert.equal(await evaluate(browser, sessionId, 'new URL(location.href).searchParams.has("lang")'), false);
|
||||
assert.equal(await evaluate(browser, sessionId, 'document.querySelector(".nav-logo-path").textContent'), '/ 快速上手');
|
||||
|
||||
const pages = ['index.html', 'gallery.html', 'guide.html', 'start.html'];
|
||||
const desktopReceipts = [];
|
||||
for (const page of pages) {
|
||||
await navigate(browser, sessionId, `${baseUrl}/${page}`);
|
||||
desktopReceipts.push(await evaluate(browser, sessionId, `(function () {
|
||||
var nav = document.querySelector('.site-nav');
|
||||
var logo = nav.querySelector('.nav-logo-text');
|
||||
var actions = nav.querySelector('.nav-right');
|
||||
var language = nav.querySelector('.btn-lang');
|
||||
var cta = nav.querySelector('.nav-cta');
|
||||
var navStyle = getComputedStyle(nav);
|
||||
var logoStyle = getComputedStyle(logo);
|
||||
var actionsStyle = getComputedStyle(actions);
|
||||
var languageStyle = getComputedStyle(language);
|
||||
var ctaStyle = getComputedStyle(cta);
|
||||
return {
|
||||
height: nav.getBoundingClientRect().height,
|
||||
position: navStyle.position,
|
||||
paddingLeft: navStyle.paddingLeft,
|
||||
background: navStyle.backgroundColor,
|
||||
borderBottom: navStyle.borderBottomWidth + ' ' + navStyle.borderBottomStyle + ' ' + navStyle.borderBottomColor,
|
||||
logoFont: logoStyle.fontFamily,
|
||||
logoSize: logoStyle.fontSize,
|
||||
actionGap: actionsStyle.gap,
|
||||
languageHeight: language.getBoundingClientRect().height,
|
||||
languageRadius: languageStyle.borderRadius,
|
||||
ctaHeight: cta.getBoundingClientRect().height,
|
||||
ctaRadius: ctaStyle.borderRadius,
|
||||
linkCount: nav.querySelectorAll('.nav-link').length
|
||||
};
|
||||
})()`));
|
||||
}
|
||||
for (const receipt of desktopReceipts.slice(1)) assert.deepEqual(receipt, desktopReceipts[0]);
|
||||
|
||||
await browser.cdp.send('Emulation.setDeviceMetricsOverride', {
|
||||
width: 390,
|
||||
height: 844,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: true,
|
||||
}, sessionId);
|
||||
for (const page of pages) {
|
||||
await navigate(browser, sessionId, `${baseUrl}/${page}`);
|
||||
const mobile = await evaluate(browser, sessionId, `(function () {
|
||||
var nav = document.querySelector('.site-nav');
|
||||
var rect = nav.getBoundingClientRect();
|
||||
var actions = nav.querySelector('.nav-right').getBoundingClientRect();
|
||||
return {
|
||||
height: rect.height,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
actionsRight: actions.right,
|
||||
linkDisplay: getComputedStyle(nav.querySelector('.nav-link')).display
|
||||
};
|
||||
})()`);
|
||||
assert.deepEqual(mobile, { height: 60, left: 0, right: 390, actionsRight: 370, linkDisplay: 'none' }, page);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
process.env.ARCHIFY_SITE_INTEGRATION = '1';
|
||||
|
||||
// GitHub-hosted Linux runners require the same explicit Chrome sandbox opt-out
|
||||
// already used by this workflow's other real-browser regression steps.
|
||||
if (process.platform === 'linux' && process.env.GITHUB_ACTIONS === 'true') {
|
||||
process.env.ARCHIFY_CHROME_NO_SANDBOX = '1';
|
||||
}
|
||||
|
||||
await import('./site-language-continuity.test.mjs');
|
||||
@@ -0,0 +1,84 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.join(here, '..');
|
||||
const skill = readFileSync(path.join(skillRoot, 'SKILL.md'), 'utf8');
|
||||
const authoringContract = readFileSync(path.join(skillRoot, 'references', 'authoring-contract.md'), 'utf8');
|
||||
const frontmatter = skill.match(/^---\n([\s\S]*?)\n---/);
|
||||
|
||||
test('skill description is portable across 1024-character runtimes and remains searchable', () => {
|
||||
assert.ok(frontmatter, 'SKILL.md must start with YAML frontmatter');
|
||||
const description = frontmatter[1].match(/^description:\s*(.+)$/m)?.[1]?.trim();
|
||||
assert.ok(description, 'frontmatter must include a one-line description');
|
||||
assert.ok(description.length <= 1024, `description is ${description.length} characters; maximum is 1024`);
|
||||
assert.ok(Buffer.byteLength(description, 'utf8') <= 1024, 'description must also fit a 1024-byte runtime limit');
|
||||
|
||||
for (const trigger of ['architecture', 'workflow', 'sequence', 'data-flow', 'lifecycle', 'Mermaid']) {
|
||||
assert.match(description, new RegExp(`\\b${trigger}\\b`, 'i'), `description must retain the ${trigger} trigger`);
|
||||
}
|
||||
assert.match(description, /standalone HTML/i);
|
||||
assert.match(description, /Use when/i);
|
||||
});
|
||||
|
||||
test('literal packaged-skill path references resolve inside the installed skill root', () => {
|
||||
const references = [...skill.matchAll(/`((?:assets|bin|examples|recipes|references|renderers|schemas|scripts)\/[^`\s]+)`/g)]
|
||||
.map((match) => match[1])
|
||||
.filter((reference) => !/[<>{}*\[\]]/.test(reference));
|
||||
|
||||
assert.ok(references.length > 0, 'expected literal packaged-skill references');
|
||||
for (const reference of new Set(references)) {
|
||||
assert.equal(existsSync(path.join(skillRoot, reference)), true, `SKILL.md references missing packaged path ${reference}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('main skill stays a bounded authoring router with progressive references', () => {
|
||||
const lines = skill.trimEnd().split('\n');
|
||||
assert.ok(lines.length <= 160, `SKILL.md is ${lines.length} lines; keep the entrypoint at 160 or fewer`);
|
||||
for (const reference of [
|
||||
'references/authoring-contract.md',
|
||||
'references/viewer-runtime.md',
|
||||
'references/delivery-contract.md',
|
||||
]) {
|
||||
assert.match(skill, new RegExp(reference.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
assert.equal(existsSync(path.join(skillRoot, reference)), true, `${reference} must ship with the skill`);
|
||||
}
|
||||
});
|
||||
|
||||
test('update awareness is notification-only and never replaces the requested workflow', () => {
|
||||
assert.match(skill, /`scripts\/check-update\.mjs`/);
|
||||
assert.match(skill, /`silent`[\s\S]*without mentioning/i);
|
||||
assert.match(skill, /`update_available`[\s\S]*compact notice/i);
|
||||
assert.match(skill, /information, not permission/i);
|
||||
assert.match(skill, /`severity` is `security`[\s\S]*security update[\s\S]*emphasis only, never user autonomy/i);
|
||||
assert.match(skill, /continue the user's original task/i);
|
||||
assert.match(skill, /installed version unchanged/i);
|
||||
assert.doesNotMatch(skill, /npx skills update|gh skill update/i);
|
||||
});
|
||||
|
||||
test('language behavior stays within the bounded locale contract', () => {
|
||||
assert.match(skill, /one primary authored language/);
|
||||
assert.match(skill, /explicit user choice; otherwise follow the request or conversation's dominant language/);
|
||||
assert.match(skill, /`meta\.locale` controls only renderer-owned Viewer UI/);
|
||||
assert.match(skill, /use `"en"` or `"zh-CN"`/);
|
||||
assert.match(skill, /For every other language, omit `meta\.locale`/);
|
||||
assert.match(skill, /fixed Viewer UI and `<html lang>` fall back to English/);
|
||||
assert.match(skill, /renderer never translates authored content/i);
|
||||
assert.match(skill, /product names.*code identifiers.*protocols.*API paths.*environment names/);
|
||||
assert.match(authoringContract, /`meta\.locale` controls only renderer-owned reader surfaces/);
|
||||
assert.match(authoringContract, /outside `en` and `zh-CN`/);
|
||||
assert.match(authoringContract, /artifact is\s+not fully localized/);
|
||||
assert.match(authoringContract, /Do not silently substitute\s+`zh-CN` for another language or Chinese locale/);
|
||||
assert.match(authoringContract, /It never translates authored content/);
|
||||
assert.match(authoringContract, /Renderer-owned default legend labels follow `meta\.locale`/);
|
||||
assert.match(authoringContract, /The fallback\s+applies only to renderer-owned surfaces/);
|
||||
});
|
||||
|
||||
test('skill keeps the title hierarchy compact by default', () => {
|
||||
assert.match(skill, /Omit `meta\.subtitle` by default/);
|
||||
assert.match(skill, /Never invent a subtitle that restates the title, nodes, or cards/);
|
||||
assert.match(authoringContract, /omitted or blank subtitle must not leave an empty visual row/);
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import crypto from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(here, '..', '..');
|
||||
const checker = path.join(repoRoot, 'scripts', 'check-stable-update-manifest.mjs');
|
||||
|
||||
function git(root, args) {
|
||||
return spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
||||
}
|
||||
|
||||
function annotatedTaggerTime(root, tag) {
|
||||
const result = git(root, [
|
||||
'for-each-ref',
|
||||
'--format=%(taggerdate:unix)',
|
||||
`refs/tags/${tag}`,
|
||||
]);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout.trim(), /^\d+$/);
|
||||
return new Date(Number(result.stdout.trim()) * 1_000)
|
||||
.toISOString()
|
||||
.replace('.000Z', 'Z');
|
||||
}
|
||||
|
||||
function writeJson(target, value) {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function runCheck(root, archive, extraArguments = []) {
|
||||
return spawnSync(process.execPath, [
|
||||
checker,
|
||||
'--root', root,
|
||||
'--archive', archive,
|
||||
'--tag', 'v3.0.0',
|
||||
...extraArguments,
|
||||
], { encoding: 'utf8' });
|
||||
}
|
||||
|
||||
test('stable release gate binds manifest tag, tree, and final archive digest', () => {
|
||||
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-stable-manifest-'));
|
||||
try {
|
||||
writeJson(path.join(fixture, 'archify', 'package.json'), { version: '3.0.0' });
|
||||
fs.writeFileSync(path.join(fixture, 'archify', 'SKILL.md'), 'stable fixture\n');
|
||||
assert.equal(git(fixture, ['init']).status, 0);
|
||||
assert.equal(git(fixture, ['add', 'archify']).status, 0);
|
||||
assert.equal(git(fixture, [
|
||||
'-c', 'user.name=Archify Test',
|
||||
'-c', 'user.email=archify@example.invalid',
|
||||
'commit', '-m', 'stable fixture',
|
||||
]).status, 0);
|
||||
assert.equal(git(fixture, [
|
||||
'-c', 'user.name=Archify Test',
|
||||
'-c', 'user.email=archify@example.invalid',
|
||||
'tag', '-a', 'v3.0.0', '-m', 'stable v3.0.0',
|
||||
]).status, 0);
|
||||
const publishedAt = annotatedTaggerTime(fixture, 'v3.0.0');
|
||||
const tree = git(fixture, ['rev-parse', 'HEAD:archify']);
|
||||
assert.equal(tree.status, 0, tree.stderr);
|
||||
const treeSha = tree.stdout.trim();
|
||||
const archive = path.join(fixture, 'archify.zip');
|
||||
const archiveBytes = Buffer.from('deterministic stable archive fixture');
|
||||
fs.writeFileSync(archive, archiveBytes);
|
||||
const artifactSha = crypto.createHash('sha256').update(archiveBytes).digest('hex');
|
||||
const manifestPath = path.join(fixture, 'docs', 'skill-updates', 'archify', 'stable.json');
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
skillId: 'archify',
|
||||
channel: 'stable',
|
||||
version: '3.0.0',
|
||||
publishedAt,
|
||||
source: {
|
||||
repository: 'https://github.com/tt-a1i/archify',
|
||||
ref: 'v3.0.0',
|
||||
treeSha,
|
||||
},
|
||||
artifact: { sha256: artifactSha },
|
||||
summary: 'Stable release fixture.',
|
||||
releaseNotes: 'https://github.com/tt-a1i/archify/releases/tag/v3.0.0',
|
||||
severity: 'normal',
|
||||
};
|
||||
writeJson(manifestPath, manifest);
|
||||
|
||||
const passing = runCheck(fixture, archive);
|
||||
assert.equal(passing.status, 0, passing.stderr);
|
||||
assert.match(passing.stdout, /stable update manifest ok: v3\.0\.0/);
|
||||
|
||||
writeJson(path.join(fixture, 'archify', 'package.json'), { version: '4.0.0-dev.0' });
|
||||
const historicalRelease = runCheck(fixture, archive, ['--source-ref', 'v3.0.0']);
|
||||
assert.equal(historicalRelease.status, 0, historicalRelease.stderr);
|
||||
const wrongHistoricalRef = runCheck(fixture, archive, ['--source-ref', 'v2.9.0']);
|
||||
assert.notEqual(wrongHistoricalRef.status, 0);
|
||||
assert.match(wrongHistoricalRef.stderr, /--source-ref must be HEAD or the exact release tag/);
|
||||
writeJson(path.join(fixture, 'archify', 'package.json'), { version: '3.0.0' });
|
||||
|
||||
writeJson(manifestPath, {
|
||||
...manifest,
|
||||
publishedAt: '2026-08-28T08:00:00+08:00',
|
||||
});
|
||||
const nonUtcTimestamp = runCheck(fixture, archive);
|
||||
assert.notEqual(nonUtcTimestamp.status, 0);
|
||||
assert.match(nonUtcTimestamp.stderr, /stable update manifest identity/);
|
||||
writeJson(manifestPath, manifest);
|
||||
|
||||
writeJson(manifestPath, {
|
||||
...manifest,
|
||||
publishedAt: new Date(Date.parse(publishedAt) + 1_000)
|
||||
.toISOString()
|
||||
.replace('.000Z', 'Z'),
|
||||
});
|
||||
const wrongTaggerTime = runCheck(fixture, archive);
|
||||
assert.notEqual(wrongTaggerTime.status, 0);
|
||||
assert.match(wrongTaggerTime.stderr, /publishedAt .* annotated tagger time/);
|
||||
writeJson(manifestPath, manifest);
|
||||
|
||||
const invalidContracts = [
|
||||
{ ...manifest, extra: true },
|
||||
{ ...manifest, source: { ...manifest.source, extra: true } },
|
||||
{ ...manifest, artifact: { ...manifest.artifact, extra: true } },
|
||||
{ ...manifest, summary: '\u202eunsafe' },
|
||||
{ ...manifest, severity: 'urgent' },
|
||||
];
|
||||
for (const invalid of invalidContracts) {
|
||||
writeJson(manifestPath, invalid);
|
||||
const rejected = runCheck(fixture, archive);
|
||||
assert.notEqual(rejected.status, 0);
|
||||
assert.match(rejected.stderr, /stable update manifest identity/);
|
||||
}
|
||||
writeJson(manifestPath, manifest);
|
||||
|
||||
writeJson(manifestPath, null);
|
||||
const nullManifest = runCheck(fixture, archive);
|
||||
assert.notEqual(nullManifest.status, 0);
|
||||
assert.match(nullManifest.stderr, /stable update manifest identity/);
|
||||
assert.doesNotMatch(nullManifest.stderr, /TypeError|check-stable-update-manifest\.mjs:\d+/);
|
||||
writeJson(manifestPath, manifest);
|
||||
|
||||
fs.appendFileSync(archive, 'tampered');
|
||||
const archiveMismatch = runCheck(fixture, archive);
|
||||
assert.notEqual(archiveMismatch.status, 0);
|
||||
assert.match(archiveMismatch.stderr, /archive sha256 .* does not match/);
|
||||
|
||||
fs.writeFileSync(archive, archiveBytes);
|
||||
writeJson(manifestPath, {
|
||||
...manifest,
|
||||
source: { ...manifest.source, treeSha: 'c'.repeat(40) },
|
||||
});
|
||||
const treeMismatch = runCheck(fixture, archive);
|
||||
assert.notEqual(treeMismatch.status, 0);
|
||||
assert.match(treeMismatch.stderr, /treeSha .* does not match HEAD:archify/);
|
||||
|
||||
writeJson(manifestPath, manifest);
|
||||
assert.equal(git(fixture, ['tag', '-d', 'v3.0.0']).status, 0);
|
||||
assert.equal(git(fixture, ['tag', 'v3.0.0']).status, 0);
|
||||
const lightweightTag = runCheck(fixture, archive);
|
||||
assert.notEqual(lightweightTag.status, 0);
|
||||
assert.match(lightweightTag.stderr, /must be an annotated tag/);
|
||||
} finally {
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { SCENARIO_RECIPES, startPromptsFor } from '../recipes/scenarios.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(skillRoot, '..');
|
||||
|
||||
class FakeElement {
|
||||
constructor({ id = '', textContent = '', dataset = {} } = {}) {
|
||||
this.id = id;
|
||||
this.textContent = textContent;
|
||||
this.dataset = dataset;
|
||||
this.style = {};
|
||||
this.attributes = {};
|
||||
this.listeners = {};
|
||||
this.tabIndex = 0;
|
||||
}
|
||||
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||||
getAttribute(name) { return this.attributes[name]; }
|
||||
addEventListener(name, listener) { this.listeners[name] = listener; }
|
||||
replaceChildren(...children) { this.children = children; }
|
||||
appendChild() {}
|
||||
remove() {}
|
||||
select() {}
|
||||
focus() { this.focused = true; }
|
||||
click() { return this.listeners.click?.({ preventDefault() {} }); }
|
||||
dispatchKey(key) { return this.listeners.keydown?.({ key, preventDefault() {} }); }
|
||||
}
|
||||
|
||||
function executeStartPage(html) {
|
||||
const dataMatch = html.match(/<script id="start-data" type="application\/json">([\s\S]*?)<\/script>/);
|
||||
const scriptMatch = html.match(/<script>\n([\s\S]*?)\n <\/script>\n<\/body>/);
|
||||
assert.ok(dataMatch);
|
||||
assert.ok(scriptMatch);
|
||||
|
||||
const ids = Object.fromEntries([
|
||||
'recipe-title', 'recipe-question', 'recipe-prompt', 'include-list', 'proof-link',
|
||||
'proof-meta', 'copy-status', 'language', 'agent-state', 'install-command',
|
||||
'project-command', 'copy-prompt', 'copy-starter',
|
||||
].map((id) => [id, new FakeElement({ id })]));
|
||||
ids['start-data'] = new FakeElement({ id: 'start-data', textContent: dataMatch[1] });
|
||||
|
||||
const types = ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']
|
||||
.map((type) => new FakeElement({ dataset: { type } }));
|
||||
const agents = ['cursor', 'codex', 'claude-code', 'opencode']
|
||||
.map((agent) => new FakeElement({ textContent: agent === 'codex' ? 'Codex' : agent, dataset: { agent } }));
|
||||
const inputs = ['description', 'repository']
|
||||
.map((input) => new FakeElement({ dataset: { input } }));
|
||||
const copySources = ['install-command', 'project-command']
|
||||
.map((copySource) => new FakeElement({ dataset: { copySource } }));
|
||||
const copied = [];
|
||||
const stored = new Map();
|
||||
let replacedUrl = '';
|
||||
const document = {
|
||||
documentElement: {},
|
||||
body: { appendChild() {} },
|
||||
getElementById(id) { return ids[id]; },
|
||||
createElement() { return new FakeElement(); },
|
||||
execCommand() { return true; },
|
||||
querySelector(selector) {
|
||||
const match = selector.match(/^\[data-agent="([^"]+)"\]$/);
|
||||
return match ? agents.find((element) => element.dataset.agent === match[1]) : null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === '[data-type]') return types;
|
||||
if (selector === '[data-agent]') return agents;
|
||||
if (selector === '[data-input]') return inputs;
|
||||
if (selector === '[data-copy-source]') return copySources;
|
||||
if (selector === '[data-en][data-zh]') return [];
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const window = {
|
||||
location: { href: 'https://example.test/start.html', search: '', pathname: '/start.html' },
|
||||
isSecureContext: true,
|
||||
dispatchEvent() {},
|
||||
ArchifySiteLanguage: {
|
||||
read() { return 'en'; },
|
||||
write(value) { return value; },
|
||||
},
|
||||
};
|
||||
const context = {
|
||||
window,
|
||||
ArchifySiteLanguage: window.ArchifySiteLanguage,
|
||||
document,
|
||||
navigator: { languages: ['en'], language: 'en', clipboard: { async writeText(value) { copied.push(value); } } },
|
||||
history: { replaceState(_state, _title, url) { replacedUrl = url; } },
|
||||
sessionStorage: {
|
||||
getItem(key) { return stored.get(key) ?? null; },
|
||||
setItem(key, value) { stored.set(key, value); },
|
||||
},
|
||||
CustomEvent: class { constructor(name, options) { this.name = name; this.detail = options.detail; } },
|
||||
URL,
|
||||
URLSearchParams,
|
||||
Set,
|
||||
Array,
|
||||
JSON,
|
||||
encodeURIComponent,
|
||||
};
|
||||
vm.createContext(context);
|
||||
new vm.Script(scriptMatch[1]).runInContext(context);
|
||||
return { data: JSON.parse(dataMatch[1]), ids, inputs, copied, window, getUrl: () => replacedUrl };
|
||||
}
|
||||
|
||||
test('start page: checked-in HTML is reproducible from canonical scenario recipes', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-start-page-'));
|
||||
const generated = path.join(tmp, 'start.html');
|
||||
try {
|
||||
execFileSync(process.execPath, [path.join(repoRoot, 'scripts/build-start.mjs'), generated]);
|
||||
assert.equal(
|
||||
fs.readFileSync(generated, 'utf8'),
|
||||
fs.readFileSync(path.join(repoRoot, 'docs/start.html'), 'utf8'),
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('start page: offers five bounded bilingual starts without ingesting source content', () => {
|
||||
const html = fs.readFileSync(path.join(repoRoot, 'docs/start.html'), 'utf8');
|
||||
assert.doesNotMatch(html, /\[\[[A-Z0-9_]+\]\]/);
|
||||
assert.match(html, /npx -y skills add tt-a1i\/archify --skill archify --agent codex --global --copy --yes/);
|
||||
assert.match(html, /npx -y skills add tt-a1i\/archify --skill archify --agent codex --copy --yes/);
|
||||
for (const agent of ['cursor', 'codex', 'claude-code', 'opencode']) {
|
||||
assert.match(html, new RegExp(`role="tab" data-agent="${agent}"`));
|
||||
}
|
||||
assert.match(html, /data-en="Describe it\."/);
|
||||
assert.match(html, /data-en="Archify maps it\."/);
|
||||
assert.match(html, /data-zh="直接说,"/);
|
||||
assert.match(html, /data-zh="Archify 就能画。"/);
|
||||
assert.match(html, /id="copy-starter"/);
|
||||
assert.match(html, /data-en="Copy install \+ prompt"/);
|
||||
assert.match(html, /data-zh="复制安装命令 \+ 提示词"/);
|
||||
assert.match(html, /data-en="No repository is required\./);
|
||||
assert.match(html, /data-zh="不需要绑定代码库。/);
|
||||
assert.match(html, /data-input="description"/);
|
||||
assert.match(html, /data-input="repository"/);
|
||||
|
||||
const dataMatch = html.match(/<script id="start-data" type="application\/json">([\s\S]*?)<\/script>/);
|
||||
assert.ok(dataMatch);
|
||||
const data = JSON.parse(dataMatch[1]);
|
||||
assert.deepEqual(Object.keys(data), ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']);
|
||||
assert.ok(Object.values(data).every((entry) => entry.en.prompt && entry.zh.prompt && entry.en.descriptionPrompt && entry.zh.descriptionPrompt && entry.en.repositoryPrompt && entry.zh.repositoryPrompt && entry.proof));
|
||||
|
||||
const scriptMatch = html.match(/<script>\n([\s\S]*?)\n <\/script>\n<\/body>/);
|
||||
assert.ok(scriptMatch);
|
||||
assert.doesNotThrow(() => new vm.Script(scriptMatch[1]));
|
||||
assert.match(scriptMatch[1], /KNOWN_TYPES\.has\(requestedType\)/);
|
||||
assert.match(scriptMatch[1], /KNOWN_AGENTS\.has\(requestedAgent\)/);
|
||||
assert.match(scriptMatch[1], /KNOWN_SOURCES\.has\(requestedSource\)/);
|
||||
assert.match(scriptMatch[1], /KNOWN_INPUTS\.has\(requestedInput\)/);
|
||||
assert.match(scriptMatch[1], /next\.searchParams\.set\('agent', agent\)/);
|
||||
assert.match(scriptMatch[1], /next\.searchParams\.set\('source', source\)/);
|
||||
assert.match(scriptMatch[1], /next\.searchParams\.set\('input', input\)/);
|
||||
assert.match(scriptMatch[1], /next\.searchParams\.delete\('lang'\)/);
|
||||
assert.match(scriptMatch[1], /--agent ' \+ agent \+ ' --global --copy --yes/);
|
||||
assert.match(scriptMatch[1], /--agent ' \+ agent \+ ' --copy --yes/);
|
||||
assert.match(scriptMatch[1], /function starterText\(\)/);
|
||||
assert.match(scriptMatch[1], /archify:start-funnel/);
|
||||
assert.match(scriptMatch[1], /archify\.start\.events\.v1/);
|
||||
assert.doesNotMatch(scriptMatch[1], /fetch\(|sendBeacon\(|XMLHttpRequest/);
|
||||
assert.match(scriptMatch[1], /textContent/);
|
||||
assert.match(scriptMatch[1], /replaceChildren/);
|
||||
assert.doesNotMatch(scriptMatch[1], /innerHTML/);
|
||||
});
|
||||
|
||||
test('start page: canonical recipes own description and repository prompt variants', () => {
|
||||
const selected = new Map([
|
||||
['architecture', 'system-overview'],
|
||||
['workflow', 'agent-tool-call'],
|
||||
['sequence', 'api-request'],
|
||||
['dataflow', 'event-stream'],
|
||||
['lifecycle', 'object-lifecycle'],
|
||||
]);
|
||||
for (const [type, id] of selected) {
|
||||
const recipe = SCENARIO_RECIPES.find((candidate) => candidate.id === id);
|
||||
assert.equal(recipe?.type, type);
|
||||
for (const language of ['en', 'zh']) {
|
||||
const prompts = startPromptsFor(recipe, language);
|
||||
assert.equal(prompts.descriptionPrompt, recipe.start[language].descriptionPrompt);
|
||||
assert.ok(prompts.repositoryPrompt.toLowerCase().includes(recipe[language].prompt.toLowerCase()));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('start page: input mode drives rendered prompt, copy, keyboard, and URL without changing event schema', async () => {
|
||||
const html = fs.readFileSync(path.join(repoRoot, 'docs/start.html'), 'utf8');
|
||||
const page = executeStartPage(html);
|
||||
const descriptionPrompt = page.data.architecture.en.descriptionPrompt;
|
||||
const repositoryPrompt = page.data.architecture.en.repositoryPrompt;
|
||||
|
||||
assert.equal(page.inputs[0].getAttribute('aria-selected'), 'true');
|
||||
assert.equal(page.inputs[1].getAttribute('aria-selected'), 'false');
|
||||
assert.equal(page.ids['recipe-prompt'].textContent, descriptionPrompt);
|
||||
assert.equal(new URL(page.getUrl(), 'https://example.test').searchParams.get('input'), 'description');
|
||||
|
||||
page.inputs[1].click();
|
||||
assert.equal(page.inputs[1].getAttribute('aria-selected'), 'true');
|
||||
assert.equal(page.ids['recipe-prompt'].textContent, repositoryPrompt);
|
||||
assert.equal(new URL(page.getUrl(), 'https://example.test').searchParams.get('input'), 'repository');
|
||||
|
||||
page.ids['copy-prompt'].click();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(page.copied.at(-1), repositoryPrompt);
|
||||
|
||||
page.inputs[1].dispatchKey('ArrowLeft');
|
||||
assert.equal(page.inputs[0].getAttribute('aria-selected'), 'true');
|
||||
assert.equal(page.inputs[0].focused, true);
|
||||
assert.equal(page.ids['recipe-prompt'].textContent, descriptionPrompt);
|
||||
|
||||
page.ids['copy-starter'].click();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.match(page.copied.at(-1), /Then start any new chat and tell Codex:/);
|
||||
assert.ok(page.copied.at(-1).endsWith(descriptionPrompt));
|
||||
|
||||
const [viewEvent, promptEvent, starterEvent] = page.window.ArchifyStartMetrics.snapshot();
|
||||
for (const event of [viewEvent, promptEvent, starterEvent]) {
|
||||
assert.deepEqual(Object.keys(event), ['schemaVersion', 'step', 'source', 'type', 'agent', 'language']);
|
||||
assert.equal('input' in event, false);
|
||||
}
|
||||
assert.deepEqual([viewEvent.step, promptEvent.step, starterEvent.step], ['start_view', 'prompt_copy', 'starter_copy']);
|
||||
});
|
||||
|
||||
test('generated artifacts omit the promotional footer and shortcut manual', () => {
|
||||
const examples = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-start-artifacts-'));
|
||||
try {
|
||||
for (const [type, input] of Object.entries(examples)) {
|
||||
const out = path.join(tmp, `${type}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${type}/render-${type}.mjs`),
|
||||
path.join(skillRoot, 'examples', input),
|
||||
out,
|
||||
]);
|
||||
const html = fs.readFileSync(out, 'utf8');
|
||||
assert.doesNotMatch(html, /<p class="footer">/, `${type}: footer element`);
|
||||
assert.doesNotMatch(html, /Built with Archify/, `${type}: product signature`);
|
||||
assert.doesNotMatch(html, /Create yours/, `${type}: promotional CTA`);
|
||||
assert.doesNotMatch(html, /Hover to trace/, `${type}: shortcut manual`);
|
||||
assert.doesNotMatch(html, /source=artifact/, `${type}: removed artifact CTA URL`);
|
||||
assert.match(html, /id="btn-diagram-guide"/, `${type}: diagram guide remains available`);
|
||||
|
||||
const svg = html.match(/<svg[\s\S]*?<\/svg>/)?.[0];
|
||||
assert.ok(svg, `${type}: SVG missing`);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('viewer gives wide screens a larger canvas without forcing a subtitle row', () => {
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets', 'template.html'), 'utf8');
|
||||
assert.match(template, /max-width: var\(--archify-reader-width, 1440px\)/);
|
||||
assert.match(template, /Archify\.readerLayout = \(function \(\)/);
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-title-hierarchy-'));
|
||||
try {
|
||||
const input = JSON.parse(fs.readFileSync(
|
||||
path.join(skillRoot, 'examples', 'web-app.architecture.json'),
|
||||
'utf8',
|
||||
));
|
||||
delete input.meta.subtitle;
|
||||
const source = path.join(tmp, 'without-subtitle.architecture.json');
|
||||
const output = path.join(tmp, 'without-subtitle.html');
|
||||
fs.writeFileSync(source, `${JSON.stringify(input, null, 2)}\n`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, 'renderers', 'architecture', 'render-architecture.mjs'),
|
||||
source,
|
||||
output,
|
||||
]);
|
||||
assert.doesNotMatch(fs.readFileSync(output, 'utf8'), /class="subtitle"/);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('artifact-to-install measurement plan separates observable funnel steps from first-diagram success', () => {
|
||||
const plan = fs.readFileSync(
|
||||
path.join(repoRoot, 'docs/artifact-install-v2-measurement.md'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
for (const required of [
|
||||
'start_view',
|
||||
'starter_copy',
|
||||
'global_install_copy',
|
||||
'project_install_copy',
|
||||
'prompt_copy',
|
||||
'proof_open',
|
||||
'starter_copy / start_view',
|
||||
'First-diagram success is not observable from this static page',
|
||||
'No network request',
|
||||
'source=artifact',
|
||||
'source=gallery',
|
||||
]) {
|
||||
assert.match(plan, new RegExp(required.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), required);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets', 'template.html'), 'utf8');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-story-beat-navigator-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
const result = spawnSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
], { encoding: 'utf8' });
|
||||
return { result, html: fs.existsSync(output) ? fs.readFileSync(output, 'utf8') : '' };
|
||||
}
|
||||
|
||||
test('all five renderers inherit native inspectable Story Beat controls without changing canonical SVG', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const { result, html } = render(mode, example);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(html, /var stop = document\.createElement\('button'\)/);
|
||||
assert.match(html, /stop\.type = 'button'/);
|
||||
assert.match(html, /stop\.setAttribute\('aria-label', storyBeatAria\(step, storySteps\.length\)\)/);
|
||||
assert.match(html, /stop\.setAttribute\('aria-current', 'step'\)/);
|
||||
assert.doesNotMatch(html, /stop\.setAttribute\('aria-pressed'/);
|
||||
const generatedSvg = html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
assert.doesNotMatch(generatedSvg, /data-story-(?:active|playing|beat|step|overlay|pulse)/);
|
||||
}
|
||||
});
|
||||
|
||||
test('adjacent stable IDs classify start, forward, reverse, group, and multiple without inferred cross-links', () => {
|
||||
assert.match(template, /function storyStep\(view, index, edgeList, byId\)/);
|
||||
assert.match(template, /from === previousId && to === id/);
|
||||
assert.match(template, /from === id && to === previousId/);
|
||||
assert.match(template, /edges\.length === 1 && forward\.length === 1 \? 'forward'/);
|
||||
assert.match(template, /edges\.length === 1 && reverse\.length === 1 \? 'reverse' : 'multiple'/);
|
||||
assert.match(template, /index === 0 \? 'start' : \(!edges\.length \? 'group'/);
|
||||
assert.match(template, /storySteps\.forEach\(function \(step\) \{\s*step\.edges\.forEach/);
|
||||
assert.doesNotMatch(template, /var storyOrder = \{\}/);
|
||||
assert.doesNotMatch(template, /Math\.max\(storyOrder/);
|
||||
});
|
||||
|
||||
test('focus pauses without selection while native activation pins one beat and preserves chapter and link ownership', () => {
|
||||
assert.match(template, /trail\.addEventListener\('focusin'[\s\S]*if \(playing\) pausePlayback\(\)/);
|
||||
assert.match(template, /trail\.addEventListener\('click'[\s\S]*selectStoryBeat\(Number\(stop\.getAttribute\('data-story-index'\)\)\)/);
|
||||
assert.match(template, /trail\.addEventListener\('keydown'[\s\S]*event\.key !== 'Enter' && event\.key !== ' '[\s\S]*event\.preventDefault\(\)/);
|
||||
assert.match(template, /function selectStoryBeat\(index\)/);
|
||||
assert.match(template, /setStoryBeat\(index, \{ manual: true, center: true, pulse: true, follow: true \}\)/);
|
||||
assert.match(template, /stop\.setAttribute\('aria-current', 'step'\)/);
|
||||
assert.match(template, /else stop\.removeAttribute\('aria-current'\)/);
|
||||
assert.match(template, /trail\.scrollLeft = target/);
|
||||
const selection = template.match(/function selectStoryBeat\(index\) \{([\s\S]*?)\n function updateUrl/)?.[1] || '';
|
||||
assert.match(selection, /updateUrl: false/);
|
||||
assert.doesNotMatch(selection, /history\.|location\.|scrollIntoView/);
|
||||
assert.match(template, /beat: function \(\)[\s\S]*edgeKeys: step\.edgeKeys\.slice\(\)/);
|
||||
});
|
||||
|
||||
test('one generation-owned scheduler resumes remaining dwell and finite exact-edge signals never loop', () => {
|
||||
assert.equal((template.match(/storyBeatTimer = setTimeout/g) || []).length, 1);
|
||||
assert.doesNotMatch(template, /storyBeatTimer = setInterval/);
|
||||
assert.match(template, /storyPlaybackGeneration \+= 1/);
|
||||
assert.match(template, /generation !== storyPlaybackGeneration/);
|
||||
assert.match(template, /preserveElapsed: options\.complete !== true/);
|
||||
assert.match(template, /storyBeatDwellMs - storyBeatElapsedMs/);
|
||||
assert.match(template, /afterHandoff\(function \(\)[\s\S]*scheduleStoryPlayback\(\)/);
|
||||
assert.match(template, /animation: archify-story-flow 0\.78s linear 1 both/);
|
||||
assert.doesNotMatch(template, /archify-story-flow 0\.78s linear infinite/);
|
||||
assert.match(template, /svg\[data-preset="blueprint"\]\[data-story-beat\] \[data-story-step\]\[data-story-beat-state="active"\] \{\s*filter: none;\s*animation: none;/);
|
||||
assert.match(template, /step\.relation !== 'forward' && step\.relation !== 'reverse'/);
|
||||
assert.match(template, /step\.edges\.length !== 1/);
|
||||
assert.match(template, /addEventListener\('animationend'[\s\S]*clearStoryPulse/);
|
||||
});
|
||||
|
||||
test('target size, reduced motion, print, embed, and export keep Story Beats viewer-only', () => {
|
||||
assert.match(template, /\.guided-view-trail \.guided-view-stop \{[\s\S]*min-height: 1\.5rem/);
|
||||
assert.match(template, /@media \(max-width: 720px\)[\s\S]*\.guided-view-trail \.guided-view-stop \{[\s\S]*min-height: 2rem/);
|
||||
assert.match(template, /touch-action: pan-x/);
|
||||
assert.match(template, /document\.documentElement\.getAttribute\('data-embed'\) !== 'true' && typeof MutationObserver !== 'undefined' && typeof Node !== 'undefined' && svg instanceof Node && document\.documentElement instanceof Node/);
|
||||
assert.match(template, /@media print[\s\S]*\.story-trail-overlay,\s*\.story-carrier-overlay \{ display: none !important; \}/);
|
||||
assert.match(template, /html\[data-embed="true"\][\s\S]*\.guided-views/);
|
||||
assert.match(template, /html\[data-motion="still"\] \.story-trail-flow/);
|
||||
assert.match(template, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.story-trail-flow/);
|
||||
assert.match(template, /clone\.querySelectorAll\('\[data-story-overlay\], \[data-story-carrier-overlay\]'\)/);
|
||||
assert.match(template, /clone\.querySelectorAll\('\[data-story-step\], \[data-story-beat-state\], \[data-story-beat-step\]'\)/);
|
||||
assert.match(template, /canonicalStateClean[\s\S]*data-story-beat-step/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,90 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets', 'template.html'), 'utf8');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-story-carrier-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all five renderers inherit one viewer-only Semantic Story Carrier', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /Archify\.flowTokens = \{/, mode);
|
||||
assert.match(html, /className: 'story-flow-token'/, mode);
|
||||
assert.match(html, /data-story-carrier-token/, mode);
|
||||
assert.match(html, /animation: archify-relationship-token-life 0\.78s linear 1 both/, mode);
|
||||
assert.doesNotMatch(canonicalSvg(html), /story-flow-token|story-carrier-token|semantic-flow-token/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('Story deduplicates SVG path and label fragments by stable authored edge key', () => {
|
||||
assert.match(template, /function uniqueStoryEdges\(edgeList\)/);
|
||||
assert.match(template, /var key = storyEdgeKey\(edge\)/);
|
||||
assert.match(template, /Object\.prototype\.hasOwnProperty\.call\(positions, key\)/);
|
||||
assert.match(template, /!storyGeometry\(unique\[index\]\)\.length && storyGeometry\(edge\)\.length/);
|
||||
assert.match(template, /forward = uniqueStoryEdges\(forward\)/);
|
||||
assert.match(template, /reverse = uniqueStoryEdges\(reverse\)/);
|
||||
assert.match(template, /edges\.length === 1 && forward\.length === 1 \? 'forward'/);
|
||||
});
|
||||
|
||||
test('Story reuses the exact semantic token vocabulary on its existing finite edge pulse', () => {
|
||||
assert.match(template, /function createSemanticFlowToken\(edge, shape, options\)/);
|
||||
assert.match(template, /relationshipTokenGeometry\(shape, relationshipTokenKind\(edge\), key, options\)/);
|
||||
assert.match(template, /Archify\.flowTokens\.create\(edge, shapes\[0\], \{/);
|
||||
assert.match(template, /className: 'story-flow-token'/);
|
||||
assert.match(template, /duration: '0\.78s'/);
|
||||
assert.match(template, /carrier\.setAttribute\('data-story-beat-step', String\(step\.index\)\)/);
|
||||
assert.match(template, /carrierOverlay\.setAttribute\('data-story-carrier-overlay', ''\)/);
|
||||
assert.match(template, /carrierWrapper\.appendChild\(carrier\)/);
|
||||
assert.match(template, /svg\.insertBefore\(carrierOverlay, firstNode\)/);
|
||||
assert.match(template, /semantic-flow-token-halo/);
|
||||
assert.doesNotMatch(template, /story-flow-token[^}]+infinite/);
|
||||
});
|
||||
|
||||
test('only explicit play=1 embeds may show the finite carrier', () => {
|
||||
assert.match(template, /data-embed'\) === 'true' &&\s*document\.documentElement\.getAttribute\('data-share-playback'\) !== 'true'/);
|
||||
assert.match(template, /autoplayPending = sharePlaybackRequested\(\)/);
|
||||
assert.match(template, /document\.documentElement\.setAttribute\('data-share-playback', 'true'\)/);
|
||||
assert.match(template, /html\[data-motion="still"\] \.story-carrier-overlay/);
|
||||
assert.match(template, /html\[data-document-hidden="true"\] \.story-carrier-overlay/);
|
||||
assert.match(template, /@media \(prefers-reduced-motion: reduce\)[\s\S]+\.story-carrier-overlay \{ display: none !important; \}/);
|
||||
});
|
||||
|
||||
test('Story Carrier cleanup and export remain owned by Story Trail', () => {
|
||||
assert.match(template, /svg\.querySelectorAll\('\[data-story-carrier-overlay\]'\)/);
|
||||
assert.match(template, /overlay\.remove\(\)/);
|
||||
assert.match(template, /var pulseGeneration = storyPulseGeneration/);
|
||||
assert.match(template, /if \(pulseGeneration === storyPulseGeneration\) clearStoryPulse\(\)/);
|
||||
assert.equal((template.match(/storyPulseOwnerToken = Archify\.motionGovernor\.claim\('story'/g) || []).length, 1);
|
||||
assert.match(template, /clone\.querySelectorAll\('\[data-story-overlay\], \[data-story-carrier-overlay\]'\)/);
|
||||
assert.match(template, /@media print \{[\s\S]+\.story-trail-overlay,[\s\S]+\.story-carrier-overlay \{ display: none !important; \}/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,91 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets', 'template.html'), 'utf8');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-story-director-strip-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all five renderers inherit one viewer-only Story Director Strip', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /id="guided-story-caption" hidden aria-live="polite" aria-atomic="true"/, mode);
|
||||
assert.match(html, /function renderStoryCaption\(step, total, nextStep\)/, mode);
|
||||
assert.match(html, /storyCaptionRoute\.textContent = storyCaptionRouteCopy\(step\)/, mode);
|
||||
assert.match(html, /storyCaptionDetail\.textContent = storyCaptionDetailCopy\(step\)/, mode);
|
||||
assert.doesNotMatch(canonicalSvg(html), /guided-story-caption|data-story-caption/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('captions derive only authored edge labels and existing node facts', () => {
|
||||
assert.match(template, /edgeLabels: edges\.map\(function \(edge\) \{ return edge\.getAttribute\('data-edge-label'\) \|\| ''; \}\)/);
|
||||
assert.match(template, /responsibility: node \? \(node\.getAttribute\('data-node-sublabel'\) \|\| ''\) : ''/);
|
||||
assert.match(template, /context: node \? \(node\.getAttribute\('data-node-context'\) \|\| ''\) : ''/);
|
||||
assert.match(template, /step\.edgeLabels\.slice\(0, 3\)\.join\(' \+ '\)/);
|
||||
assert.match(template, /viewerText\('viewer\.guided\.caption\.grouped'\)/);
|
||||
assert.match(template, /if \(!facts\.length\) facts\.push\(viewerText\('viewer\.guided\.caption\.starting'\)\)/);
|
||||
assert.match(template, /viewerText\('viewer\.guided\.caption\.direction'/);
|
||||
assert.doesNotMatch(template, /inferred relationship|likely transition|calls service/);
|
||||
});
|
||||
|
||||
test('route copy preserves start, forward, reverse, multiple, and grouped semantics', () => {
|
||||
assert.match(template, /viewerText\('viewer\.guided\.beat\.start'/);
|
||||
assert.match(template, /viewerText\('viewer\.guided\.beat\.forward'/);
|
||||
assert.match(template, /viewerText\('viewer\.guided\.beat\.reverse'/);
|
||||
assert.match(template, /viewerText\('viewer\.guided\.beat\.multiple'/);
|
||||
assert.match(template, /viewerText\('viewer\.guided\.beat\.group'/);
|
||||
});
|
||||
|
||||
test('playback announcements and motion remain reader-controlled', () => {
|
||||
assert.match(template, /storyCaption\.setAttribute\('aria-live', playing \? 'off' : 'polite'\)/);
|
||||
assert.match(template, /html\[data-motion="still"\] \.guided-story-caption/);
|
||||
assert.match(template, /@media \(prefers-reduced-motion: reduce\) \{\s*\.guided-story-caption \{ animation: none !important; \}/);
|
||||
assert.match(template, /animation: archify-story-caption-in 140ms/);
|
||||
});
|
||||
|
||||
test('Presentation playback removes secondary chrome without hiding Pause or navigation', () => {
|
||||
assert.match(template, /\.guided-views\[data-story-beat\] \.guided-view-copy > #guided-view-label/);
|
||||
assert.match(template, /@media \(min-width: 721px\)[\s\S]*\.guided-views\[data-playing="true"\] \.guided-view-index/);
|
||||
assert.match(template, /\.guided-views\[data-playing="true"\] \.guided-view-beat-link/);
|
||||
assert.match(template, /\.guided-views\[data-playing="true"\] \.guided-view-all/);
|
||||
const presentationRule = template.match(/@media \(min-width: 721px\) \{([\s\S]*?)\n \}/)?.[1] || '';
|
||||
assert.doesNotMatch(presentationRule, /guided-view-play/);
|
||||
assert.doesNotMatch(presentationRule, /guided-view-prev|guided-view-next/);
|
||||
assert.match(template, /@media \(max-width: 720px\)[\s\S]*\.guided-views > #guided-view-prev,[\s\S]*height: 2\.75rem/);
|
||||
assert.match(template, /@media \(max-width: 720px\)[\s\S]*\.guided-view-play,[\s\S]*\.guided-view-beat-link \{ min-height: 2\.75rem; \}/);
|
||||
});
|
||||
|
||||
test('embed, print, and canonical export boundaries stay clean', () => {
|
||||
assert.match(template, /html\[data-embed="true"\] \.guided-views \{ display: none !important; \}/);
|
||||
assert.match(template, /@media print[\s\S]*\.guided-views/);
|
||||
assert.doesNotMatch(canonicalSvg(render('workflow', CASES.workflow)), /Story Director|guided-story-caption|data-story-caption/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,103 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets', 'template.html'), 'utf8');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-story-follow-camera-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all five renderers inherit one viewer-only Story Follow Camera', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const html = render(mode, example);
|
||||
assert.match(html, /function followStoryStep\(step, options\)/, mode);
|
||||
assert.match(html, /panel\.setAttribute\('data-story-follow', 'moving'\)/, mode);
|
||||
assert.match(html, /panel\.setAttribute\('data-story-follow-node', step\.nodeId\)/, mode);
|
||||
assert.doesNotMatch(canonicalSvg(html), /data-story-follow/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('Story Follow frames the exact previous, current, and next authored stops through the shared camera', () => {
|
||||
assert.match(template, /function storyFrameIds\(step\)/);
|
||||
assert.match(template, /step\.index > 0 && storySteps\[step\.index - 1\]/);
|
||||
assert.match(template, /ids\.push\(step\.nodeId\)/);
|
||||
assert.match(template, /step\.index \+ 1 < storySteps\.length/);
|
||||
assert.match(template, /var ids = storyFrameIds\(step\)/);
|
||||
assert.match(template, /Archify\.view\.reveal\(ids, \{/);
|
||||
assert.match(template, /reason: options\.manual === true \? 'story-beat' : 'story-follow'/);
|
||||
assert.match(template, /padding: 64/);
|
||||
assert.match(template, /maxScale: 1\.65/);
|
||||
assert.match(template, /duration: STORY_FOLLOW_DURATION_MS/);
|
||||
assert.match(template, /storyFollowGeneration/);
|
||||
assert.match(template, /generation !== storyFollowGeneration \|\| storyBeatIndex !== step\.index/);
|
||||
});
|
||||
|
||||
test('playback and deliberate beat activation follow while stable moment restoration stays calm', () => {
|
||||
assert.match(template, /setStoryBeat\(0, \{ pulse: true, follow: true \}\)/);
|
||||
assert.match(template, /setStoryBeat\(storyBeatIndex \+ 1, \{ pulse: true, follow: true \}\)/);
|
||||
assert.match(template, /if \(storyBeatIndex >= 0\) followStoryStep\(storySteps\[storyBeatIndex\]\)/);
|
||||
assert.match(template, /setStoryBeat\(index, \{ manual: true, center: true, pulse: true, follow: true \}\)/);
|
||||
assert.match(template, /follow: options\.follow === true/);
|
||||
assert.match(template, /selectStoryBeatById\(requestedBeat, \{ linked: true, follow: true, followInstant: true \}\)/);
|
||||
assert.match(template, /if \(embed && !explicitEmbedPlayback && options\.linked !== true\) return false/);
|
||||
assert.match(template, /var deferredGeneration = \+\+storyFollowGeneration/);
|
||||
assert.match(template, /requestAnimationFrame\(function \(\) \{[\s\S]*?storyBeatIndex !== deferredIndex[\s\S]*?followStoryStep\(step, options\)/);
|
||||
});
|
||||
|
||||
test('adaptive dwell, Still, reduced motion, hidden pages, and print keep camera motion bounded', () => {
|
||||
assert.match(template, /var STORY_FOLLOW_MIN_DWELL_MS = 1100/);
|
||||
assert.match(template, /var STORY_FOLLOW_DURATION_MS = 320/);
|
||||
assert.match(template, /Math\.max\(STORY_FOLLOW_MIN_DWELL_MS, VIEW_INTERVAL_MS \/ Math\.max\(1, total\)\)/);
|
||||
assert.match(template, /storyBeatDwellMs = storyBeatDwell\(total\)/);
|
||||
assert.match(template, /if \(!step \|\| document\.hidden/);
|
||||
assert.match(template, /window\.matchMedia\('print'\)\.matches/);
|
||||
assert.match(template, /instant: options\.instant === true \|\| reducedMotion\(\) \|\| document\.documentElement\.getAttribute\('data-motion'\) !== 'live'/);
|
||||
assert.match(template, /function storyAutomaticPlaybackAllowed\(\)/);
|
||||
assert.match(template, /Archify\.motionGovernor && Archify\.motionGovernor\.capable\) return !Archify\.motionGovernor\.isPaused\(\)/);
|
||||
assert.match(template, /play\.disabled = !playing && !automaticPlaybackAllowed/);
|
||||
assert.match(template, /'viewer\.guided\.motionUnavailable'/);
|
||||
assert.match(template, /function startPlayback\(\) \{[\s\S]*?if \(!storyAutomaticPlaybackAllowed\(\)\)/);
|
||||
assert.match(template, /if \(shouldPlay && svg\.getAttribute\('data-story-playing'\) !== 'true'\)/);
|
||||
assert.match(template, /else if \(!shouldPlay && svg\.hasAttribute\('data-story-playing'\)\)/);
|
||||
assert.doesNotMatch(template, /storyFollowTimer = setInterval/);
|
||||
});
|
||||
|
||||
test('pause, settle, overview, and manual camera takeover cancel Story Follow state', () => {
|
||||
assert.match(template, /function clearStoryFollow\(\)/);
|
||||
assert.match(template, /svg\.removeAttribute\('data-story-follow'\)/);
|
||||
assert.match(template, /panel\.removeAttribute\('data-story-follow'\)/);
|
||||
assert.match(template, /function pausePlayback\(options\)[\s\S]*?clearStoryFollow\(\)/);
|
||||
assert.match(template, /function settleStoryBeats\(\)[\s\S]*?clearStoryFollow\(\)/);
|
||||
assert.match(template, /function clearStoryTrail\(\)[\s\S]*?clearStoryFollow\(\)/);
|
||||
assert.match(template, /function interruptCamera\(reason\)[\s\S]*?Archify\.guidedViews\.pause\(\)/);
|
||||
assert.match(template, /clone\.removeAttribute\('data-story-follow'\)/);
|
||||
assert.match(template, /!clone\.hasAttribute\('data-story-follow'\)/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,89 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets', 'template.html'), 'utf8');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-story-horizon-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
const result = spawnSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
], { encoding: 'utf8' });
|
||||
return { result, html: fs.existsSync(output) ? fs.readFileSync(output, 'utf8') : '' };
|
||||
}
|
||||
|
||||
test('all five renderers inherit one viewer-only Story Horizon', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const { result, html } = render(mode, example);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(html, /data-story-beat-state="next"/, mode);
|
||||
assert.match(html, /step === storyBeatIndex \+ 1/, mode);
|
||||
assert.match(html, /svg\.setAttribute\('data-story-next', nextStep\.nodeId\)/, mode);
|
||||
assert.match(html, /panel\.setAttribute\('data-story-next', nextStep\.nodeId\)/, mode);
|
||||
assert.match(html, /id="guided-story-caption-next" hidden aria-hidden="true"/, mode);
|
||||
const generatedSvg = html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
assert.doesNotMatch(generatedSvg, /data-story-next|data-story-beat-state="next"/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('the temporal hierarchy has one bounded next state and a clean final beat', () => {
|
||||
assert.match(template, /if \(step < storyBeatIndex\) return 'past'/);
|
||||
assert.match(template, /if \(step === storyBeatIndex\) return 'active'/);
|
||||
assert.match(template, /if \(step === storyBeatIndex \+ 1\) return 'next'/);
|
||||
assert.match(template, /return 'pending'/);
|
||||
assert.match(template, /storyBeatIndex \+ 1 < storySteps\.length \? storySteps\[storyBeatIndex \+ 1\] : null/);
|
||||
assert.match(template, /else \{\s*svg\.removeAttribute\('data-story-next'\);\s*panel\.removeAttribute\('data-story-next'\)/);
|
||||
assert.match(template, /storyCaptionNext\.hidden = !nextStep/);
|
||||
});
|
||||
|
||||
test('next edges reuse exact authored step membership without synthesizing topology', () => {
|
||||
assert.match(template, /storySteps\.forEach\(function \(step\) \{\s*step\.edges\.forEach/);
|
||||
assert.match(template, /edge\.setAttribute\('data-story-beat-step', String\(edgeBeat\)\)/);
|
||||
assert.match(template, /edge\.setAttribute\('data-story-beat-state', storyBeatState\(step\)\)/);
|
||||
assert.match(template, /index === 0 \? 'start' : \(!edges\.length \? 'group'/);
|
||||
assert.match(template, /edges\.length === 1 && forward\.length === 1 \? 'forward'/);
|
||||
assert.match(template, /edges\.length === 1 && reverse\.length === 1 \? 'reverse' : 'multiple'/);
|
||||
assert.doesNotMatch(template, /createElementNS\([^\n]+story-horizon|data-story-horizon-edge/);
|
||||
});
|
||||
|
||||
test('next remains static, subordinate, preset-safe, and mobile-height neutral', () => {
|
||||
assert.match(template, /data-story-step\]\[data-story-beat-state="next"\] \{\s*opacity: 0\.5;\s*filter: saturate\(0\.66\)/);
|
||||
assert.match(template, /data-story-beat-state="past"\] \{\s*opacity: 0\.72/);
|
||||
assert.match(template, /data-edge-from\]\[data-story-beat-step\]\[data-story-beat-state="next"\] \{ opacity: 0\.34; \}/);
|
||||
assert.match(template, /guided-view-stop\[data-story-beat-state="next"\][\s\S]*border-style: dashed/);
|
||||
assert.match(template, /@media \(max-width: 720px\)[\s\S]*\.guided-story-caption-next \{ display: none; \}/);
|
||||
const nextRules = template.match(/[^\n{]*data-story-beat-state="next"[^\n{]*\{[^}]*\}/g)?.join('\n') || '';
|
||||
assert.doesNotMatch(nextRules, /animation:|drop-shadow|stroke-dasharray/);
|
||||
});
|
||||
|
||||
test('Still, accessibility, teardown, and export preserve the product boundary', () => {
|
||||
assert.match(template, /id="guided-story-caption-next" hidden aria-hidden="true"/);
|
||||
assert.match(template, /storyCaption\.setAttribute\('aria-live', playing \? 'off' : 'polite'\)/);
|
||||
assert.doesNotMatch(template, /storyCaptionNext\.setAttribute\('aria-live'/);
|
||||
assert.match(template, /html\[data-motion="still"\] \[data-story-step\]/);
|
||||
assert.match(template, /html\[data-motion="still"\] svg \[data-node-id\][\s\S]*transition: none !important/);
|
||||
assert.match(template, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.story-trail-flow/);
|
||||
assert.ok((template.match(/svg\.removeAttribute\('data-story-next'\)/g) || []).length >= 3);
|
||||
assert.ok((template.match(/panel\.removeAttribute\('data-story-next'\)/g) || []).length >= 2);
|
||||
assert.match(template, /clone\.removeAttribute\('data-story-next'\)/);
|
||||
assert.match(template, /canonicalStateClean[\s\S]*!clone\.hasAttribute\('data-story-next'\)/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets', 'template.html'), 'utf8');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-story-moment-link-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
const result = spawnSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
], { encoding: 'utf8' });
|
||||
return { result, html: fs.existsSync(output) ? fs.readFileSync(output, 'utf8') : '' };
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all five renderers inherit one viewer-only Story Moment Link control', () => {
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
const { result, html } = render(mode, example);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(html, /id="guided-view-beat-link"[^>]+aria-label="Select a Story Beat to copy its exact link"[^>]+disabled/);
|
||||
assert.match(html, /id="guided-view-beat-link-label">Copy moment<\/span>/);
|
||||
assert.doesNotMatch(canonicalSvg(html), /data-story-moment|guided-view-beat-link|#view=/);
|
||||
}
|
||||
});
|
||||
|
||||
test('moment links use exact stable view and node ids without mutating manual selection URLs', () => {
|
||||
assert.match(template, /function storyMomentLink\(\)/);
|
||||
assert.match(template, /url\.searchParams\.delete\('play'\)/);
|
||||
assert.match(template, /url\.hash = 'view=' \+ encodeURIComponent\(view\.id\) \+ '&beat=' \+ encodeURIComponent\(step\.nodeId\)/);
|
||||
assert.match(template, /function selectStoryBeatById\(id, options\)/);
|
||||
assert.match(template, /storySteps\.findIndex\(function \(step\) \{ return step\.nodeId === id; \}\)/);
|
||||
assert.match(template, /var requestedBeat = params\.get\('beat'\)/);
|
||||
assert.match(template, /var restoreGeneration = \+\+momentRestoreGeneration/);
|
||||
assert.match(template, /afterHandoff\(function \(\) \{[\s\S]*restoreGeneration !== momentRestoreGeneration[\s\S]*selectStoryBeatById\(requestedBeat, \{ linked: true, follow: true, followInstant: true \}\)/);
|
||||
const manualSelection = template.match(/function selectStoryBeat\(index\) \{([\s\S]*?)\n function updateUrl/)?.[1] || '';
|
||||
assert.doesNotMatch(manualSelection, /history\.|location\.|updateUrl\(/);
|
||||
});
|
||||
|
||||
test('invalid or cross-chapter beat ids fail closed while the public receipt stays read-only', () => {
|
||||
assert.match(template, /if \(index < 0\) return false/);
|
||||
assert.doesNotMatch(template, /Number\(params\.get\('beat'\)\)/);
|
||||
assert.match(template, /setStoryBeat\(index, \{[\s\S]*?manual: false,[\s\S]*?center: true,[\s\S]*?pulse: false,[\s\S]*?follow: options\.follow === true,[\s\S]*?linked: options\.linked === true,[\s\S]*?followInstant: options\.followInstant === true/);
|
||||
assert.match(template, /beatLink: storyMomentLink/);
|
||||
assert.match(template, /copyBeatLink: copyStoryMomentLink/);
|
||||
assert.match(template, /beat: function \(\)[\s\S]*nodeId: step\.nodeId/);
|
||||
});
|
||||
|
||||
test('copy feedback, one-shot playback, and reduced motion preserve the requested moment', () => {
|
||||
assert.match(template, /navigator\.clipboard && typeof navigator\.clipboard\.writeText === 'function'/);
|
||||
assert.match(template, /navigator\.clipboard\.writeText\(value\)/);
|
||||
assert.match(template, /document\.execCommand\('copy'\)/);
|
||||
assert.match(template, /beatLinkLabel\.textContent = viewerText\(copied \? 'viewer\.guided\.copied' : 'viewer\.guided\.copyFailed'\)/);
|
||||
assert.match(template, /viewerText\(copied \? 'viewer\.guided\.momentCopied' : 'viewer\.guided\.momentCopyFailed'\)/);
|
||||
assert.match(template, /function hashBeatMatchesCurrent\(\)/);
|
||||
assert.match(template, /if \(!storyAutomaticPlaybackAllowed\(\)\)[\s\S]*hashBeatMatchesCurrent\(\)[\s\S]*setAutoplayState\('reduced-motion'\)/);
|
||||
assert.match(template, /storyPlaybackScope = 'chapter'/);
|
||||
assert.match(template, /if \(storyBeatIndex < 0\)[\s\S]*setStoryBeat\(0, \{ pulse: true, follow: true \}\)/);
|
||||
});
|
||||
|
||||
test('the control keeps stable desktop/mobile geometry and existing viewer boundaries', () => {
|
||||
assert.match(template, /\.guided-view-beat-link \{[\s\S]*min-height: 1\.5rem/);
|
||||
assert.match(template, /@media \(max-width: 720px\)[\s\S]*\.guided-view-actions \{[\s\S]*grid-template-columns: repeat\(3, minmax\(0, 1fr\)\)/);
|
||||
assert.match(template, /@media \(max-width: 720px\)[\s\S]*\.guided-view-beat-link \{[\s\S]*min-height: 2\.75rem/);
|
||||
assert.match(template, /html\[data-embed="true"\][\s\S]*\.guided-views \{ display: none !important; \}/);
|
||||
assert.match(template, /html\[data-embed="true"\]\[data-share-moment="true"\] \.share-chapter-cue:not\(\[hidden\]\)/);
|
||||
assert.match(template, /pinnedMode = !shareMode && embedMode && hashBeatMatchesCurrent\(\)/);
|
||||
assert.match(template, /pinned: viewerText\('viewer\.guided\.state\.pinned'\)/);
|
||||
assert.match(template, /@media print[\s\S]*\.guided-views/);
|
||||
assert.match(template, /syncStoryControlsDisabled\(\)[\s\S]*beatLink\.disabled =/);
|
||||
assert.match(template, /clone\.querySelectorAll\('\[data-story-overlay\], \[data-story-carrier-overlay\]'\)/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,84 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets/template.html'), 'utf8');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-story-shelf-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
execFileSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', CASES[mode]),
|
||||
output,
|
||||
]);
|
||||
return fs.readFileSync(output, 'utf8');
|
||||
}
|
||||
|
||||
function canonicalSvg(html) {
|
||||
return html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
}
|
||||
|
||||
test('all five renderers inherit one compact cold-open Story Shelf', () => {
|
||||
for (const mode of Object.keys(CASES)) {
|
||||
const html = render(mode);
|
||||
assert.match(html, /\.guided-views\[data-active-view="all"\]\s*\{/, mode);
|
||||
assert.match(html, /panel\.setAttribute\('data-active-view', view \? view\.id : 'all'\)/, mode);
|
||||
assert.doesNotMatch(canonicalSvg(html), /Story Shelf|data-active-view|guided-view-index/, mode);
|
||||
}
|
||||
});
|
||||
|
||||
test('cold shelf keeps chapter identity and Play while removing only unavailable duplicate controls', () => {
|
||||
assert.match(template, /\.guided-views\[data-active-view="all"\] > #guided-view-prev,[\s\S]*#guided-view-next,[\s\S]*\.guided-view-beat-link,[\s\S]*\.guided-view-all\s*\{\s*display: none;\s*\}/);
|
||||
assert.doesNotMatch(template, /\.guided-views\[data-active-view="all"\][^{]*(?:\.guided-view-play|\.guided-view-index)[^{]*\{\s*display:\s*none/);
|
||||
assert.match(template, /<button class="guided-view-play"/);
|
||||
assert.match(template, /<nav class="guided-view-index"/);
|
||||
});
|
||||
|
||||
test('desktop shelf follows DOM order and returns vertical space to the diagram', () => {
|
||||
const rule = template.match(/\.guided-views\[data-active-view="all"\]\s*\{([^}]*)\}/)?.[1] || '';
|
||||
assert.match(rule, /grid-template-columns:\s*minmax\(11rem,\s*\.72fr\)\s+auto\s+minmax\(0,\s*2fr\)/);
|
||||
assert.match(template, /\.guided-views\[data-active-view="all"\] \.guided-view-copy\s*\{[^}]*grid-column:\s*1/);
|
||||
assert.match(template, /\.guided-views\[data-active-view="all"\] \.guided-view-actions\s*\{[^}]*grid-column:\s*2/);
|
||||
assert.match(template, /\.guided-views\[data-active-view="all"\] \.guided-view-index\s*\{[^}]*grid-column:\s*3/);
|
||||
assert.match(template, /\.guided-views\[data-active-view="all"\] \.guided-view-copy > #guided-view-note\s*\{\s*display:\s*none/);
|
||||
});
|
||||
|
||||
test('mobile shelf preserves 44px controls, horizontal chapters, and honest expansion', () => {
|
||||
assert.match(template, /@media \(max-width: 720px\)[\s\S]*\.guided-views\[data-active-view="all"\][\s\S]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+auto/);
|
||||
assert.match(template, /\.guided-views\[data-active-view="all"\] \.guided-view-index\s*\{[^}]*grid-column:\s*1 \/ -1;[^}]*grid-row:\s*2/);
|
||||
assert.match(template, /\.guided-views\[data-active-view="all"\] \.guided-view-play\s*\{[^}]*min-height:\s*2\.75rem/);
|
||||
assert.match(template, /\.guided-view-chapters\s*\{[^}]*overflow-x:\s*auto/);
|
||||
assert.match(template, /\.guided-view-chapter\s*\{[^}]*min-height:\s*2\.75rem/);
|
||||
});
|
||||
|
||||
test('active stories expand through existing state without storage or a second interaction owner', () => {
|
||||
assert.match(template, /panel\.setAttribute\('data-active-view', 'all'\);\s*panel\.hidden = false/);
|
||||
assert.match(template, /data-active-view', view \? view\.id : 'all'/);
|
||||
assert.match(template, /if \(activeIndex < 0\) activate\(0, \{ playback: true \}\)/);
|
||||
assert.match(template, /showAll\([\s\S]*activeIndex = -1;[\s\S]*render\(\)/);
|
||||
assert.doesNotMatch(template, /storyShelf(?:Open|Expanded|Storage)|archify-story-shelf|localStorage[^\n]*shelf/i);
|
||||
});
|
||||
|
||||
test('Story Shelf remains viewer-only, embed-safe, print-safe, and motion-neutral', () => {
|
||||
assert.match(template, /html\[data-embed="true"\] \.guided-views \{ display: none !important; \}/);
|
||||
assert.match(template, /\.toolbar, \.diagram-nav, \.focus-chip, \.guided-views, \.archify-toast, \.no-print \{ display: none !important; \}/);
|
||||
assert.doesNotMatch(canonicalSvg(render('workflow')), /Story Shelf|guided-view|data-active-view/);
|
||||
assert.doesNotMatch(template, /@keyframes\s+archify-story-shelf|animation:[^;]*story-shelf/i);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,81 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { 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-story-trail-'));
|
||||
|
||||
const CASES = {
|
||||
architecture: 'web-app.architecture.json',
|
||||
workflow: 'agent-tool-call.workflow.json',
|
||||
sequence: 'cache-miss-request.sequence.json',
|
||||
dataflow: 'product-analytics.dataflow.json',
|
||||
lifecycle: 'agent-run.lifecycle.json',
|
||||
};
|
||||
|
||||
function render(mode, example) {
|
||||
const output = path.join(tmp, `${mode}.html`);
|
||||
const result = spawnSync(process.execPath, [
|
||||
path.join(skillRoot, `renderers/${mode}/render-${mode}.mjs`),
|
||||
path.join(skillRoot, 'examples', example),
|
||||
output,
|
||||
], { encoding: 'utf8' });
|
||||
return { result, html: fs.existsSync(output) ? fs.readFileSync(output, 'utf8') : '' };
|
||||
}
|
||||
|
||||
for (const [mode, example] of Object.entries(CASES)) {
|
||||
test(`${mode}: guided paths expose a viewer-only Story Trail`, () => {
|
||||
const { result, html } = render(mode, example);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(html, /id="guided-view-trail" hidden role="group" aria-label="Story trail"/);
|
||||
assert.match(html, /function renderStoryTrail\(view\)/);
|
||||
assert.match(html, /document\.createElement\('button'\)/);
|
||||
assert.match(html, /stop\.type = 'button'/);
|
||||
assert.match(html, /data-story-node/);
|
||||
assert.match(html, /data-story-link/);
|
||||
assert.match(html, /edges\.length === 1 && forward\.length === 1 \? 'forward'/);
|
||||
assert.match(html, /data-story-overlay/);
|
||||
assert.match(html, /data-story-playing/);
|
||||
assert.match(html, /data-story-beat/);
|
||||
assert.match(html, /data-story-beat-state/);
|
||||
assert.match(html, /data-story-beat-step/);
|
||||
assert.match(html, /storySteps\.forEach\(function \(step\)/);
|
||||
assert.match(html, /step\.edges\.forEach\(function \(edge\)/);
|
||||
assert.match(html, /edge\.setAttribute\('data-story-beat-step', String\(edgeBeat\)\)/);
|
||||
assert.match(html, /svg\[data-story-beat\] \[data-edge-from\]\[data-story-beat-step\]/);
|
||||
assert.match(html, /story-trail-flow/);
|
||||
assert.match(html, /function scheduleStoryPlayback\(\)/);
|
||||
assert.match(html, /storyBeatTimer = setTimeout/);
|
||||
assert.match(html, /storyBeatDwellMs = storyBeatDwell\(total\)/);
|
||||
assert.match(html, /Math\.max\(STORY_FOLLOW_MIN_DWELL_MS, VIEW_INTERVAL_MS \/ Math\.max\(1, total\)\)/);
|
||||
assert.match(html, /function storyStep\(view, index, edgeList, byId\)/);
|
||||
assert.match(html, /prefers-reduced-motion: reduce/);
|
||||
assert.match(html, /from === previousId && to === id/);
|
||||
assert.match(html, /from === id && to === previousId/);
|
||||
assert.match(html, /firstEdge\.parentNode\.insertBefore\(overlay, firstEdge\)/);
|
||||
assert.doesNotMatch(html, /content: '\\2192';\s*font-size: 0\.65rem/);
|
||||
|
||||
const generatedSvg = html.match(/<svg\b[\s\S]*?<\/svg>/)?.[0] || '';
|
||||
assert.doesNotMatch(generatedSvg, /data-story-(?:active|playing|beat|step|overlay|carrier)/);
|
||||
});
|
||||
}
|
||||
|
||||
test('Story Trail state is removed from every export clone', () => {
|
||||
const template = fs.readFileSync(path.join(skillRoot, 'assets', 'template.html'), 'utf8');
|
||||
assert.match(template, /clone\.removeAttribute\('data-story-active'\)/);
|
||||
assert.match(template, /clone\.removeAttribute\('data-story-playing'\)/);
|
||||
assert.match(template, /clone\.removeAttribute\('data-story-beat'\)/);
|
||||
assert.match(template, /clone\.querySelectorAll\('\[data-story-overlay\], \[data-story-carrier-overlay\]'\)/);
|
||||
assert.match(template, /clone\.querySelectorAll\('\[data-story-step\], \[data-story-beat-state\], \[data-story-beat-step\]'\)/);
|
||||
assert.match(template, /el\.removeAttribute\('data-story-beat-state'\)/);
|
||||
assert.match(template, /el\.removeAttribute\('data-story-beat-step'\)/);
|
||||
assert.match(template, /el\.style\.removeProperty\('--story-step'\)/);
|
||||
assert.match(template, /canonicalStateClean[\s\S]*data-story-beat-state/);
|
||||
});
|
||||
|
||||
process.on('exit', () => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
@@ -0,0 +1,57 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const template = fs.readFileSync(path.resolve(__dirname, '../assets/template.html'), 'utf8');
|
||||
|
||||
test('toolbar keeps four independent controls with explicit open states', () => {
|
||||
assert.match(template, /\.toolbar \{[\s\S]*?gap: 0\.5rem;[\s\S]*?padding: 0;[\s\S]*?background: transparent;[\s\S]*?box-shadow: none;/);
|
||||
assert.match(template, /\.toolbar button \{[\s\S]*?background: var\(--toolbar-bg\);[\s\S]*?border: 1px solid var\(--toolbar-border\);/);
|
||||
assert.match(template, /button\[aria-expanded="true"\]/);
|
||||
assert.doesNotMatch(template, /\.preset-wrap::before,[\s\S]*?\.export-wrap::before/);
|
||||
assert.match(template, /<span id="theme-icon" class="toolbar-icon"/);
|
||||
assert.match(template, /<span id="present-icon" class="toolbar-icon"/);
|
||||
assert.doesNotMatch(template.match(/<div class="toolbar"[\s\S]*?<div class="container">/)?.[0] || '', /<svg\b/);
|
||||
assert.doesNotMatch(template, /icon\.textContent =/);
|
||||
});
|
||||
|
||||
test('export menu has grouped, single-column rows and a zoom-safe width', () => {
|
||||
const sectionCss = template.match(/\.export-menu-section \{[\s\S]*?\}/)?.[0] || '';
|
||||
assert.match(template, /class="export-menu-section" role="group" aria-label="\{\{i18n:viewer\.export\.share\}\}"/);
|
||||
assert.match(template, /class="export-menu-section" role="group" aria-label="\{\{i18n:viewer\.export\.raster\}\}"/);
|
||||
assert.match(template, /class="export-menu-section" role="group" aria-label="\{\{i18n:viewer\.export\.vectorMotion\}\}"/);
|
||||
assert.match(template, /class="export-menu-header" role="presentation"/);
|
||||
assert.match(template, /\.toolbar \.export-menu \{[\s\S]*?width: 19rem;[\s\S]*?max-width: calc\(100vw - 2rem\);/);
|
||||
assert.match(template, /\.export-menu-section \{[\s\S]*?grid-template-columns: minmax\(0, 1fr\);/);
|
||||
assert.doesNotMatch(sectionCss, /repeat\(2/);
|
||||
assert.match(template, /\.toolbar \.export-menu button \{[\s\S]*?grid-template-columns: 1\.15rem minmax\(0, 1fr\);[\s\S]*?white-space: nowrap;/);
|
||||
assert.match(template, /\.export-item-copy strong,[\s\S]*?\.export-item-copy small \{ display: block; \}/);
|
||||
});
|
||||
|
||||
test('mobile menus share one viewport-safe placement and disabled exports remain explicit', () => {
|
||||
assert.match(template, /@media \(max-width: 720px\)[\s\S]*?\.toolbar \.preset-menu,[\s\S]*?\.toolbar \.export-menu \{[\s\S]*?position: fixed;/);
|
||||
assert.match(template, /\.toolbar \.export-menu button:disabled \{[\s\S]*?cursor: not-allowed;/);
|
||||
assert.doesNotMatch(template, /it\.style\.opacity = '0\.5'/);
|
||||
});
|
||||
|
||||
test('diagram view dock stays compact on desktop and touch-safe on narrow screens', () => {
|
||||
assert.match(template, /\.diagram-nav \{[\s\S]*?padding: 0\.15rem;[\s\S]*?border-radius: 0\.58rem;/);
|
||||
assert.match(template, /\.diagram-nav button \{[\s\S]*?min-width: 2rem;[\s\S]*?height: 2rem;/);
|
||||
assert.match(template, /@media \(max-width: 720px\)[\s\S]*?\.diagram-nav button \{[\s\S]*?min-width: 2\.75rem;[\s\S]*?height: 2\.75rem;/);
|
||||
assert.match(template, /class="diagram-nav-icon find"/);
|
||||
assert.match(template, /class="diagram-nav-icon guide"/);
|
||||
assert.match(template, /class="diagram-nav-icon minus"/);
|
||||
assert.match(template, /class="diagram-nav-icon plus"/);
|
||||
});
|
||||
|
||||
test('diagram view reset separates semantic detail from zoom percentage', () => {
|
||||
assert.match(template, /data-view="reset"[\s\S]*?data-view-detail hidden>\{\{i18n:viewer\.nav\.read\}\}<[\s\S]*?data-view-percent>100%</);
|
||||
assert.match(template, /var resolvedLevel = semantic \? viewerText\('viewer\.nav\.level\.auto'\) : levelLabel;/);
|
||||
assert.match(template, /var showDetailLevel = semantic \|\| detail !== 'read';/);
|
||||
assert.match(template, /resetDetailLabel\.hidden = !showDetailLevel/);
|
||||
assert.match(template, /resetPercentLabel\.textContent = percent/);
|
||||
assert.match(template, /resetBtn\.toggleAttribute\('data-detail-visible', showDetailLevel\)/);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user