import assert from 'node:assert/strict'; import { test } from 'node:test'; import { applyPacks, checkPackShape, compilePack, coverage, createStringEngine, driftReport, hashText, inventoryLookup, isPageInventory, packInventory, renderMessage, restoreCatalog, sliceInventory, validateString, type InventoryEntry, type StringInventory, type StringPack, } from '../src/stringPacks.ts'; import { parseMessage } from '../src/i18n.ts'; import { installStringPacks, mapChunkRequires, watchChunkArray, type CatalogHookAdapter, type CatalogSink } from '../src/stringPackHook.ts'; import { installTextNet } from '../src/textNet.ts'; // A small site: two texts, two messages (one plural), one rich entry, one formatter, one lazy group. const INVENTORY: StringInventory = { format: 1, site: 'demo', catalog: 'web', base: 'en', build: 'b1', entries: { a0000001: { kind: 'text', hash: hashText('Home') }, a0000002: { kind: 'text', hash: hashText('Following') }, b0000001: { kind: 'message', params: ['name'], hash: hashText('Hello {name}') }, b0000002: { kind: 'message', params: ['count', 'name'], plural: ['count'], hash: hashText('{count} posts by {name}') }, c0000001: { kind: 'rich', hash: hashText('rich') }, d0000001: { kind: 'format', hash: hashText('fmt') }, e0000001: { kind: 'text', group: 'emoji', hash: hashText('Grinning face') }, }, }; /** The site's own values, as its catalog registers them. */ const site = () => ({ a0000001: 'Home', a0000002: 'Following', b0000001: (e: { name: string }) => `Hello ${e.name}`, b0000002: (e: { count: number; name: string }) => `${e.count} ${e.count === 1 ? 'post' : 'posts'} by ${e.name}`, c0000001: () => ({ element: true }), d0000001: (n: number) => String(n), e0000001: 'Grinning face', }); const pack = (strings: Record, extra: Partial = {}): StringPack => ({ format: 1, site: 'demo', catalog: 'web', language: 'en-x-test', name: 'Test', authors: ['A tester'], base: 'en', strings, ...extra, }); test('hashText: stable, 11 characters, different for different text', () => { assert.equal(hashText('Home'), hashText('Home')); assert.match(hashText('Home'), /^[0-9a-z]{11}$/); assert.notEqual(hashText('Home'), hashText('home')); assert.equal(hashText(''), hashText('')); }); test('pack envelope: what is refused as a whole', () => { assert.deepEqual(checkPackShape(pack({ a0000001: 'Hoom' })), []); const errors = (input: unknown) => checkPackShape(input).filter(issue => issue.level === 'error').map(issue => issue.message).join(' | '); assert.match(errors([]), /JSON object/); assert.match(errors({ ...pack({}), format: 2 }), /format/); assert.match(errors({ ...pack({}), language: 'not a tag' }), /language/); assert.match(errors({ ...pack({}), name: 'x' }), /name/); assert.match(errors({ ...pack({}), authors: 'me' }), /authors/); assert.match(errors({ ...pack({}), strings: [] }), /strings/); assert.match(errors({ ...pack({}), site: 'Demo Site' }), /site/); assert.deepEqual(checkPackShape({ ...pack({}), extra: 1 }).map(issue => issue.level), ['warning']); assert.throws(() => compilePack({ ...pack({}), format: 2 }, INVENTORY), /invalid string pack/); assert.throws(() => compilePack(pack({}, { catalog: 'other' }), INVENTORY), /the pack is for demo\/other/); }); test('validator: parameters, plural kind, other, markup', () => { const entry = (id: string) => INVENTORY.entries[id]!; const reason = (id: string, value: unknown) => { const result = validateString(entry(id), value); return 'reason' in result ? result.reason : ''; }; // Accepted. assert.equal(reason('a0000001', 'Hoom'), ''); assert.equal(reason('b0000001', 'Ahoy {name}'), ''); assert.equal(reason('b0000001', 'Ahoy'), '', 'a subset of the parameters is fine'); assert.equal(reason('b0000002', '{count, plural, one {# postle} other {# postles}} by {name}'), ''); assert.equal(reason('b0000002', '{count, plural, =0 {nothing} other {#}}'), ''); assert.equal(reason('b0000002', '{count} by {name}'), '', 'a language without plural forms needs none'); assert.equal(reason('a0000001', "It's '{'fine'}'"), '', 'quoted braces are text'); // Refused. assert.match(reason('a0000001', 'Hi {name}'), /takes no parameters/); assert.match(reason('b0000001', 'Ahoy {user}'), /not a parameter of the original \(it has: name\)/); assert.match(reason('b0000001', '{name, plural, one {a} other {b}}'), /not a plural of the original/); assert.match(reason('b0000002', '{name, select, a {x} other {y}}'), /not a select of the original/); assert.match(reason('b0000002', '{count, plural, one {a}}'), /"other"/); assert.match(reason('b0000002', '{count, plural, lots {a} other {b}}'), /not a plural form/); assert.match(reason('b0000002', '{count, selectordinal, one {a} other {b}}'), /selectordinal/); assert.match(reason('b0000002', '{count, number}'), /not supported/); assert.match(reason('b0000002', '{count, plural, offset:1 other {b}}'), /offset/); assert.match(reason('a0000001', 'Home'), /markup is not allowed/); assert.match(reason('a0000001', 'a > b'), /markup is not allowed/); assert.match(reason('a0000001', 'Home {'), /invalid message syntax/); assert.match(reason('a0000001', ''), /empty/); assert.match(reason('a0000001', 42), /must be text/); assert.match(reason('a0000001', 'x'.repeat(2001)), /longer than/); assert.match(reason('a0000001', 'a‮b'), /control or bidi/); assert.match(reason('c0000001', 'text'), /not translatable yet \(rich\)/); assert.match(reason('d0000001', 'text'), /not translatable yet \(format\)/); }); test('validator: tag slots must be the original\'s', () => { const shape: InventoryEntry = { kind: 'text', tags: ['link'], hash: 'x' }; const reason = (value: string) => { const result = validateString(shape, value); return 'reason' in result ? result.reason : ''; }; assert.equal(reason('Read the rules'), ''); assert.match(reason('Read the rules'), /tags are missing: /); assert.match(reason('Read the rules'), / is not a tag of the original/); assert.match(reason('Read the rules'), /not closed/); assert.match(reason('Read the rules'), /does not close/); assert.match(reason('1 < 2 x'), /may only be used for the original's tags/); }); test('compilePack: unknown keys are reported and never applied; bad values are rejected one by one', () => { const compiled = compilePack(pack({ a0000001: 'Hoom', b0000001: 'Ahoy {name}', zzzzzzzz: 'Nope', a0000002: 'x', c0000001: 'rich text', b0000002: '{count, plural, one {# postle} other {# postles}}', }), INVENTORY); assert.deepEqual([...compiled.values.keys()].sort(), ['a0000001', 'b0000001', 'b0000002']); assert.deepEqual(compiled.report.unknown, ['zzzzzzzz']); assert.deepEqual(compiled.report.rejected.map(item => item.id).sort(), ['a0000002', 'c0000001']); assert.equal(compiled.report.total, 6); assert.equal(compiled.report.valid, 3); // An unknown key reaches the engine as nothing at all. const engine = createStringEngine(INVENTORY, [compiled]); assert.equal(engine.resolve('zzzzzzzz', 'Site text'), 'Site text'); assert.equal(engine.resolve('a0000002', 'Following'), 'Following', 'a rejected value leaves the original'); }); test('renderMessage: concatenation semantics, plural by the pack language, exact forms, select', () => { const render = (source: string, params: Record, languages: (string | undefined)[] = ['en']) => renderMessage(parseMessage(source), params, languages); assert.equal(render('Ahoy {name}!', { name: 'Ada' }), 'Ahoy Ada!'); assert.equal(render('Ahoy {name}!', {}), 'Ahoy !'); assert.equal(render('{count} of them', { count: '1,234' }), '1,234 of them', 'a parameter is inserted as it comes'); const plural = '{count, plural, =0 {none} one {# postle} other {# postles}}'; assert.equal(render(plural, { count: 0 }), 'none'); assert.equal(render(plural, { count: 1 }), '1 postle'); assert.equal(render(plural, { count: 5 }), '5 postles'); assert.equal(render(plural, { count: '7' }), '7 postles'); assert.equal(render(plural, { count: 'many' }), 'many postles', 'not a number: other'); // A language the runtime does not know falls back to the pack's base, then to `other`. assert.equal(render(plural, { count: 1 }, ['qqq-x-made-up', 'en']), '1 postle'); assert.equal(render(plural, { count: 1 }, ['zz-x-test', undefined]), '1 postles'); assert.equal(render(plural, { count: 1 }, ['en-x-test']), '1 postle', 'a private-use tag uses its language'); assert.equal(render('{count, plural, one {# gönderi} other {# gönderi}}', { count: 2 }, ['tr']), '2 gönderi'); assert.equal(render('{kind, select, a {first} other {rest}}', { kind: 'a' }), 'first'); assert.equal(render('{kind, select, a {first} other {rest}}', { kind: 'b' }), 'rest'); }); test('engine: a pack value when the key has one, otherwise exactly the site\'s value', () => { const values = site(); const engine = createStringEngine(INVENTORY, [compilePack(pack({ a0000001: 'Hoom', b0000002: '{count, plural, one {# postle} other {# postles}} by {name}', }), INVENTORY)]); const out = applyPacks(values, engine); assert.equal(out.a0000001, 'Hoom'); assert.equal((out.b0000002 as (e: unknown) => string)({ count: 1, name: 'Ada' }), '1 postle by Ada'); assert.equal((out.b0000002 as (e: unknown) => string)({ count: 3, name: 'Ada' }), '3 postles by Ada'); assert.equal((out.b0000002 as () => string)(), ' postles by ', 'called without parameters: still a string'); // Untouched keys are the very same values (same function objects). for (const id of ['a0000002', 'b0000001', 'c0000001', 'd0000001', 'e0000001'] as const) assert.equal(out[id], values[id], id); // The site's catalog object was not changed. assert.deepEqual(Object.keys(values), Object.keys(site())); assert.equal(values.a0000001, 'Home'); assert.deepEqual(engine.stats(), { seen: 7, applied: 2, unlisted: [], mismatched: [] }); }); test('engine: applying twice equals applying once', () => { const values = site(); const engine = createStringEngine(INVENTORY, [compilePack(pack({ a0000001: 'Hoom', b0000001: 'Ahoy {name}' }), INVENTORY)]); const once = applyPacks(values, engine); const twice = applyPacks(once, engine); const again = applyPacks(values, engine); for (const id of Object.keys(values)) { assert.equal(twice[id], once[id], `${id}: second application changes nothing`); assert.equal(again[id], once[id], `${id}: the same input gives the same value`); } assert.equal(engine.stats().applied, 2); // A fresh engine (a reload) gives the same result for the same input. const reloaded = applyPacks(values, createStringEngine(INVENTORY, [compilePack(pack({ a0000001: 'Hoom', b0000001: 'Ahoy {name}' }), INVENTORY)])); assert.equal(reloaded.a0000001, once.a0000001); assert.equal((reloaded.b0000001 as (e: unknown) => string)({ name: 'Ada' }), (once.b0000001 as (e: unknown) => string)({ name: 'Ada' })); }); test('engine: packs apply in the listed order, the later one wins per key', () => { const language = compilePack(pack({ a0000001: 'Hoom', a0000002: 'Trailing' }, { name: 'Language' }), INVENTORY); const rewording = compilePack(pack({ a0000001: 'Den' }, { name: 'Rewording' }), INVENTORY); const first = applyPacks(site(), createStringEngine(INVENTORY, [language, rewording])); assert.equal(first.a0000001, 'Den'); assert.equal(first.a0000002, 'Trailing', 'a key only the earlier pack has keeps its value'); const second = applyPacks(site(), createStringEngine(INVENTORY, [rewording, language])); assert.equal(second.a0000001, 'Hoom'); // No packs: everything is the site's. const none = applyPacks(site(), createStringEngine(INVENTORY, [])); assert.equal(none.a0000001, 'Home'); }); test('engine: restore gives back exactly the site\'s values', () => { const values = site(); const engine = createStringEngine(INVENTORY, [compilePack(pack({ a0000001: 'Hoom', b0000001: 'Ahoy {name}' }), INVENTORY)]); const applied = applyPacks(values, engine); assert.deepEqual([...engine.originals().keys()].sort(), ['a0000001', 'b0000001'], 'only what a pack changed is kept'); const restored = restoreCatalog(applied, engine); for (const id of Object.keys(values) as (keyof typeof values)[]) assert.equal(restored[id], values[id], id); assert.equal(applied.a0000001, 'Hoom', 'restoring does not change the applied object'); }); test('engine: a live value of another kind than the inventory says is left alone', () => { const engine = createStringEngine(INVENTORY, [compilePack(pack({ a0000001: 'Hoom', b0000001: 'Ahoy {name}' }), INVENTORY)]); const fn = () => 'now a function'; assert.equal(engine.resolve('a0000001', fn), fn); assert.equal(engine.resolve('b0000001', 'now a string'), 'now a string'); assert.deepEqual(engine.stats().mismatched.sort(), ['a0000001', 'b0000001']); assert.equal(engine.stats().applied, 0); // A key the site has and the inventory lacks is counted as drift. assert.equal(engine.resolve('f0000009', 'New text'), 'New text'); assert.deepEqual(engine.stats().unlisted, ['f0000009']); }); test('page inventory: the compact form answers like the full one', () => { const page = packInventory(INVENTORY); assert.equal(JSON.stringify(page).includes('hash'), false); const full = inventoryLookup(INVENTORY); const compact = inventoryLookup(page); assert.equal(compact('a0000001')?.kind, 'text'); assert.equal(compact('e0000001')?.kind, 'text'); assert.deepEqual(compact('b0000002'), { kind: 'message', params: ['count', 'name'], plural: ['count'], select: [] }); assert.equal(compact('nope'), undefined); assert.equal(full('nope'), undefined); assert.equal(full('constructor'), undefined, 'inherited names are not keys'); for (const id of ['c0000001', 'd0000001']) assert.notEqual(compact(id)?.kind, 'text'); // The same pack compiles the same against both. const strings = { a0000001: 'Hoom', b0000002: '{count, plural, one {#} other {#}}', c0000001: 'x', zzzzzzzz: 'y' }; const a = compilePack(pack(strings), INVENTORY); const b = compilePack(pack(strings), page); assert.deepEqual([...a.values.keys()], [...b.values.keys()]); assert.deepEqual(a.report.unknown, b.report.unknown); assert.deepEqual(a.report.rejected.map(item => item.id), b.report.rejected.map(item => item.id)); }); test('inventory slice: only what the packs name travels to the page, and decides the same', () => { const strings = { a0000001: 'Hoom', b0000002: '{count, plural, one {#} other {#}}', c0000001: 'x', zzzzzzzz: 'y' }; const slice = sliceInventory(INVENTORY, Object.keys(strings)); assert.deepEqual(slice, { site: 'demo', catalog: 'web', text: 'a0000001', message: { b0000002: 'count* name' }, closed: 'c0000001', partial: true }); assert.deepEqual(sliceInventory(packInventory(INVENTORY), Object.keys(strings)), slice, 'a slice of the compact form is the same'); const full = compilePack(pack(strings), INVENTORY); const sliced = compilePack(pack(strings), slice); assert.deepEqual([...sliced.values.keys()], [...full.values.keys()]); assert.deepEqual(sliced.report, full.report, 'unknown and refused keys are the same'); // A key outside the slice is the site's own value, and is not reported as a new key of the site. const engine = createStringEngine(slice, [sliced]); assert.equal(engine.resolve('a0000002', 'Following'), 'Following'); assert.equal(engine.resolve('a0000001', 'Home'), 'Hoom'); assert.deepEqual(engine.stats().unlisted, []); // Untrusted data: the shape check. assert.ok(isPageInventory(slice)); assert.ok(isPageInventory(JSON.parse(JSON.stringify(packInventory(INVENTORY))))); for (const bad of [null, [], {}, { ...slice, text: 5 }, { ...slice, text: 'a ' }, { ...slice, message: { a: 'x { const compiled = compilePack(pack({ a0000001: 'Hoom', b0000001: 'Ahoy {name}', e0000001: 'Grin' }), INVENTORY); assert.deepEqual(coverage(INVENTORY, compiled), { all: { translatable: 5, translated: 3, closed: 2 }, main: { translatable: 4, translated: 2, closed: 2 }, emoji: { translatable: 1, translated: 1, closed: 0 }, }); }); test('drift report: new, reworded, reshaped; vanished only for a group whose base catalog was loaded', () => { const observed: Record = { a0000001: { kind: 'text', hash: hashText('Home') }, a0000002: { kind: 'text', hash: hashText('Following now') }, b0000001: { kind: 'message', params: ['name', 'title'], hash: hashText('Hello {name}') }, c0000001: { kind: 'rich', hash: hashText('rich') }, d0000001: { kind: 'format', hash: hashText('fmt') }, f0000001: { kind: 'text', hash: hashText('Brand new') }, }; assert.deepEqual(driftReport(INVENTORY, observed, ['main']), { added: ['f0000001'], reworded: ['a0000002'], reshaped: ['b0000001'], vanished: ['b0000002'], }); // The emoji group was never loaded: its key is not called vanished. With it loaded, it is. assert.deepEqual(driftReport(INVENTORY, observed, ['main', 'emoji']).vanished, ['b0000002', 'e0000001']); // Nothing loaded: nothing can be called vanished. assert.deepEqual(driftReport(INVENTORY, observed, []).vanished, []); assert.deepEqual(driftReport(INVENTORY, INVENTORY.entries, ['main', 'emoji']), { added: [], reworded: [], reshaped: [], vanished: [] }); }); // ------------------------------------------------------------------------------------------- // The chunk array hook /** A stand-in for webpack's runtime: takes over `push`, replays what is there, installs modules. */ function webpackRuntime(array: unknown[]) { const installed: Record = {}; const parentPush = array.push.bind(array); const callback = (parent: ((chunk: unknown) => number) | 0, chunk: unknown) => { const [, modules] = chunk as [unknown, Record]; Object.assign(installed, modules); if (parent) parent(chunk); return 0; }; array.forEach(chunk => callback(0, chunk)); array.push = ((chunk: unknown) => callback(parentPush, chunk)) as typeof array.push; return installed; } test('chunk array hook: every chunk is seen once, before the site gets it, whichever loads first', () => { const win: Record & { document: { currentScript: { src: string } | null } } = { document: { currentScript: { src: 'https://cdn.test/a.js' } } }; const seen: string[] = []; watchChunkArray(win, 'chunks', (chunk, script) => { seen.push(`${String((chunk[0] as unknown[])[0])}@${script}`); }); const array = win.chunks as unknown[]; const early = [[1], { m1: () => 1 }]; array.push(early); assert.equal(array.length, 1, 'before the runtime loads, push is still the array\'s own'); const installed = webpackRuntime(array); win.document.currentScript = { src: 'https://cdn.test/b.js' }; const late = [[2], { m2: () => 2 }]; array.push(late); win.document.currentScript = null; array.push([[3], {}]); assert.deepEqual(seen, ['1@https://cdn.test/a.js', '2@https://cdn.test/b.js', '3@']); assert.deepEqual(Object.keys(installed), ['m1', 'm2']); assert.deepEqual(array, [early, late, [[3], {}]], 'the array holds the same chunks in the same order'); }); test('chunk array hook: a chunk nobody maps passes through byte-identical', () => { const win: Record = {}; watchChunkArray(win, 'chunks', () => { /* looks, touches nothing */ }); const array = win.chunks as unknown[]; const installed = webpackRuntime(array); const factory = function (module: { exports: unknown }) { module.exports = 42; }; const runtime = () => {}; const chunk = [[7, 8], { m7: factory }, runtime]; const snapshot = { ids: chunk[0], modules: chunk[1], source: factory.toString(), keys: Object.keys(chunk[1] as object) }; array.push(chunk); assert.equal(array[0], chunk, 'the same chunk object reaches the array'); assert.equal(chunk.length, 3); assert.equal(chunk[0], snapshot.ids); assert.equal(chunk[1], snapshot.modules); assert.equal(chunk[2], runtime); assert.equal((chunk[1] as Record).m7, factory, 'the same factory function'); assert.equal(installed.m7, factory); assert.equal(factory.toString(), snapshot.source); assert.deepEqual(Object.keys(chunk[1] as object), snapshot.keys); }); test('chunk array hook: fails open, and removal puts the site\'s push back', () => { const win: Record = {}; const remove = watchChunkArray(win, 'chunks', () => { throw new Error('boom'); }); const array = win.chunks as unknown[]; const installed = webpackRuntime(array); const sitePush = Object.getOwnPropertyDescriptor(array, 'push'); assert.ok(sitePush?.get, 'the accessor is ours while installed'); assert.doesNotThrow(() => array.push([[1], { m1: 1 }])); assert.deepEqual(Object.keys(installed), ['m1']); remove(); assert.equal(typeof Object.getOwnPropertyDescriptor(array, 'push')?.value, 'function', 'a plain property again'); array.push([[2], { m2: 2 }]); assert.deepEqual(Object.keys(installed), ['m1', 'm2']); assert.equal(array.length, 2); // Removed before any runtime took over: the array has no own push left. const other: Record = {}; watchChunkArray(other, 'chunks', () => {})(); assert.equal(Object.prototype.hasOwnProperty.call(other.chunks, 'push'), false); // An existing array is kept (the site's `||= []` finds it). const existing: unknown[] = []; const third: Record = { chunks: existing }; watchChunkArray(third, 'chunks', () => {}); assert.equal(third.chunks, existing); }); test('mapChunkRequires: a module sees a shadow of what it requires; the required module is unchanged', () => { const registry = { register: (language: string) => `site:${language}`, other: 1 }; const require = Object.assign((id: string): any => (id === 'registry' ? registry : { id }), { e: 'kept' }); const results: unknown[] = []; const factory = function (this: unknown, module: { exports: unknown }, _exports: unknown, req: typeof require) { results.push(req('registry').register('en'), req('plain'), req.e, this); module.exports = 'done'; }; const chunk: [unknown, Record] = [[1], { m: factory }]; mapChunkRequires(chunk, exports => { if (exports !== registry) return undefined; const shadow = Object.create(registry) as typeof registry; Object.defineProperty(shadow, 'register', { value: (language: string) => `ours:${language}` }); return shadow; }); assert.notEqual(chunk[1].m, factory); const module = { exports: {} as unknown }; (chunk[1].m as (...args: unknown[]) => void).call('self', module, module.exports, require); assert.deepEqual(results, ['ours:en', { id: 'plain' }, 'kept', 'self']); assert.equal(module.exports, 'done'); assert.equal(registry.register('en'), 'site:en', 'the site\'s module is untouched'); assert.deepEqual(Object.keys(registry), ['register', 'other']); // A throwing map hands out the module's own exports. const failing: [unknown, Record] = [[2], { m: factory }]; mapChunkRequires(failing, () => { throw new Error('boom'); }); results.length = 0; (failing[1].m as (...args: unknown[]) => void).call('self', module, module.exports, require); assert.equal(results[0], 'site:en'); }); // ------------------------------------------------------------------------------------------- // Adapters and the self-check function fakeWindow() { const timers: (() => void)[] = []; const win = { setTimeout: (fn: () => void) => { timers.push(fn); return timers.length; }, clearTimeout: () => {} } as unknown as Window; return { win, settle: () => { for (const fn of timers.splice(0)) fn(); } }; } /** An adapter driven by the test: `drive` plays the site. */ function adapter(catalog = 'web') { const state: { sink: CatalogSink | null; removed: number } = { sink: null, removed: 0 }; const hook: CatalogHookAdapter = { catalog, install: (_win, sink) => { state.sink = sink; return () => { state.removed += 1; }; } }; return { hook, state }; } test('self-check: ok, "hook not matched" and "no catalog seen" are distinct', () => { const packs = [pack({ a0000001: 'Hoom' })]; // ok: a catalog file was seen, the hook matched, keys went through. { const { win, settle } = fakeWindow(); const { hook, state } = adapter(); const told: string[] = []; const handle = installStringPacks(win, [{ adapter: hook, inventory: INVENTORY, packs }], { onStatus: report => told.push(report.status) }); assert.equal(handle.status(), 'pending'); state.sink!.seen(); state.sink!.matched('en-GB'); assert.equal(state.sink!.resolve('a0000001', 'Home'), 'Hoom'); assert.equal(state.sink!.resolve('a0000002', 'Following'), 'Following'); assert.equal(handle.status(), 'ok'); settle(); assert.deepEqual(told, ['ok'], 'told once'); const [report] = handle.report(); assert.deepEqual({ status: report!.status, languages: report!.languages, seen: report!.seen, applied: report!.engine.applied }, { status: 'ok', languages: ['en-GB'], seen: { main: 1 }, applied: 1 }); handle.dispose(); assert.equal(state.removed, 1); } // A catalog file was seen but nothing went through the hook. { const { win, settle } = fakeWindow(); const { hook, state } = adapter(); const told: string[] = []; const handle = installStringPacks(win, [{ adapter: hook, inventory: INVENTORY, packs }], { onStatus: report => told.push(report.status) }); state.sink!.seen('main'); assert.equal(handle.status(), 'pending'); settle(); assert.equal(handle.status(), 'not-matched'); assert.deepEqual(told, ['not-matched']); } // No catalog file at all. { const { win, settle } = fakeWindow(); const { hook } = adapter(); const told: string[] = []; const handle = installStringPacks(win, [{ adapter: hook, inventory: INVENTORY, packs }], { onStatus: report => told.push(report.status) }); settle(); assert.equal(handle.status(), 'no-catalog'); assert.deepEqual(told, ['no-catalog']); } }); test('install: no usable pack means no hook; several catalogs keep their own packs', () => { const { win } = fakeWindow(); const web = adapter('web'); const guest = adapter('guest'); const guestInventory: StringInventory = { format: 1, site: 'demo', catalog: 'guest', base: 'en', entries: { 'Sign in': { kind: 'text', hash: hashText('Sign in') } } }; const handle = installStringPacks(win, [ { adapter: web.hook, inventory: INVENTORY, packs: [] }, { adapter: guest.hook, inventory: guestInventory, packs: [pack({ 'Sign in': 'Sign inne' }, { catalog: 'guest' }), pack({ a0000001: 'Hoom' }), 'not a pack'] }, ]); assert.equal(web.state.sink, null, 'a catalog without packs is not hooked'); assert.ok(guest.state.sink); assert.equal(guest.state.sink!.resolve('Sign in', 'Sign in'), 'Sign inne'); const report = handle.report(); assert.equal(report[1]!.packs.length, 1); assert.equal(report[1]!.refused.length, 2, 'a pack for another catalog and a malformed one are refused as a whole'); assert.equal(handle.engine('web'), undefined); assert.ok(handle.engine('guest')); // Only unknown keys: nothing usable, nothing hooked. const third = adapter(); installStringPacks(win, [{ adapter: third.hook, inventory: INVENTORY, packs: [pack({ zzzzzzzz: 'x' })] }]); assert.equal(third.state.sink, null); }); test('install: an adapter or engine that throws leaves the site\'s value', () => { const { win } = fakeWindow(); const broken: CatalogHookAdapter = { catalog: 'web', install: () => { throw new Error('boom'); } }; assert.doesNotThrow(() => installStringPacks(win, [{ adapter: broken, inventory: INVENTORY, packs: [pack({ a0000001: 'Hoom' })] }]).dispose()); }); // ------------------------------------------------------------------------------------------- // The DOM safety net (a minimal document: text nodes, elements, attributes) type FakeNode = { nodeType: number; nodeValue: string | null; parentElement: FakeElement | null }; class FakeElement { nodeType = 1; nodeValue = null; parentElement: FakeElement | null = null; children: (FakeElement | FakeNode)[] = []; attrs = new Map(); tag: string; constructor(tag: string, attrs: Record = {}) { this.tag = tag; for (const [k, v] of Object.entries(attrs)) this.attrs.set(k, v); } add(child: T): T { child.parentElement = this; this.children.push(child); return child; } text(value: string): FakeNode { return this.add({ nodeType: 3, nodeValue: value, parentElement: null }); } getAttribute(name: string) { return this.attrs.get(name) ?? null; } setAttribute(name: string, value: string) { this.attrs.set(name, value); } hasAttribute(name: string) { return this.attrs.has(name); } closest(selector: string): FakeElement | null { const tags = selector.split(',').map(part => part.trim()); for (let el: FakeElement | null = this; el; el = el.parentElement) if (tags.includes(el.tag)) return el; return null; } all(): FakeElement[] { return this.children.flatMap(child => (child instanceof FakeElement ? [child, ...child.all()] : [])); } querySelectorAll(selector: string) { const name = selector.slice(1, -1); return this.all().filter(el => el.attrs.has(name)); } textNodes(): FakeNode[] { return this.children.flatMap(child => (child instanceof FakeElement ? child.textNodes() : [child])); } } function fakeDocument() { const root = new FakeElement('html'); let callback: (records: unknown[]) => void = () => {}; let observing = false; const win = { document: { documentElement: root, createTreeWalker(element: FakeElement) { const nodes = element.textNodes(); let index = -1; return { get currentNode() { return nodes[index]; }, nextNode() { index += 1; return index < nodes.length; } }; }, querySelectorAll: (selector: string) => [...(root.attrs.has(selector.slice(1, -1)) ? [root] : []), ...root.querySelectorAll(selector)], }, MutationObserver: class { constructor(fn: (records: unknown[]) => void) { callback = fn; } observe() { observing = true; } disconnect() { observing = false; } }, }; return { root, win: win as unknown as Window, mutate: (records: unknown[]) => { if (observing) callback(records); } }; } test('text net: exact texts and attributes are replaced once, rewritten nodes are looked at again, dispose restores', () => { const { root, win, mutate } = fakeDocument(); const nav = root.add(new FakeElement('nav', { 'aria-label': 'Trending' })); const label = nav.text('Trending'); const other = nav.text('Trending topics today'); const script = root.add(new FakeElement('script')); const code = script.text('Trending'); const net = installTextNet(win, { map: new Map([['Trending', 'Trendish'], ['News', 'Newsish']]), skip: 'script' }); assert.equal(label.nodeValue, 'Trendish'); assert.equal(nav.getAttribute('aria-label'), 'Trendish'); assert.equal(other.nodeValue, 'Trending topics today', 'only an exact text is a match'); assert.equal(code.nodeValue, 'Trending', 'skipped elements are not touched'); assert.equal(net.count(), 2); // Our own writes come back as mutations: nothing changes further. mutate([{ type: 'characterData', target: label }, { type: 'attributes', target: nav, attributeName: 'aria-label' }]); assert.equal(label.nodeValue, 'Trendish'); assert.equal(net.count(), 2); // A node added later, and the site rewriting a node. const added = new FakeElement('div', { title: 'News' }); const news = added.text('News'); root.add(added); mutate([{ type: 'childList', addedNodes: [added] }]); assert.equal(news.nodeValue, 'Newsish'); assert.equal(added.getAttribute('title'), 'Newsish'); label.nodeValue = 'News'; mutate([{ type: 'characterData', target: label }]); assert.equal(label.nodeValue, 'Newsish'); label.nodeValue = 'Something of the site'; mutate([{ type: 'characterData', target: label }]); assert.equal(label.nodeValue, 'Something of the site'); assert.equal(net.count(), 3); net.dispose(); assert.equal(news.nodeValue, 'News'); assert.equal(added.getAttribute('title'), 'News'); assert.equal(nav.getAttribute('aria-label'), 'Trending'); assert.equal(label.nodeValue, 'Something of the site'); assert.equal(net.count(), 0); // Disposed: later mutations do nothing. label.nodeValue = 'Trending'; mutate([{ type: 'characterData', target: label }]); assert.equal(label.nodeValue, 'Trending'); });