- 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.
423 lines
17 KiB
JavaScript
423 lines
17 KiB
JavaScript
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);
|
|
}
|
|
});
|