waybetter-for-x/apps/desktop/tests/stringPacks.test.ts
2026-10-02 02:54:04 +07:00

489 lines
30 KiB
TypeScript

// 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<string, Record<string, unknown>> = {};
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<string, (module: { exports: unknown }, exports: unknown, require: (id: string) => unknown) => void> = {
registry: module => { module.exports = registry; },
};
const cache: Record<string, { exports: unknown }> = {};
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<string, unknown>)[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<string, unknown>)[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<string, unknown>, 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<string, unknown>, () => 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<string, unknown[]>)[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<string, unknown>])[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<string, string>();
(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<string, unknown>)[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<string, number> = {};
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<string, string[]>)[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);
});