- featureTable.ts: areas, per-feature flags, core flags, modes as presets; sparse storage, ownership (an area that is off turns off what it owns), mode switching that keeps or drops manual changes, table validation - featureModules.ts: createFeatureRegistry (flag + surface gating, scoped context, palette commands under the area with a budget of 5, settings dialog, probes, hotfix selector overrides and kill flags) - settingsForm.ts: a settings schema normalizes stored values and draws the controls from the UI kit - domHub observeSelector; api.observe goes through the shared hub (a root of the caller's own keeps its observer) - createFeatureFlags: a delivery is the whole state (a flag it does not name is at its default), resolve() for ownership, current() Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
12 KiB
TypeScript
195 lines
12 KiB
TypeScript
// The runtime composition helpers: the IPC bridge read at call time, flag-controlled parts with a
|
|
// deck column's overrides, the runtime object under its window names with what native code queued,
|
|
// the page strings native code delivers, the palette command report and the panel button's placement.
|
|
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
import { devErrorReporter, installCommandReport } from '../src/commandReport.ts';
|
|
import { createPageStrings, PAGE_STRING_LIMITS } from '../src/pageStrings.ts';
|
|
import { BUTTON_GAP, buttonBottom, buttonHidden } from '../src/panelButton.ts';
|
|
import { createPluginContext } from '../src/permissions.ts';
|
|
import { createFeatureFlags, installSiteRuntime, pageInvoke } from '../src/siteRuntime.ts';
|
|
|
|
const tick = (ms = 15) => new Promise(resolve => setTimeout(resolve, ms));
|
|
const SITE = { product: 'wayBetter for Test', runtimeGlobal: 'Demo' };
|
|
|
|
test('pageInvoke: the bridge is read at call time; a test browser without one has no invoke', async () => {
|
|
const win = { navigator: { webdriver: false } } as unknown as Window & { __TAURI_INTERNALS__?: unknown };
|
|
const invoke = pageInvoke(win, 'Demo')!;
|
|
await assert.rejects(invoke('ping', {}), /Demo: IPC bridge not ready/);
|
|
win.__TAURI_INTERNALS__ = { invoke: async (command: string, args: unknown) => ({ command, args }) };
|
|
assert.deepEqual(await invoke('ping', { a: 1 }), { command: 'ping', args: { a: 1 } });
|
|
assert.equal(pageInvoke({ navigator: { webdriver: true } } as unknown as Window, 'Demo'), undefined);
|
|
});
|
|
|
|
type Flags = { gate: boolean; pill: boolean; css: boolean };
|
|
|
|
test('feature flags: parts install on enable and dispose on disable, in order, and a failing part is contained', () => {
|
|
const log: string[] = [];
|
|
const part = (name: string) => () => { log.push(`+${name}`); return { dispose: () => { log.push(`-${name}`); } }; };
|
|
const applied: Array<Partial<Flags>> = [];
|
|
const errors: unknown[] = [];
|
|
const original = console.error;
|
|
console.error = (...args: unknown[]) => { errors.push(args[0]); };
|
|
try {
|
|
const flags = createFeatureFlags<Flags>({
|
|
defaults: { gate: true, pill: false, css: true },
|
|
label: 'Demo',
|
|
installers: [['gate', part('gate')], ['pill', () => { throw new Error('no pill'); }]],
|
|
apply: config => applied.push(config),
|
|
});
|
|
assert.deepEqual(log, ['+gate'], 'default-on parts install at once');
|
|
assert.ok(flags.installed('gate') && !flags.installed('pill'));
|
|
flags.apply({ gate: false, css: false });
|
|
assert.deepEqual(log, ['+gate', '-gate']);
|
|
assert.deepEqual(applied, [{ gate: false, pill: false, css: false }], 'the full map: a flag the delivery does not name is at its default');
|
|
flags.apply({ gate: true, pill: true });
|
|
assert.deepEqual(log, ['+gate', '-gate', '+gate']);
|
|
assert.equal(flags.installed('pill'), false);
|
|
assert.match(String(errors[0]), /\[Demo\] pill failed/);
|
|
flags.set('gate', false);
|
|
assert.equal(flags.installed('gate'), false, 'a kill switch');
|
|
flags.set('css', true);
|
|
} finally { console.error = original; }
|
|
});
|
|
|
|
test('feature flags: a deck column overrides global flags; dropped overrides go back to the global value or the default', () => {
|
|
const applied: Array<Partial<Flags>> = [];
|
|
const flags = createFeatureFlags<Flags>({ defaults: { gate: true, pill: false, css: true }, label: 'Demo', installers: [], apply: config => applied.push(config) });
|
|
flags.apply({ pill: true });
|
|
flags.setColumnFlags({ pill: false, css: false, unknown: true, gate: 'yes' });
|
|
assert.deepEqual(applied.at(-1), { gate: true, pill: false, css: false }, 'only known booleans; the column wins');
|
|
flags.apply({ pill: true, css: true });
|
|
assert.deepEqual(applied.at(-1), { gate: true, pill: false, css: false }, 'a global change does not undo the column');
|
|
flags.setColumnFlags({ pill: false });
|
|
assert.deepEqual(applied.at(-1), { gate: true, pill: false, css: true }, 'css is the global value again');
|
|
flags.setColumnFlags(null);
|
|
assert.deepEqual(applied.at(-1), { gate: true, pill: true, css: true }, 'an ordinary tab again: the global flags');
|
|
flags.apply({});
|
|
assert.deepEqual(applied.at(-1), { gate: true, pill: false, css: true }, 'a delivery is the whole state: what it does not name is at its default');
|
|
assert.deepEqual(flags.current(), { gate: true, pill: false, css: true });
|
|
const before = applied.length;
|
|
flags.setColumnFlags(undefined);
|
|
assert.equal(applied.length, before, 'nothing to change');
|
|
// Delivered at document start, a column's flags wait for <html>.
|
|
const listeners: Array<() => void> = [];
|
|
const doc = { documentElement: null as unknown, addEventListener: (_: string, listener: () => void) => { listeners.push(listener); } };
|
|
const late: Array<Partial<Flags>> = [];
|
|
const waiting = createFeatureFlags<Flags>({ defaults: { gate: true, pill: false, css: true }, label: 'Demo', installers: [], apply: config => late.push(config), document: doc as unknown as Document });
|
|
waiting.setColumnFlags({ css: false });
|
|
assert.deepEqual(late, []);
|
|
waiting.apply({ pill: true });
|
|
assert.deepEqual(late.at(-1), { gate: true, pill: true, css: false }, 'recorded at once: a global delivery already respects it');
|
|
listeners[0]!();
|
|
assert.deepEqual(late.at(-1), { gate: true, pill: true, css: false });
|
|
// Ownership: `resolve` decides what runs; a column's own choice still wins.
|
|
const owned: Array<Partial<Flags>> = [];
|
|
const gated = createFeatureFlags<Flags>({ defaults: { gate: true, pill: false, css: true }, label: 'Demo', installers: [], apply: config => owned.push(config), resolve: all => (all.gate ? all : { ...all, css: false }) });
|
|
gated.apply({ gate: false });
|
|
assert.deepEqual(owned.at(-1), { gate: false, pill: false, css: false }, 'the owner is off, so what it owns is off');
|
|
gated.setColumnFlags({ css: true });
|
|
assert.deepEqual(owned.at(-1), { gate: false, pill: false, css: true });
|
|
});
|
|
|
|
function runtimeWindow(queued: Record<string, unknown> = {}) {
|
|
return { navigator: { webdriver: false }, ...queued } as unknown as Window & typeof globalThis & Record<string, unknown>;
|
|
}
|
|
|
|
test('the runtime is published under its names, takes what native code queued and reports commands', async () => {
|
|
const seen: Record<string, unknown> = {};
|
|
const sent: unknown[] = [];
|
|
const commandListeners: Array<(list: unknown[]) => void> = [];
|
|
let list = [{ id: 'a.plugin.js:go', title: 'Go', keywords: '' }];
|
|
const toasts: string[] = [];
|
|
const api = { ui: { toast: (message: string) => { toasts.push(message); } }, commands: { list: () => list, onChange: (callback: (next: unknown[]) => void) => { commandListeners.push(callback); return () => {}; } }, i18n: {} };
|
|
const strings = createPageStrings({ english: { 'demo.hello': 'Hello {name}' }, prefixes: ['demo.'], label: 'Demo' });
|
|
const win = runtimeWindow({
|
|
__wbI18n: { locale: 'tr', messages: { 'demo.hello': 'Merhaba {name}', 'other.key': 'dropped' } },
|
|
__btFeatures: { gate: false },
|
|
__btLinks: { site: 'tab' },
|
|
__btColumn: { source: 'home' },
|
|
__demoExtra: ['x'],
|
|
});
|
|
const runtime = installSiteRuntime({
|
|
win, site: SITE, version: '1.2.3', aliases: ['OldName'],
|
|
invoke: async (command, args) => { sent.push([command, args]); },
|
|
plugins: createPluginContext(api),
|
|
strings,
|
|
pluginFailed: error => `Plugin failed: ${error}`,
|
|
features: { apply: (config: unknown) => { seen.features = config; } },
|
|
links: { configure: value => { seen.links = value; } },
|
|
column: { set: config => { seen.column = config; }, markRead() {}, jumpToUnread() {}, unread: () => 3 },
|
|
extra: { demo: { set: (value: unknown) => { seen.extra = value; } } },
|
|
queues: [['__demoExtra', value => { seen.extra = value; }]],
|
|
});
|
|
for (const name of ['wayBetter', 'Demo', 'OldName']) {
|
|
assert.equal(win[name], runtime, name);
|
|
assert.deepEqual([Object.getOwnPropertyDescriptor(win, name)!.writable, Object.getOwnPropertyDescriptor(win, name)!.configurable], [false, false]);
|
|
}
|
|
assert.equal(runtime.version, '1.2.3');
|
|
assert.deepEqual(seen, { features: { gate: false }, links: { site: 'tab' }, column: { source: 'home' }, extra: ['x'] });
|
|
assert.equal(runtime.i18n.locale(), 'tr');
|
|
assert.equal(runtime.i18n.t('demo.hello', { name: 'Ada' }), 'Merhaba Ada');
|
|
assert.equal(strings.message('other.key'), undefined, 'only the runtime\'s prefixes are taken');
|
|
assert.equal(runtime.column.unread(), 3);
|
|
assert.equal(runtime.api, api, 'outside a plugin: the full API');
|
|
assert.equal(runtime.withPlugin('a.plugin.js', null, () => runtime.api), api, 'a legacy plugin: the full API');
|
|
// A member that throws never breaks the others.
|
|
const errors: unknown[] = [];
|
|
const original = console.error;
|
|
console.error = (...args: unknown[]) => { errors.push(args[0]); };
|
|
try {
|
|
const broken = installSiteRuntime({ win: runtimeWindow(), site: { product: 'Demo', runtimeGlobal: 'Broken' }, version: '1', plugins: createPluginContext(api), strings, pluginFailed: String, fonts: () => { throw new Error('bad fonts'); } });
|
|
broken.fonts.provide([]);
|
|
assert.match(String(errors[0]), /\[Demo\] fonts failed/);
|
|
} finally { console.error = original; }
|
|
// Developer mode: a plugin that failed to start shows file:line in a toast.
|
|
runtime.devError('a.plugin.js', Object.assign(new Error('boom'), { stack: 'Error: boom\n at start (wb-dev/a.plugin.js:12:5)' }));
|
|
assert.match(toasts[0] ?? '', /^Plugin failed: a\.plugin\.js:\d+/);
|
|
// The palette hears about commands only when they change.
|
|
list = [...list, { id: 'a.plugin.js:stop', title: 'Stop', keywords: 'halt' }];
|
|
commandListeners.forEach(listener => listener(list));
|
|
await tick(120);
|
|
assert.deepEqual(sent, [['tab_report', { report: { commands: list } }]]);
|
|
});
|
|
|
|
test('page strings: deliveries are data (prefix, type and length checked), English is the fallback', () => {
|
|
const strings = createPageStrings({ english: { 'x.a': 'A', 'kit.ok': 'OK', 'plugin.clean-links.name': 'Clean links' }, prefixes: ['x.', 'kit.', 'plugin.'], label: 'Demo', bundledPrefix: 'bt-' });
|
|
let changes = 0;
|
|
const off = strings.onChange(() => { changes += 1; });
|
|
strings.set(null);
|
|
strings.set({ locale: 'not a tag!', messages: {} });
|
|
strings.set({ locale: 'de' });
|
|
assert.equal(strings.locale(), 'en');
|
|
strings.set({ locale: 'de', messages: { 'x.a': 'Ä', 'x.long': 'l'.repeat(PAGE_STRING_LIMITS.value + 1), 'x.n': 5, 'native.secret': 'no' } });
|
|
assert.equal(changes, 1);
|
|
assert.deepEqual([strings.locale(), strings.t('x.a'), strings.t('kit.ok'), strings.message('x.long'), strings.message('native.secret'), strings.t('x.unknown')], ['de', 'Ä', 'OK', undefined, undefined, 'x.unknown']);
|
|
strings.set({ locale: 'de', messages: { 'x.a': 'Ä' } });
|
|
assert.equal(changes, 1, 'the same delivery changes nothing');
|
|
assert.equal(strings.source.message('x.a'), 'Ä');
|
|
// Bundled plugins are installed with a prefix; their strings are keyed by the source name.
|
|
assert.equal(strings.pluginNamespace('bt-clean-links.plugin.js'), 'clean-links');
|
|
assert.equal(strings.pluginNamespace('bt-mine.plugin.js'), 'bt-mine', 'an owner\'s file that only looks bundled keeps its name');
|
|
assert.equal(strings.pluginNamespace('other.plugin.js'), 'other');
|
|
off();
|
|
strings.set({ locale: 'fr', messages: {} });
|
|
assert.equal(changes, 1);
|
|
});
|
|
|
|
test('command report and developer errors without a bridge or a toast do nothing', () => {
|
|
assert.doesNotThrow(() => installCommandReport({ list: () => [], onChange: () => () => {} }, undefined)());
|
|
assert.doesNotThrow(() => devErrorReporter(() => undefined, String)('a.plugin.js', new Error('x')));
|
|
assert.doesNotThrow(() => devErrorReporter(() => () => { throw new Error('toast broke'); }, String)('a.plugin.js', new Error('x')));
|
|
assert.doesNotThrow(() => devErrorReporter(() => () => {}, String)(42, new Error('x')));
|
|
});
|
|
|
|
test('the panel button sits above a bottom bar, never over a viewer or under an overlay', () => {
|
|
assert.equal(buttonBottom(null, 800), BUTTON_GAP);
|
|
assert.equal(buttonBottom(747, 800), 53 + BUTTON_GAP, 'above a 53px bottom bar');
|
|
assert.equal(buttonBottom(800, 800), BUTTON_GAP, 'an empty bar at the bottom edge adds nothing');
|
|
assert.equal(buttonBottom(100, 800), BUTTON_GAP, 'an implausibly tall bar adds nothing');
|
|
const site = { hiddenOn: (pathname: string) => /\/photo\/\d+$/.test(pathname) };
|
|
assert.equal(buttonHidden(site, '/a/status/1/photo/1', false), true);
|
|
assert.equal(buttonHidden(site, '/home', true), true);
|
|
assert.equal(buttonHidden(site, '/home', false), false);
|
|
assert.equal(buttonHidden({}, '/a/status/1/photo/1', false), false);
|
|
});
|