Production /login rendered a blank page (browser console: SyntaxError: 10 through the vue-i18n parser). Root cause proved with a RED regression (RES: vue-i18n public API reproduces 'Invalid linked format' code 10) plus an independent reviewer: auth.emailPlaceholder="name@company.com" is invalid vue-i18n linked-message syntax, so createI18n() throws a message-compilation SyntaxError while LoginView renders t('auth.emailPlaceholder'). Fix: escape the literal at-sign as name{'@'}company.com in th and en so the message compiles and the visible label is unchanged (name@company.com). Add an all-translations regression that translates every string in th/en (objects and arrays) through vue-i18n's public createI18n/global.t API and asserts the visible placeholder value. Verification: - RED test failed at th:auth.emailPlaceholder (code 10) before the fix. - Independent reviewer verified reproduction + fix, finished PASS. - Frontend tests 11 passed; production build passed (index-B4oVHpLg.js). - Chrome headless rendered the login card, Thai heading, and name@company.com from the production dist. Artifact checksum hash 3621155075b3d9245d2d05511aaf39b1b0cbcaeea local vs server.
106 lines
3.5 KiB
JavaScript
106 lines
3.5 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import test from 'node:test'
|
|
import { createI18n } from 'vue-i18n'
|
|
|
|
const repoRoot = path.resolve(import.meta.dirname, '../..')
|
|
const read = relativePath => fs.readFileSync(path.join(repoRoot, relativePath), 'utf8')
|
|
const parseJson = relativePath => JSON.parse(read(relativePath))
|
|
|
|
const flattenKeys = (value, prefix = '') => {
|
|
const keys = []
|
|
for (const [key, child] of Object.entries(value)) {
|
|
const fullKey = prefix ? `${prefix}.${key}` : key
|
|
if (child && typeof child === 'object' && !Array.isArray(child)) {
|
|
keys.push(...flattenKeys(child, fullKey))
|
|
} else {
|
|
keys.push(fullKey)
|
|
}
|
|
}
|
|
return keys.sort()
|
|
}
|
|
|
|
const flattenStringKeys = (value, prefix = '') => {
|
|
const keys = []
|
|
for (const [key, child] of Object.entries(value)) {
|
|
const fullKey = prefix ? `${prefix}.${key}` : key
|
|
if (child && typeof child === 'object') {
|
|
keys.push(...flattenStringKeys(child, fullKey))
|
|
} else if (typeof child === 'string') {
|
|
keys.push(fullKey)
|
|
}
|
|
}
|
|
return keys.sort()
|
|
}
|
|
|
|
test('locale registry exposes only Thai and English', () => {
|
|
const languages = parseJson('locales/languages.json')
|
|
assert.deepEqual(Object.keys(languages).sort(), ['en', 'th'])
|
|
assert.equal(languages.en.label, 'English')
|
|
assert.equal(languages.th.label, 'ไทย')
|
|
})
|
|
|
|
test('Thai and English translation dictionaries have the same keys', () => {
|
|
assert.deepEqual(
|
|
flattenKeys(parseJson('locales/en.json')),
|
|
flattenKeys(parseJson('locales/th.json')),
|
|
)
|
|
})
|
|
|
|
test('every translation message compiles through the vue-i18n public API', () => {
|
|
const messages = {
|
|
th: parseJson('locales/th.json'),
|
|
en: parseJson('locales/en.json'),
|
|
}
|
|
const i18n = createI18n({ legacy: false, locale: 'th', messages })
|
|
|
|
for (const locale of ['th', 'en']) {
|
|
i18n.global.locale.value = locale
|
|
for (const keyPath of flattenStringKeys(messages[locale])) {
|
|
assert.doesNotThrow(
|
|
() => i18n.global.t(keyPath),
|
|
`${locale}:${keyPath} is not valid vue-i18n syntax`,
|
|
)
|
|
}
|
|
}
|
|
|
|
assert.equal(i18n.global.t('auth.emailPlaceholder'), 'name@company.com')
|
|
})
|
|
|
|
test('frontend i18n uses Thai as the safe default and fallback', () => {
|
|
const source = read('frontend/src/i18n/index.js')
|
|
assert.match(source, /DEFAULT_LOCALE\s*=\s*['"]th['"]/)
|
|
assert.match(source, /fallbackLocale:\s*DEFAULT_LOCALE/)
|
|
assert.doesNotMatch(source, /\|\|\s*['"]zh['"]|fallbackLocale:\s*['"]zh['"]/)
|
|
})
|
|
|
|
test('HTML metadata has no Chinese locale or font dependency', () => {
|
|
const html = read('frontend/index.html')
|
|
assert.match(html, /<html\s+lang=["']th["']/)
|
|
assert.doesNotMatch(html, /lang=["']zh|Noto Sans SC|预测|社交媒体|中文/)
|
|
})
|
|
|
|
test('production build text artifacts contain no CJK code points', () => {
|
|
const distRoot = path.join(repoRoot, 'frontend/dist')
|
|
const binaryExtensions = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico'])
|
|
const textFiles = []
|
|
|
|
const visit = directory => {
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
const entryPath = path.join(directory, entry.name)
|
|
if (entry.isDirectory()) {
|
|
visit(entryPath)
|
|
} else if (!binaryExtensions.has(path.extname(entry.name).toLowerCase())) {
|
|
textFiles.push(entryPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
visit(distRoot)
|
|
for (const filePath of textFiles) {
|
|
const content = fs.readFileSync(filePath, 'utf8')
|
|
assert.doesNotMatch(content, /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/, filePath)
|
|
}
|
|
})
|