// String packs on X: the catalog hook adapter against a stand-in for X's webpack client, the // shipped inventory and test pack, and the inventory script on a made-up language chunk. import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import { X_CHUNK_ARRAY, X_PACKS_KEY, installXStringPacks, languageFile, readDelivery, storedDelivery, xWebCatalog } from '../src/inject/stringPacks.ts'; import { installStringPacks, type StringPacksHandle } from '../../../waybetter/packages/runtime/src/stringPackHook.ts'; import { compilePack, coverage, hashText, inventoryLookup, packInventory, sliceInventory, type PageInventory, type StringInventory } from '../../../waybetter/packages/runtime/src/stringPacks.ts'; import { buildInventory, extractCatalog, renderInventory } from '../scripts/strings-inventory.mjs'; const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8'); const INVENTORY: StringInventory = JSON.parse(read('../strings/x.web.inventory.json')); const COMPACT: PageInventory = JSON.parse(read('../strings/x.web.page.json')); const TESTISH = JSON.parse(read('../strings/packs/testish.strings.json')); /** What a page gets with the test pack: the slice of the inventory its keys need. */ const SLICE = sliceInventory(INVENTORY, Object.keys(TESTISH.strings)); const CDN = 'https://abs.twimg.com/responsive-web/client-web/'; // ------------------------------------------------------------------------------------------- // A stand-in for X's client: the chunk array, webpack's runtime and the string registry. type Setter = (id: string, value: unknown) => void; function fakeX() { const timers: (() => void)[] = []; const page = { currentScript: null as { src: string } | null }; const win = { document: page, setTimeout: (fn: () => void) => { timers.push(fn); return timers.length; }, clearTimeout() {}, } as unknown as Window; /** X's registry module: a table per language; `_register` hands out that language's setter. */ const tables: Record> = {}; const registry = { language: '', _register(this: { language: string }, language: string) { tables[language] ??= {}; this.language = language; return ((id, value) => { tables[language]![id] = value; }) as Setter; }, }; const registryKeys = Object.keys(registry); const nativeRegister = registry._register; const modules: Record unknown) => void> = { registry: module => { module.exports = registry; }, }; const cache: Record = {}; const require = (id: string): unknown => { if (!cache[id]) { cache[id] = { exports: {} }; modules[id]!.call(cache[id]!.exports, cache[id]!, cache[id]!.exports, require); } return cache[id]!.exports; }; /** Webpack's runtime taking over the array (what `main` does when it loads). */ const startRuntime = () => { const array = ((win as unknown as Record)[X_CHUNK_ARRAY] ??= []) as unknown[]; const parent = array.push.bind(array); const callback = (push: ((chunk: unknown) => number) | 0, chunk: unknown) => { const [, more, run] = chunk as [unknown, typeof modules, ((r: typeof require) => void)?]; Object.assign(modules, more); if (push) push(chunk); run?.(require); return 0; }; array.forEach(chunk => callback(0, chunk)); array.push = ((chunk: unknown) => callback(parent, chunk)) as typeof array.push; }; /** A script pushing a chunk, as `(globalThis.webpackChunk… ||= []).push([...])` does. */ const load = (src: string, chunk: unknown[]) => { page.currentScript = { src }; (((win as unknown as Record)[X_CHUNK_ARRAY] ??= []) as unknown[]).push(chunk); page.currentScript = null; }; /** A language chunk: one module that registers `values` in `language`, required right away. */ const loadLanguage = (file: string, language: string, values: Record, id = `lang:${file}`) => { const factory = (_module: unknown, _exports: unknown, req: (id: string) => unknown) => { const set = (req('registry') as typeof registry)._register(language) as Setter; for (const [key, value] of Object.entries(values)) set(key, value); }; load(`${CDN}i18n/${file}`, [[id], { [id]: factory }, (r: typeof require) => { r(id); }]); return factory; }; return { win, tables, registry, registryKeys, nativeRegister, startRuntime, load, loadLanguage, settle: () => { for (const fn of timers.splice(0)) fn(); } }; } /** X's own values for a few keys of the real inventory (the texts here are invented). */ const siteValues = () => ({ ha8209bc: 'Base', // text, in the pack a0102d00: 'Untouched text', // text, not in the pack e2ee95a1: (e: { date: string }) => `Since ${e.date}`, // message with a parameter, in the pack i73fff1b: (e: { count: number }) => `Show ${e.count} new`, // plural message, in the pack a03f5c73: (e: { applicationCount: number }) => `${e.applicationCount} untouched`, // message, not in the pack _ActualI18NFormatMessage: class {}, // X's rich-text component zz000000: 'A key the inventory does not have', }); const install = (win: Window, packs: unknown[] = [TESTISH]): StringPacksHandle => installStringPacks(win, [{ adapter: xWebCatalog, inventory: SLICE, packs }]); test('language file filter: only X\'s language chunks on its CDN', () => { assert.equal(languageFile(`${CDN}i18n/en-GB.969bde798bef19cca.js`), 'main'); assert.equal(languageFile(`${CDN}i18n/zh-Hant.0123456789abcdef0.js`), 'main'); assert.equal(languageFile(`${CDN}i18n/ar-x-fm.0123456789abcdef0.js`), 'main'); assert.equal(languageFile(`${CDN}i18n/emoji-en-GB.f47c9e39de79caaaa.js`), 'emoji'); for (const src of [ '', `${CDN}main.c99c7e5d227ad3baa.js`, `${CDN}vendor.17b0c0f279e7935ca.js`, `${CDN}bundle.i18n.0123456789abcdef0.js`, 'https://evil.example/responsive-web/client-web/i18n/en.0123456789abcdef0.js', `${CDN}i18n/en.0123456789abcdef0.js?x=1`, `http://abs.twimg.com/responsive-web/client-web/i18n/en.0123456789abcdef0.js`, `${CDN}i18n/en.js`, ]) assert.equal(languageFile(src), null, src); }); test('adapter: pack values where the pack has the key, X\'s own everywhere else', () => { const x = fakeX(); const handle = install(x.win); x.startRuntime(); const site = siteValues(); x.loadLanguage('en-GB.969bde798bef19cca.js', 'en-GB', site); const table = x.tables['en-GB']!; assert.equal(table.ha8209bc, 'Hoom'); assert.equal((table.e2ee95a1 as (e: unknown) => string)({ date: 'May 2020' }), 'Arrived May 2020'); assert.equal((table.i73fff1b as (e: unknown) => string)({ count: 1 }), 'Show 1 fresh postle'); assert.equal((table.i73fff1b as (e: unknown) => string)({ count: 35 }), 'Show 35 fresh postles'); for (const id of ['a0102d00', 'a03f5c73', '_ActualI18NFormatMessage', 'zz000000'] as const) assert.equal(table[id], site[id], `${id} is X's own value`); assert.deepEqual(Object.keys(table), Object.keys(site), 'every key registered, in X\'s order'); // X's registry module is as it was. assert.deepEqual(Object.keys(x.registry), x.registryKeys); assert.equal(x.registry._register, x.nativeRegister); assert.equal(x.registry.language, 'en-GB', 'X\'s own `this` was used'); const [report] = handle.report(); assert.equal(report!.status, 'ok'); assert.deepEqual(report!.languages, ['en-GB']); assert.deepEqual(report!.seen, { main: 1 }); assert.equal(report!.engine.applied, 3); assert.deepEqual(report!.engine.unlisted, [], 'a slice does not call other keys new'); const size = Object.keys(TESTISH.strings).length; assert.deepEqual(report!.packs[0]!.report, { total: size, valid: size, unknown: [], rejected: [] }); handle.dispose(); }); test('adapter: the language chunk may load before webpack\'s runtime, and a second application changes nothing', () => { const x = fakeX(); const handle = install(x.win); const site = siteValues(); x.loadLanguage('en-GB.969bde798bef19cca.js', 'en-GB', site); // queued: no runtime yet assert.deepEqual(x.tables, {}); x.startRuntime(); // replays the queue const first = { ...x.tables['en-GB']! }; assert.equal(first.ha8209bc, 'Hoom'); // X registers the same language again (the same values, and once with what is in the table now). x.loadLanguage('en-GB.969bde798bef19cca.js', 'en-GB', site, 'again'); for (const id of Object.keys(site)) assert.equal(x.tables['en-GB']![id], first[id], `${id} after a second registration`); x.loadLanguage('en-GB.969bde798bef19cca.js', 'en-GB', first, 'fed-back'); for (const id of Object.keys(site)) assert.equal(x.tables['en-GB']![id], first[id], `${id} after our own values came back in`); assert.equal(handle.report()[0]!.engine.applied, 3); handle.dispose(); }); test('adapter: every other chunk passes through byte-identical', () => { const x = fakeX(); const handle = install(x.win); x.startRuntime(); const factory = function (module: { exports: unknown }) { module.exports = 'vendor'; }; const source = factory.toString(); const run = () => {}; const chunk: [number[], Record, () => void] = [[1, 2], { vendor: factory }, run]; const ids = chunk[0]; const table = chunk[1]; // A chunk that even uses the registry, but is not a language file: untouched. const usesRegistry = function (_m: unknown, _e: unknown, req: (id: string) => unknown) { (req('registry') as { _register(l: string): Setter })._register('en-GB')('ha8209bc', 'Base'); }; x.load(`${CDN}main.c99c7e5d227ad3baa.js`, chunk); x.load(`${CDN}bundle.Settings.0123456789abcdef0.js`, [[3], { settings: usesRegistry }, (r: (id: string) => unknown) => { r('settings'); }]); const array = (x.win as unknown as Record)[X_CHUNK_ARRAY]!; assert.equal(array[0], chunk); assert.equal(chunk.length, 3); assert.equal(chunk[0], ids); assert.equal(chunk[1], table); assert.equal(chunk[2], run); assert.equal(table.vendor, factory); assert.equal(factory.toString(), source); assert.deepEqual(Object.keys(table), ['vendor']); assert.equal((array[1] as [unknown, Record])[1].settings, usesRegistry); assert.equal(x.tables['en-GB']!.ha8209bc, 'Base', 'a registration outside a language file is X\'s own'); assert.deepEqual(handle.report()[0]!.seen, {}); handle.dispose(); }); test('adapter: the emoji names go through the same setter, as their own group', () => { const x = fakeX(); const handle = install(x.win); x.startRuntime(); x.loadLanguage('en-GB.969bde798bef19cca.js', 'en-GB', siteValues()); x.loadLanguage('emoji-en-GB.f47c9e39de79caaaa.js', 'en-GB', { ef15e12a: 'A face', ha8209bc: 'Base' }); assert.deepEqual(handle.report()[0]!.seen, { main: 1, emoji: 1 }); assert.equal(x.tables['en-GB']!.ef15e12a, 'A face'); assert.equal(x.tables['en-GB']!.ha8209bc, 'Hoom'); handle.dispose(); }); test('self-check: "hook not matched" and "no catalog seen" are different reports', () => { // A language file arrives, but its module does not use a registry with `_register` any more. { const x = fakeX(); const handle = install(x.win); x.startRuntime(); let ran = false; x.load(`${CDN}i18n/en-GB.969bde798bef19cca.js`, [['lang'], { lang: () => { ran = true; } }, (r: (id: string) => unknown) => { r('lang'); }]); assert.ok(ran, 'X\'s module still runs'); assert.equal(handle.status(), 'pending'); x.settle(); assert.equal(handle.status(), 'not-matched'); handle.dispose(); } // No language file at all (signed out, or another build of X). { const x = fakeX(); const handle = install(x.win); x.startRuntime(); x.load(`${CDN}main.c99c7e5d227ad3baa.js`, [[1], {}]); x.settle(); assert.equal(handle.status(), 'no-catalog'); handle.dispose(); } }); test('adapter: a failure inside the hook leaves X working with its own text', () => { const x = fakeX(); const handle = installStringPacks(x.win, [{ adapter: xWebCatalog, inventory: SLICE, packs: [TESTISH], }]); x.startRuntime(); // A setter called in ways the hook does not expect: non-string ids, extra arguments. const factory = (_m: unknown, _e: unknown, req: (id: string) => unknown) => { const set = (req('registry') as { _register(l: string): (...a: unknown[]) => void })._register('en-GB'); set(42, 'number id'); set('ha8209bc', () => 'a function where the inventory says text'); set('e2ee95a1', 'a string where the inventory says message'); }; assert.doesNotThrow(() => x.load(`${CDN}i18n/en-GB.969bde798bef19cca.js`, [['lang'], { lang: factory }, (r: (id: string) => unknown) => { r('lang'); }])); assert.equal(x.tables['en-GB']!['42'], 'number id'); assert.equal(typeof x.tables['en-GB']!.ha8209bc, 'function'); assert.equal(x.tables['en-GB']!.e2ee95a1, 'a string where the inventory says message'); assert.deepEqual(handle.report()[0]!.engine.mismatched.sort(), ['e2ee95a1', 'ha8209bc']); handle.dispose(); }); test('no pack: nothing is hooked, and removing the pack gives X\'s text back exactly', () => { const x = fakeX(); const storage = new Map(); (x.win as unknown as { sessionStorage: unknown }).sessionStorage = { getItem: (key: string) => storage.get(key) ?? null }; assert.equal(storedDelivery(x.win), null); assert.deepEqual(installXStringPacks(x.win).report(), []); assert.equal((x.win as unknown as Record)[X_CHUNK_ARRAY], undefined, 'the chunk array is not even created'); x.startRuntime(); const site = siteValues(); x.loadLanguage('en-GB.969bde798bef19cca.js', 'en-GB', site); for (const id of Object.keys(site) as (keyof typeof site)[]) assert.equal(x.tables['en-GB']![id], site[id]); // A delivery is read as data: the packs and the inventory slice for their keys. Junk is no delivery. storage.set(X_PACKS_KEY, JSON.stringify({ inventory: SLICE, packs: [TESTISH] })); assert.equal(storedDelivery(x.win)?.packs.length, 1); assert.ok(SLICE.partial && JSON.stringify(SLICE).length < 3000, 'the slice is small'); storage.set(X_PACKS_KEY, '{not json'); assert.equal(storedDelivery(x.win), null); for (const junk of [null, [TESTISH], { packs: [TESTISH] }, { inventory: SLICE, packs: [] }, { inventory: SLICE, packs: 'x' }, { inventory: { ...SLICE, catalog: 'other' }, packs: [TESTISH] }, { inventory: { ...SLICE, site: 'y' }, packs: [TESTISH] }, { inventory: { ...SLICE, text: 7 }, packs: [TESTISH] }]) { assert.equal(readDelivery(junk), null, JSON.stringify(junk).slice(0, 60)); } assert.equal(readDelivery({ inventory: SLICE, packs: Array.from({ length: 20 }, () => TESTISH) })?.packs.length, 8); }); // ------------------------------------------------------------------------------------------- // What ships test('inventory: ids and shapes only, none of X\'s text; the page form is current', () => { assert.equal(INVENTORY.site, 'x'); assert.equal(INVENTORY.catalog, 'web'); assert.equal(INVENTORY.base, 'en'); const allowed = new Set(['kind', 'params', 'plural', 'slots', 'hash', 'group']); const kinds: Record = {}; for (const [id, entry] of Object.entries(INVENTORY.entries)) { assert.match(id, /^(?:[a-z][0-9a-f]{7}|_?[A-Za-z0-9]+)$/, id); for (const field of Object.keys(entry)) assert.ok(allowed.has(field), `${id}.${field}`); assert.match(entry.hash, /^[0-9a-z]{11}$/); for (const name of [...(entry.params ?? []), ...(entry.plural ?? [])]) assert.match(name, /^[A-Za-z_$][\w$]*$/, `${id}: ${name}`); for (const name of entry.plural ?? []) assert.ok(entry.params?.includes(name), `${id}: plural ${name} is a parameter`); const key = `${entry.group ?? 'main'}/${entry.kind}`; kinds[key] = (kinds[key] ?? 0) + 1; } assert.ok(kinds['main/text']! > 7000 && kinds['main/message']! > 1000 && kinds['rich/rich']! > 400 && kinds['main/format']! > 300, JSON.stringify(kinds)); // No value is longer than an id, a name or a hash: there is no room for text. assert.ok(!/ [a-z]+ [a-z]+ [a-z]+ /.test(read('../strings/x.web.inventory.json')), 'no sentence in the inventory'); assert.deepEqual(COMPACT, JSON.parse(JSON.stringify(packInventory(INVENTORY))), 'run `node scripts/strings-inventory.mjs` again'); assert.equal(renderInventory(INVENTORY), read('../strings/x.web.inventory.json').replaceAll('\r\n', '\n')); }); test('test pack: valid against the inventory, with a parameter message and a plural', () => { const compiled = compilePack(TESTISH, INVENTORY); assert.deepEqual(compiled.report.unknown, []); assert.deepEqual(compiled.report.rejected, []); assert.ok(compiled.report.valid >= 36, 'a few dozen strings'); const lookup = inventoryLookup(INVENTORY); const messages = Object.keys(TESTISH.strings).filter(id => lookup(id)?.kind === 'message'); assert.ok(messages.some(id => !lookup(id)?.plural?.length && /\{\w+\}/.test(TESTISH.strings[id])), 'one with a parameter'); assert.ok(messages.some(id => /plural/.test(TESTISH.strings[id])), 'one plural'); // The compact form and the slice a page gets decide the same. assert.deepEqual([...compilePack(TESTISH, COMPACT).values.keys()], [...compiled.values.keys()]); assert.deepEqual([...compilePack(TESTISH, SLICE).values.keys()], [...compiled.values.keys()]); const cover = coverage(INVENTORY, compiled); assert.equal(cover.all!.translated, compiled.report.valid); assert.ok(cover.all!.closed > 300 && cover.all!.closed < 500, 'only formatters (and a few rich values without slots) are counted as not translatable'); }); // ------------------------------------------------------------------------------------------- // The inventory script (on a made-up chunk in the shape of X's) const chunk = (names: { setter: string; param: string; plural: string }, words: { hello: string }) => ` "use strict";(globalThis.webpackChunk_twitter_responsive_web||=[]).push([[101],{5001(e,t,a){ var ${names.setter}=a(7)._register("en",{get emoji(){return a.e(202).then(()=>a.t(9,23))}}),n=function(e){return 1==e?"one":"other"}; function ${names.plural}(e,t,a){return"one"===n(e)?t:a} ${names.setter}("a0000001","${words.hello}"),${names.setter}("a0000002",\`Two lines\`), ${names.setter}("b0000001",function(${names.param}){return"Hello "+${names.param}.name+"!"}), ${names.setter}("b0000002",function(${names.param}){return ${names.param}.shown+" thing"+${names.plural}(${names.param}.count,"","s")+" by "+${names.param}.name}), ${names.setter}("c0000001",function(${names.param}){return ${names.plural}(${names.param}.count,i.createElement(i.Fragment,null,${names.param}.name),"others")}); var i=a(3); class l extends i.Component{get d0000001(){return["Read the ",," first."]}get templateReducer(){return 1}render(){return null}} ${names.setter}("_Message",l); var f=a(4);f.a1=f.make(["#,##0"]); ${names.setter}("e0000001",f.a1)}}]);`; test('inventory script: kinds, parameters and plurals from the source, no text kept', () => { const { language, entries, counts } = extractCatalog(chunk({ setter: 'o', param: 'e', plural: 'r' }, { hello: 'Hello there' })); assert.equal(language, 'en'); assert.deepEqual(counts, { text: 2, message: 2, rich: 3, format: 1 }); assert.deepEqual(entries.a0000001, { kind: 'text', hash: hashText('Hello there') }); assert.deepEqual(entries.a0000002, { kind: 'text', hash: hashText('Two\nlines') }); assert.deepEqual(entries.b0000001, { kind: 'message', params: ['name'], hash: hashText('Hello {name}!') }); assert.deepEqual(entries.b0000002, { kind: 'message', params: ['shown', 'count', 'name'], plural: ['count'], hash: hashText('{shown} thing{count, plural, | s} by {name}') }); assert.equal(entries.c0000001.kind, 'rich', 'a message that returns an element'); assert.deepEqual(entries.d0000001, { kind: 'rich', slots: 3, hash: hashText('Read the {1} first.'), group: 'rich' }); assert.equal(entries._Message.kind, 'rich'); assert.equal(entries.e0000001.kind, 'format'); assert.equal(JSON.stringify(entries).includes('Hello'), false); assert.throws(() => extractCatalog('var a = 1;'), /not a language chunk/); }); test('inventory script: a rebuilt chunk with other minified names is no drift; a reworded string is', () => { const first = buildInventory({ main: chunk({ setter: 'o', param: 'e', plural: 'r' }, { hello: 'Hello there' }), emoji: null, build: 'one', previous: null }); assert.equal(first.drift, null); const renamed = buildInventory({ main: chunk({ setter: 's', param: 't', plural: 'p' }, { hello: 'Hello there' }), emoji: null, build: 'two', previous: first.inventory }); assert.deepEqual(renamed.drift, { added: [], reworded: [], reshaped: [], vanished: [] }); assert.deepEqual(renamed.inventory.entries, first.inventory.entries); const reworded = buildInventory({ main: chunk({ setter: 'o', param: 'e', plural: 'r' }, { hello: 'Hello again' }), emoji: null, build: 'three', previous: first.inventory }); assert.deepEqual(reworded.drift, { added: [], reworded: ['a0000001'], reshaped: [], vanished: [] }); // The emoji group: read when given, kept from the previous inventory when not, never "vanished" unread. const emoji = '(globalThis.webpackChunk_twitter_responsive_web||=[]).push([[202],{9(e,t,a){var i=a(7)._register("en");i("f0000001","A face")}}]);'; const withEmoji = buildInventory({ main: chunk({ setter: 'o', param: 'e', plural: 'r' }, { hello: 'Hello there' }), emoji, build: 'four', previous: first.inventory }); assert.deepEqual(withEmoji.inventory.entries.f0000001, { kind: 'text', hash: hashText('A face'), group: 'emoji' }); assert.deepEqual(withEmoji.drift!.added, ['f0000001']); const without = buildInventory({ main: chunk({ setter: 'o', param: 'e', plural: 'r' }, { hello: 'Hello there' }), emoji: null, build: 'five', previous: withEmoji.inventory }); assert.ok(without.inventory.entries.f0000001, 'kept'); assert.deepEqual(without.drift, { added: [], reworded: [], reshaped: [], vanished: [] }); assert.throws(() => buildInventory({ main: chunk({ setter: 'o', param: 'e', plural: 'r' }, { hello: 'x' }).replace('"en"', '"tr"'), emoji: null, build: 'x', previous: null }), /built from English/); }); // ------------------------------------------------------------------------------------------- // The built-in Twitter wording: a rewording pack that carries none of X's sentences. const WORDING = JSON.parse(read('../strings/packs/twitter-wording.strings.json')); test('the Twitter wording is a data-only rewrite pack for English, without a single key of X', () => { assert.deepEqual(WORDING.strings, {}, 'no copy of X\'s sentences'); assert.deepEqual(WORDING.when, { languages: ['en'] }); const compiled = compilePack(WORDING, COMPACT); assert.equal(compiled.values.size, 0); assert.equal(compiled.rewrite!('Post, Posts and Reposts; Reposted by you'), 'Tweet, Tweets and Retweets; Retweeted by you'); assert.equal(compiled.rewrite!('Compost and postal'), 'Compost and postal'); const english = readFileSync(new URL('../locales/en.json', import.meta.url), 'utf8'); assert.ok(!english.includes('twitter-wording'), 'the pack is not a catalog string'); }); test('the wording applies through the catalog hook only while X is in English, and keeps parameter values of messages', () => { // Real key ids of the inventory with invented X values (no real X text). const text = 'a0102d00'; const message = 'e2ee95a1'; assert.equal(INVENTORY.entries[text]!.kind, 'text'); assert.equal(INVENTORY.entries[message]!.kind, 'message'); const values = () => ({ [text]: 'Post your reply', [message]: (e: { date: string }) => `Post by ${e.date}` }); const run = (language: string, file: string) => { const x = fakeX(); installStringPacks(x.win, [{ adapter: xWebCatalog, inventory: COMPACT, packs: [WORDING] }]); x.startRuntime(); x.loadLanguage(file, language, values()); return x.tables[language]!; }; const en = run('en', 'en.969bde798bef19cca.js'); assert.equal(en[text], 'Tweet your reply'); assert.equal((en[message] as (e: unknown) => string)({ date: 'Post Malone' }), 'Tweet by Post Malone'); const british = run('en-GB', 'en-GB.969bde798bef19cca.js'); assert.equal(british[text], 'Tweet your reply'); const turkish = run('tr', 'tr.969bde798bef19cca.js'); assert.equal(turkish[text], 'Post your reply', 'a Turkish X is left as it is'); assert.equal((turkish[message] as (e: unknown) => string)({ date: 'x' }), 'Post by x'); }); test('the delivery with the wording carries the whole inventory and is accepted by the page runtime', async () => { const { buildDelivery } = await import('../../../waybetter/packages/shell/src/stringPacksModel.ts'); const text = buildDelivery([JSON.stringify(WORDING)], COMPACT)!; const delivery = readDelivery(JSON.parse(text))!; assert.ok(delivery, 'a rewrite-only pack is a delivery'); assert.equal(delivery.packs.length, 1); assert.equal(delivery.inventory.text, COMPACT.text, 'the whole inventory, not a slice'); assert.equal(buildDelivery([], COMPACT), null, 'no pack: no delivery (the setting is off)'); }); // ------------------------------------------------------------------------------------------- // X's rich-text sentences: the replacement class, with a small stand-in for React and for the // class X registers (getters of pieces, `templateReducer`, `render`: copied in shape from X's chunk). import { richText, isReact } from '../src/inject/xRichText.ts'; import { createStringEngine } from '../../../waybetter/packages/runtime/src/stringPacks.ts'; const FRAGMENT = Symbol('Fragment'); const fakeReact = { Fragment: FRAGMENT, createElement: (type: unknown, props: unknown, ...children: unknown[]) => ({ type, props, children }), Children: { toArray: (children: unknown) => (Array.isArray(children) ? children : children === undefined ? [] : [children]).filter(child => child != null) }, }; function xClass() { return class XRich { props: { $i18n: string; children?: unknown }; constructor(props: { $i18n: string; children?: unknown }) { this.props = props; } get a0000001() { return ['Read ', ' first, then ', ' now.']; } get a0000002() { return ['Your post ', , ' was a Repost']; } get templateReducer() { const kids = fakeReact.Children.toArray(this.props.children); return (acc: unknown[], piece: unknown, index: number) => acc.concat(piece, kids[index]); } render() { return fakeReact.createElement.apply(null, (this as unknown as Record)[this.props.$i18n]!.reduce(this.templateReducer as never, [FRAGMENT, null]) as never); } }; } test('rich text: a pack\'s sentence is shown with X\'s own elements in the pack\'s order; X\'s own render otherwise', () => { const inventory: PageInventory = { site: 'x', catalog: 'web', text: '', message: {}, closed: '', rich: { a0000001: 3, a0000002: 3 } }; const pack = { format: 1, site: 'x', catalog: 'web', language: 'eo', name: 'P', authors: ['a'], strings: { a0000001: 'Unue {e1}, poste {e0}.' } }; const reword = { format: 1, site: 'x', catalog: 'web', language: 'en-x-w', name: 'W', authors: ['a'], when: { languages: ['en'] }, rewrite: { words: { post: 'tweet', Repost: 'Retweet', Post: 'Tweet' } }, strings: {} }; const engine = createStringEngine(inventory, [compilePack(pack, inventory), compilePack(reword, inventory)]); engine.setLanguage('en'); const rich = richText((id, value) => engine.resolve(id, value)); const React = fakeReact; assert.ok(isReact(React)); rich.seen({ not: 'react' }); rich.seen(React); const Original = xClass(); const Replaced = rich.intercept('_ActualI18NFormatMessage', Original)!.value as typeof Original; assert.notEqual(Replaced, Original); assert.ok(Replaced.prototype instanceof Original, 'a subclass: X\'s class is not changed'); assert.equal(rich.intercept('some.other.key', 1), undefined); const link = { link: true }; const bold = { bold: true }; // The pack's sentence: pieces and order, the elements are the caller's. const shown = new Replaced({ $i18n: 'a0000001', children: [link, bold] }).render() as { type: unknown; children: unknown[] }; assert.equal(shown.type, FRAGMENT); assert.deepEqual(shown.children, ['Unue ', bold, ', poste ', link, '.']); // A key only reworded goes through X's own reducer, holes kept. const reworded = new Replaced({ $i18n: 'a0000002', children: [link] }).render() as { children: unknown[] }; assert.deepEqual(reworded.children, ['Your tweet ', link, ' was a Retweet', undefined]); // Not English: X's own render, identical to the original class. engine.setLanguage('tr'); const turkish = engine.resolve('a0000002', ['x']); assert.deepEqual(turkish, ['x'], 'a condition that does not hold changes nothing'); assert.deepEqual(new Replaced({ $i18n: 'a0000002', children: [link] }).render(), new Original({ $i18n: 'a0000002', children: [link] }).render()); // An engine that throws leaves X's own sentence. const broken = richText(() => { throw new Error('boom'); }); broken.seen(React); const Safe = broken.intercept('_ActualI18NFormatMessage', Original)!.value as typeof Original; assert.deepEqual(new Safe({ $i18n: 'a0000001', children: [link, bold] }).render(), new Original({ $i18n: 'a0000001', children: [link, bold] }).render()); // The element X's callers use now creates the replacement; other elements are left alone. const make = (props: unknown) => ({ type: Original, props, key: null }); const wrapped = rich.intercept('I18NFormatMessage', make)!.value as (props: unknown) => { type: unknown; props: unknown }; assert.equal(wrapped({ $i18n: 'a0000001' }).type, Replaced); const foreign = { type: 'div', props: {}, key: null }; assert.equal((rich.intercept('I18NFormatMessage', () => foreign)!.value as () => unknown)(), foreign); }); test('rich text without React or before X\'s class: values are left exactly as they are', () => { const rich = richText(() => { throw new Error('never'); }); const Original = xClass(); assert.equal(rich.intercept('_ActualI18NFormatMessage', Original)!.value, Original, 'no React seen: X\'s own class'); const make = () => null; assert.equal(rich.intercept('I18NFormatMessage', make)!.value, make); });