- 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.
208 lines
9.2 KiB
JavaScript
208 lines
9.2 KiB
JavaScript
// 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');
|