diff --git a/lib/translation.ts b/lib/translation.ts index 33ca8529..a4fa3bf9 100644 --- a/lib/translation.ts +++ b/lib/translation.ts @@ -146,25 +146,28 @@ export function translatePlural( * @return {Promise} promise */ export function loadTranslations(appName: string, callback: (...args: []) => unknown) { - // already available ? + interface TranslationBundle { + translations: Translations + pluralForm: string + } + if (hasAppTranslations(appName) || getLocale() === 'en') { return Promise.resolve().then(callback) } const url = generateFilePath(appName, 'l10n', getLocale() + '.json') - const promise = new Promise<{ - translations: Translations - pluralForm: string - }>((resolve, reject) => { + const promise = new Promise((resolve, reject) => { const request = new XMLHttpRequest() - request.open('GET', url, false) + request.open('GET', url, true) request.onerror = () => { - reject(new Error(request.statusText)) + reject(new Error(request.statusText || 'Network error')) } request.onload = () => { if (request.status >= 200 && request.status < 300) { - resolve(JSON.parse(request.responseText)) + const bundle = JSON.parse(request.responseText) + if (bundle?.translations) resolve(bundle) + else reject(new Error('Invalid content of translation bundle')) } else { reject(new Error(request.statusText)) } @@ -175,9 +178,7 @@ export function loadTranslations(appName: string, callback: (...args: []) => unk // load JSON translation bundle per AJAX return promise .then((result) => { - if (result.translations) { - register(appName, result.translations) - } + register(appName, result.translations) return result }) .then(callback) diff --git a/package-lock.json b/package-lock.json index 1e42c3e0..52e9647d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "gettext-parser": "^6.0.0", "jest": "^29.3.1", "jest-environment-jsdom": "^29.3.1", + "mock-xmlhttprequest": "^8.1.0", "rollup": "^3.9.1", "ts-jest": "^29.0.3", "tslib": "^2.4.1", @@ -5984,6 +5985,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mock-xmlhttprequest": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/mock-xmlhttprequest/-/mock-xmlhttprequest-8.1.0.tgz", + "integrity": "sha512-hOpjaDRdWQTscwOME6W50OTT9duY1hm8w7Nx3i5GE35OHseiR3Z2s2Azy1BhpY7dUioiNiL0bs7dfEla3siRnw==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -12484,6 +12494,12 @@ "dev": true, "peer": true }, + "mock-xmlhttprequest": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/mock-xmlhttprequest/-/mock-xmlhttprequest-8.1.0.tgz", + "integrity": "sha512-hOpjaDRdWQTscwOME6W50OTT9duY1hm8w7Nx3i5GE35OHseiR3Z2s2Azy1BhpY7dUioiNiL0bs7dfEla3siRnw==", + "dev": true + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", diff --git a/package.json b/package.json index eb30e30f..612b634b 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "gettext-parser": "^6.0.0", "jest": "^29.3.1", "jest-environment-jsdom": "^29.3.1", + "mock-xmlhttprequest": "^8.1.0", "rollup": "^3.9.1", "ts-jest": "^29.0.3", "tslib": "^2.4.1", diff --git a/tests/loadTranslations.test.ts b/tests/loadTranslations.test.ts new file mode 100644 index 00000000..b9a04335 --- /dev/null +++ b/tests/loadTranslations.test.ts @@ -0,0 +1,166 @@ +import { MockXhrServer, newServer } from 'mock-xmlhttprequest' + +import { loadTranslations, register, translate, _unregister } from '../lib/translation' + +const setLocale = (locale) => document.documentElement.setAttribute('data-locale', locale) + +describe('loadTranslations', () => { + let server: MockXhrServer + + beforeEach(() => { + setLocale('de') + server = newServer() + .addHandler('GET', '/myapp/l10n/de.json', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + translations: { + 'Hello world!': 'Hallo Welt!', + }, + }), + }) + .addHandler('GET', '/invalid/l10n/de.json', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + strings: { + 'Hello world!': 'Hallo Welt!', + }, + }), + }) + .addHandler('GET', '/empty/l10n/de.json', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: '', + }) + .addHandler('GET', '/404/l10n/de.json', { + status: 404, + statusText: 'Not Found', + }) + .addHandler('GET', '/500/l10n/de.json', { + status: 500, + statusText: 'Internal Server Error', + }) + .addHandler('GET', '/networkissue/l10n/de.json', (req) => req.setNetworkError()) + .setDefault404() + server + .disableTimeout() + server + .install() + }) + + afterEach(() => { + server.remove() + jest.clearAllMocks() + }) + + it('calls callback if app already exists', async () => { + register('myapp', { + Bye: 'Tschüss', + }) + + const callback = jest.fn() + try { + await loadTranslations('myapp', callback) + // Callback called + expect(callback).toBeCalledTimes(1) + // No requests done + expect(server.getRequestLog().length).toBe(0) + // Old translations work + expect(translate('myapp', 'Bye')).toBe('Tschüss') + // does not override translations + expect(translate('myapp', 'Hello world!')).toBe('Hello world!') + } catch (e) { + expect(e).toBe('Unexpected error') + } finally { + _unregister('myapp') + } + }) + + it('calls callback if locale is English', async () => { + setLocale('en') + const callback = jest.fn() + + try { + await loadTranslations('myapp', callback) + // Callback called + expect(callback).toBeCalledTimes(1) + // No requests done + expect(server.getRequestLog().length).toBe(0) + } catch (e) { + expect(e).toBe('Unexpected error') + } + }) + + it('registers new translations', async () => { + const callback = jest.fn() + try { + await loadTranslations('myapp', callback) + // Callback called + expect(callback).toBeCalledTimes(1) + // No requests done + expect(server.getRequestLog().length).toBe(1) + // New translations work + expect(translate('myapp', 'Hello world!')).toBe('Hallo Welt!') + } catch (e) { + expect(e).toBe('Unexpected error') + } finally { + console.warn(server.getRequestLog()[0]) + } + }) + + it('does reject on network error', async () => { + const callback = jest.fn() + try { + await loadTranslations('networkissue', callback) + expect('').toBe('Unexpected pass') + } catch (e) { + expect(e instanceof Error).toBe(true) + expect((e).message).toBe('Network error') + } + }) + + it('does reject on server error', async () => { + const callback = jest.fn() + try { + await loadTranslations('500', callback) + expect('').toBe('Unexpected pass') + } catch (e) { + expect(e instanceof Error).toBe(true) + expect((e).message).toBe('Internal Server Error') + } + }) + + it('does reject on unavailable bundle', async () => { + const callback = jest.fn() + try { + await loadTranslations('404', callback) + expect('').toBe('Unexpected pass') + } catch (e) { + expect(e instanceof Error).toBe(true) + expect((e).message).toBe('Not Found') + } + }) + + it('does reject on invalid bundle', async () => { + const callback = jest.fn() + try { + await loadTranslations('invalid', callback) + expect('').toBe('Unexpected pass') + } catch (e) { + expect(e instanceof Error).toBe(true) + expect((e).message).toBe('Invalid content of translation bundle') + } + }) + + it('does reject on empty bundle', async () => { + const callback = jest.fn() + try { + await loadTranslations('invalid', callback) + expect('').toBe('Unexpected pass') + } catch (e) { + expect(e instanceof Error).toBe(true) + expect((e).message).toBe('Invalid content of translation bundle') + } + }) +})