fix(web): fall back for malformed localization messages (#5920)

This commit is contained in:
Dave Lane authored and GitHub committed 2026-10-13 21:02:11 -04:00
1 parent c33f3b46fa
commit 601a9f5975
6 files changed
+157 -5

No files matched your search

+1
View File
@@ -9,6 +9,7 @@
"version": "0.0.0",
"license": "GPL-3.0-only",
"dependencies": {
"@intlify/message-compiler": "11.4.14",
"@lucide/vue": "1.56.0",
"bootstrap": "5.3.8",
"date-fns": "4.4.0",
+1
View File
@@ -24,6 +24,7 @@
},
"type": "module",
"dependencies": {
"@intlify/message-compiler": "11.4.14",
"@lucide/vue": "1.56.0",
"bootstrap": "5.3.8",
"date-fns": "4.4.0",
+28 -2
View File
@@ -1,4 +1,5 @@
import {createI18n} from "vue-i18n";
import {createParser, defaultOnError} from "@intlify/message-compiler";
// Import only the fallback language files
import en from './public/assets/locale/en.json'
@@ -42,7 +43,32 @@ function resolveLocale(configuredLocale) {
}
/**
* @brief Load messages for a supported locale with an English fallback.
* @brief Omit malformed translation messages so Vue I18n can fall back to English.
*
* @param {object} messages Nested translation messages.
* @param {string} locale Locale identifier used in diagnostics.
* @param {string} [prefix] Parent message key used in diagnostics.
* @return {object} Messages with valid Vue I18n syntax.
*/
function filterLocaleMessages(messages, locale, prefix = '') {
return Object.fromEntries(Object.entries(messages).flatMap(([key, message]) => {
const path = prefix ? `${prefix}.${key}` : key;
if (typeof message !== 'string') {
return [[key, filterLocaleMessages(message, locale, path)]];
}
try {
createParser({ onError: defaultOnError }).parse(message);
return [[key, message]];
} catch (error) {
console.error('Ignoring invalid translation', locale, path, error);
return [];
}
}));
}
/**
* @brief Load valid messages for a supported locale with an English fallback.
*
* @param {string} locale Supported locale identifier.
* @param {Function} loadTranslation Function that loads a translation path.
@@ -59,7 +85,7 @@ export async function loadLocaleMessages(
const translation = await loadTranslation(localePaths.get(locale));
messages = Object.fromEntries([
['en', en],
[locale, translation],
[locale, filterLocaleMessages(translation, locale)],
]);
}
} catch (e) {
+47 -2
View File
@@ -6,8 +6,21 @@ vi.mock('../../src_assets/common/assets/web/Navbar.vue', () => ({
}))
import Troubleshooting from '../../src_assets/common/assets/web/Troubleshooting.vue'
import createSunshineI18n from '../../src_assets/common/assets/web/locale.js'
import pt from '../../src_assets/common/assets/web/public/assets/locale/pt.json'
import ptBR from '../../src_assets/common/assets/web/public/assets/locale/pt_BR.json'
async function mountTroubleshooting(platform, gamepadDriver, licenseStatus = {}, permissions = []) {
/**
* @brief Mount troubleshooting with mocked API responses and optional real translations.
*
* @param {string} platform Host platform.
* @param {string} gamepadDriver Selected gamepad driver.
* @param {object} licenseStatus License status overrides.
* @param {Array} permissions Platform permissions.
* @param {object|null} i18n Optional Vue I18n plugin.
* @return {Promise<object>} Mounted troubleshooting wrapper.
*/
async function mountTroubleshooting(platform, gamepadDriver, licenseStatus = {}, permissions = [], i18n = null) {
vi.stubGlobal('fetch', vi.fn(async (url) => {
if (url === '/api/config') {
return { json: async () => ({ platform, gamepad_driver: gamepadDriver }) }
@@ -48,7 +61,8 @@ async function mountTroubleshooting(platform, gamepadDriver, licenseStatus = {},
const wrapper = mount(Troubleshooting, {
global: {
mocks: { $t: key => key },
mocks: i18n ? {} : { $t: key => key },
plugins: i18n ? [i18n] : [],
stubs: { Navbar: true, RouterLink: true },
},
})
@@ -58,9 +72,40 @@ async function mountTroubleshooting(platform, gamepadDriver, licenseStatus = {},
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('virtual input troubleshooting', () => {
it.each([
['pt', pt],
['pt_BR', ptBR],
])('renders Linux permissions and device management with the bundled %s catalog', async (locale, catalog) => {
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.stubGlobal('fetch', vi.fn(async url => ({
json: async () => url === './api/configLocale' ? { locale } : catalog,
})))
const i18n = await createSunshineI18n()
const wrapper = await mountTroubleshooting('linux', 'none', {}, [
{ id: 'input', status: 'granted', required: true },
], i18n)
try {
expect(i18n.global.t('_common.loading')).toBe('Loading...')
expect(i18n.global.t('troubleshooting.driver_release_checking')).toBe('Checking...')
expect(i18n.global.t('apps.delete_confirm', { name: 'Moonlight' })).toBe(
catalog.apps.delete_confirm.replace('{name}', 'Moonlight')
)
expect(wrapper.get('h1').text()).toBe(catalog.troubleshooting.troubleshooting)
expect(wrapper.get('.permission-table-shell th').text()).toBe('Permission')
expect(wrapper.get('#unpair').text()).toBe(catalog.troubleshooting.unpair_title)
expect(wrapper.get('#logs').text()).toBe(catalog.troubleshooting.logs)
expect(wrapper.find('.permission-table-shell tbody tr').exists()).toBe(true)
} finally {
wrapper.unmount()
i18n.dispose()
}
})
it('orders permission rows consistently across platforms', async () => {
const permissions = [
{ id: 'local_network', status: 'on_use', required: true },
+42 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('vue-i18n', () => ({
createI18n: vi.fn(options => options),
@@ -8,6 +8,11 @@ import { createI18n } from 'vue-i18n'
import createSunshineI18n, { loadLocaleMessages } from '../../src_assets/common/assets/web/locale.js'
describe('locale initialization', () => {
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
beforeEach(() => {
createI18n.mockClear()
document.documentElement.removeAttribute('lang')
@@ -58,4 +63,40 @@ describe('locale initialization', () => {
expect(error).toHaveBeenCalledWith('Failed to download translations', expect.any(Error))
error.mockRestore()
})
it('omits malformed messages while preserving valid translations and interpolation syntax', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const catalog = {
broken: 'Loadingchar@@0',
nested: {
broken: 'Hello {name',
greeting: 'Hallo {name}',
list: '{0}',
literal: "{'@'}",
linked: '@:nested.greeting',
plural: 'none | one | {count} items',
empty: '',
},
}
const original = structuredClone(catalog)
const result = await loadLocaleMessages('de', async () => catalog)
expect(result.locale).toBe('de')
expect(result.messages.de).toEqual({
nested: {
greeting: 'Hallo {name}',
list: '{0}',
literal: "{'@'}",
linked: '@:nested.greeting',
plural: 'none | one | {count} items',
empty: '',
},
})
expect(result.messages.en).toBeDefined()
expect(catalog).toEqual(original)
expect(error).toHaveBeenCalledTimes(2)
expect(error).toHaveBeenCalledWith('Ignoring invalid translation', 'de', 'broken', expect.any(SyntaxError))
expect(error).toHaveBeenCalledWith('Ignoring invalid translation', 'de', 'nested.broken', expect.any(SyntaxError))
})
})
+38
View File
@@ -0,0 +1,38 @@
// @vitest-environment node
import { readFileSync, readdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const localeDirectory = resolve(dirname(fileURLToPath(import.meta.url)), '../../src_assets/common/assets/web/public/assets/locale')
const localeFiles = readdirSync(localeDirectory).filter(file => file.endsWith('.json')).sort()
/**
* @brief Find corrupted numbered placeholders in nested localization messages.
*
* @param {object} messages Parsed localization messages.
* @param {string} [prefix] Parent message key.
* @return {string[]} Corrupted message keys and their values.
*/
function findCorruptedMessages(messages, prefix = '') {
return Object.entries(messages).flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key
if (typeof value === 'string') {
return /(?:char|format)@@\d+/u.test(value) ? [`${path}: ${value}`] : []
}
return findCorruptedMessages(value, path)
})
}
describe('localization JSON files', () => {
it('discovers localization catalogs to validate', () => {
expect(localeFiles.length).toBeGreaterThan(0)
})
it.each(localeFiles)('%s contains no corrupted localization placeholders', file => {
const messages = JSON.parse(readFileSync(resolve(localeDirectory, file), 'utf8'))
expect(findCorruptedMessages(messages), `Corrupted localization placeholders in ${file}`).toEqual([])
})
})