Files

423 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
});