mirror of
https://github.com/LizardByte/Sunshine.git
synced 2026-10-23 03:28:06 +09:00
fix(web): fall back for malformed localization messages (#5920)
This commit is contained in:
1 parent
c33f3b46fa
commit
601a9f5975
6 files changed
+157
-5
No files matched your search
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user