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,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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user