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, /^\n]*\blang="zh-CN"/); assert.ok(result.html.includes(`${authoredTitle}`), `${type}: authored title changed`); assert.ok(result.html.includes(`

${authoredTitle}

`), `${type}: authored heading changed`); assert.match(result.html, /]*>\u56fe\u4f8b<\/text>/); assert.match(result.html, /aria-label="\u805a\u7126/); assert.match(result.html, new RegExp(`\u7531 Archify \u751f\u6210\u7684`)); assert.match(result.html, /"locale":"zh-CN"/); assert.match(result.html, />\u5bfc\u51fa\u56fe\u8868 { 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(`^\\n]*\\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(`${candidate.document.meta.title}`), type); assert.match(result.html, />导出图表${candidate.document.meta.title} Diagram`), type); assert.match(result.html, />Export diagram { 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, /^\n${authoredTitle} Diagram`), `${type}: authored title changed`); assert.ok(result.html.includes(`

${authoredTitle}

`), `${type}: authored heading changed`); assert.match(result.html, /]*\blang="en"/); assert.match(result.html, /aria-label="Focus /); assert.match(result.html, /"locale":"en"/); assert.match(result.html, />Export diagram { 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); } });