waybetter/packages/runtime/tests/stringPacks.test.ts

565 lines
32 KiB
TypeScript

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<string, string>, extra: Partial<StringPack> = {}): 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: '<b>x</b>' }), /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', '<b>Home</b>'), /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 <link>the rules</link>'), '');
assert.match(reason('Read the rules'), /tags are missing: <link>/);
assert.match(reason('Read <b>the</b> <link>rules</link>'), /<b> is not a tag of the original/);
assert.match(reason('Read <link>the rules'), /not closed/);
assert.match(reason('Read </link>the rules<link>'), /does not close/);
assert.match(reason('1 < 2 <link>x</link>'), /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: '<i>x</i>', 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<string, unknown>, 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 <b>' }, { ...slice, message: { a: 'x<y' } }, { ...slice, site: 'Demo Site' }, { ...slice, partial: 'yes' }]) assert.equal(isPageInventory(bad), false);
});
test('coverage: per group, with what cannot be translated yet counted apart', () => {
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<string, InventoryEntry> = {
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<string, unknown> = {};
const parentPush = array.push.bind(array);
const callback = (parent: ((chunk: unknown) => number) | 0, chunk: unknown) => {
const [, modules] = chunk as [unknown, Record<string, unknown>];
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<string, unknown> & { 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<string, unknown> = {};
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<string, unknown>).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<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> = { 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<string, unknown>] = [[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<string, unknown>] = [[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<string, string>();
tag: string;
constructor(tag: string, attrs: Record<string, string> = {}) { this.tag = tag; for (const [k, v] of Object.entries(attrs)) this.attrs.set(k, v); }
add<T extends FakeElement | FakeNode>(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');
});