A mode is a starting point, never a lock and never applied again on its own. modeStatus() (runtime) compares the flags with the mode's preset: custom, what re-applying turns on and off, and which of those the preset gained in an update since it was applied. mode_set keeps the preset of that moment (modes.json "preset", flag names only, bounded; forgotten when the mode changes). Settings, the first-run screen and the app menu's Mode row show Custom; Re-apply lists what it turns on and off before it writes anything; a preset that grew is mentioned once on the mode row (Review), never applied. Tests: exact match, changed by the owner, preset grew, choice made before the preset was kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
470 lines
29 KiB
TypeScript
470 lines
29 KiB
TypeScript
// The feature table (areas, flags, modes), the settings schema and the feature-module registry:
|
|
// flag and surface gating, ownership, one shared observer, the command budget, settings, hotfix
|
|
// overrides and the selector health check.
|
|
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { observeSelector, type DomWindow } from '../src/domHub.ts';
|
|
import { AREA_COMMAND_BUDGET, PROBE_STRIKES, createFeatureRegistry, type FeatureModule, type HotfixData } from '../src/featureModules.ts';
|
|
import {
|
|
areaFlag, effectiveFlags, manualChanges, modeFlags, modeStatus, normalizeFlags, pausedFeatures, sparseFlags, supersededFiles, switchMode, tableDefaults, validateTable,
|
|
type FeatureTable,
|
|
} from '../src/featureTable.ts';
|
|
import { createCorePluginApi, type CorePluginApi, type PluginApiSite } from '../src/pluginApi.ts';
|
|
import { normalizeSettings, validateSchema, type SettingsSchema } from '../src/settingsForm.ts';
|
|
|
|
const TABLE: FeatureTable = {
|
|
areas: [
|
|
{ id: 'navigation', name: 'Navigation+', description: 'plugin.navigation-plus.description', owner: 'wb-navigation-plus.plugin.js', namespace: 'navigation-plus' },
|
|
{ id: 'reader', name: 'Reader+', description: 'plugin.reader-plus.description', owner: 'wb-reader-plus.plugin.js', namespace: 'reader-plus' },
|
|
],
|
|
features: [
|
|
{ id: 'fixScroll', area: null, group: 'fixes', default: true, backing: 'app' },
|
|
{ id: 'hideTrends', area: 'navigation', default: false, backing: 'module', surfaces: ['timeline'] },
|
|
{ id: 'navigation.focusMode', area: 'navigation', default: false, backing: 'module', settings: true, supersedes: 'wb-focus-mode.plugin.js' },
|
|
{ id: 'navigation.keys', area: 'navigation', default: false, backing: 'module' },
|
|
{ id: 'hideAds', area: 'reader', default: true, backing: 'app' },
|
|
{ id: 'reader.cleanLinks', area: 'reader', default: false, backing: 'plugin', file: 'wb-clean-links.plugin.js' },
|
|
],
|
|
modes: [
|
|
{ id: 'basic', name: 'mode.basic', description: 'mode.basic.text', flags: { 'area.navigation': false, 'area.reader': false } },
|
|
{ id: 'advanced', name: 'mode.advanced', description: 'mode.advanced.text', recommended: true, flags: {} },
|
|
{ id: 'power', name: 'mode.power', description: 'mode.power.text', flags: { 'navigation.keys': true } },
|
|
],
|
|
};
|
|
|
|
test('feature table: defaults, normalizing, sparse storage and ownership', () => {
|
|
assert.deepEqual(validateTable(TABLE), []);
|
|
const defaults = tableDefaults(TABLE);
|
|
assert.deepEqual(defaults, { fixScroll: true, hideTrends: false, 'navigation.focusMode': false, 'navigation.keys': false, hideAds: true, 'area.navigation': true, 'area.reader': true },
|
|
'a feature still backed by a plugin file is not a flag; every area has its switch, on');
|
|
assert.deepEqual(normalizeFlags(TABLE, { hideTrends: true, hideAds: 'yes', unknown: true, 'reader.cleanLinks': true }), { ...defaults, hideTrends: true }, 'unknown keys dropped, non-booleans take the default');
|
|
assert.deepEqual(sparseFlags(TABLE, { ...defaults, hideTrends: true, hideAds: false }), { hideTrends: true, hideAds: false }, 'only what differs from the default is stored');
|
|
assert.deepEqual(sparseFlags(TABLE, defaults), {});
|
|
// An area that is off turns off what it owns; core flags and other areas are untouched.
|
|
const off = effectiveFlags(TABLE, { ...defaults, hideTrends: true, [areaFlag('reader')]: false });
|
|
assert.equal(off.hideAds, false);
|
|
assert.equal(off.hideTrends, true);
|
|
assert.equal(off.fixScroll, true, 'no plugin switch turns a core flag off');
|
|
assert.deepEqual(supersededFiles(TABLE), [{ file: 'wb-focus-mode.plugin.js', flag: 'navigation.focusMode' }]);
|
|
});
|
|
|
|
test('feature table: a mode is a starting point; changing it keeps or drops the manual changes', () => {
|
|
const defaults = tableDefaults(TABLE);
|
|
assert.deepEqual(modeFlags(TABLE, 'advanced'), defaults);
|
|
assert.deepEqual(modeFlags(TABLE, null), defaults, 'never chosen: the defaults');
|
|
assert.equal(modeFlags(TABLE, 'basic')['area.reader'], false);
|
|
// The owner, on Advanced, turned trends hiding on and ads hiding off.
|
|
const mine = { ...defaults, hideTrends: true, hideAds: false };
|
|
assert.deepEqual(manualChanges(TABLE, mine, 'advanced').sort(), ['hideAds', 'hideTrends']);
|
|
const kept = switchMode(TABLE, mine, 'advanced', 'power', true);
|
|
assert.deepEqual(kept, { ...defaults, 'navigation.keys': true, hideTrends: true, hideAds: false });
|
|
const reset = switchMode(TABLE, mine, 'advanced', 'power', false);
|
|
assert.deepEqual(reset, { ...defaults, 'navigation.keys': true });
|
|
// From Basic back to Advanced with "keep": the areas Basic switched off are not manual changes.
|
|
const basic = switchMode(TABLE, mine, 'advanced', 'basic', false);
|
|
assert.deepEqual(manualChanges(TABLE, basic, 'basic'), []);
|
|
assert.deepEqual(switchMode(TABLE, basic, 'basic', 'advanced', true), defaults);
|
|
});
|
|
|
|
test('a mode is a starting point: exact, changed by the owner, and a preset that grew', () => {
|
|
const power = TABLE.modes.find(mode => mode.id === 'power')!;
|
|
const applied = modeFlags(TABLE, 'power');
|
|
// Exact match: the flags are what the mode starts from.
|
|
assert.deepEqual(modeStatus(TABLE, applied, 'power', power.flags), { custom: false, on: [], off: [], added: [] });
|
|
// No mode chosen, or one this table does not know: nothing to say, whatever the flags are.
|
|
assert.deepEqual(modeStatus(TABLE, { ...applied, hideTrends: true }, null), { custom: false, on: [], off: [], added: [] });
|
|
assert.equal(modeStatus(TABLE, applied, 'gone').custom, false);
|
|
|
|
// The owner changed features: custom, and re-applying says what it turns on and off. None of it is "new".
|
|
const mine = { ...applied, hideTrends: true, 'navigation.keys': false, 'area.reader': false };
|
|
assert.deepEqual(modeStatus(TABLE, mine, 'power', power.flags), { custom: true, on: ['navigation.keys', 'area.reader'], off: ['hideTrends'], added: [] });
|
|
// Applying again is exactly that list: afterwards the match is exact.
|
|
assert.equal(modeStatus(TABLE, switchMode(TABLE, mine, 'power', 'power', false), 'power', power.flags).custom, false);
|
|
|
|
// An update made the preset larger: the flags are as the old preset left them. Custom, nothing
|
|
// applied, and the additions are named so they can be mentioned.
|
|
const grown: FeatureTable = { ...TABLE, modes: TABLE.modes.map(mode => mode.id === 'power' ? { ...mode, flags: { ...mode.flags, hideTrends: true, hideAds: false } } : mode) };
|
|
assert.deepEqual(modeStatus(grown, applied, 'power', power.flags), { custom: true, on: ['hideTrends'], off: ['hideAds'], added: ['hideTrends', 'hideAds'] });
|
|
// Both at once: the owner's own change is a difference but not an addition.
|
|
assert.deepEqual(modeStatus(grown, { ...applied, 'navigation.keys': false }, 'power', power.flags), { custom: true, on: ['hideTrends', 'navigation.keys'], off: ['hideAds'], added: ['hideTrends', 'hideAds'] });
|
|
// An addition the owner already has is no difference at all.
|
|
assert.deepEqual(modeStatus(grown, { ...applied, hideTrends: true }, 'power', power.flags).added, ['hideAds']);
|
|
// Once the grown preset is the one remembered (the note was looked at), it is not "new" again; the mode stays custom.
|
|
assert.deepEqual(modeStatus(grown, applied, 'power', grown.modes.find(mode => mode.id === 'power')!.flags), { custom: true, on: ['hideTrends'], off: ['hideAds'], added: [] });
|
|
// A choice made before the preset was remembered: a difference, never a guess about what is new.
|
|
assert.deepEqual(modeStatus(grown, applied, 'power'), { custom: true, on: ['hideTrends'], off: ['hideAds'], added: [] });
|
|
assert.deepEqual(modeStatus(grown, applied, 'power', null).added, []);
|
|
});
|
|
|
|
test('feature table: problems are reported', () => {
|
|
const bad: FeatureTable = {
|
|
areas: [{ id: 'Nav', name: 'Nav', description: 'd', owner: 'nav.js', namespace: 'nav' }, ...TABLE.areas],
|
|
features: [
|
|
...TABLE.features,
|
|
{ id: 'hideTrends', area: 'reader', default: false, backing: 'app' },
|
|
{ id: 'area.x', area: null, default: false, backing: 'app' },
|
|
{ id: 'lost', area: 'nowhere', default: false, backing: 'app' },
|
|
{ id: 'reader.x', area: 'reader', default: true, backing: 'plugin' },
|
|
{ id: 'reader.y', area: 'reader', default: false, backing: 'app', settings: true },
|
|
],
|
|
modes: [...TABLE.modes, { id: 'odd', name: 'n', description: 'd', recommended: true, flags: { nope: true, hideAds: true } }],
|
|
};
|
|
const problems = validateTable(bad).join('\n');
|
|
for (const expected of ['area "Nav": invalid id', 'owner must be a plugin file name', 'hideTrends: owned twice', 'area.x: "area." is kept', 'lost: unknown area', 'reader.x: a plugin-backed feature names its file',
|
|
'reader.x: a plugin file is off', 'reader.y: only a feature module has a settings schema', 'mode odd: unknown flag nope', 'mode odd: hideAds is already the default', 'exactly one mode is the recommended one']) {
|
|
assert.ok(problems.includes(expected), `${expected}\n${problems}`);
|
|
}
|
|
});
|
|
|
|
const SCHEMA: SettingsSchema = [
|
|
{ key: 'enabled', type: 'toggle', default: true, label: 'enabled' },
|
|
{ type: 'divider' },
|
|
{ key: 'days', type: 'number', default: 30, min: 1, max: 365, label: 'days' },
|
|
{ key: 'mode', type: 'select', default: 'calm', options: [{ value: 'calm', label: 'calm' }, { value: 'zen', label: 'zen' }], label: 'mode' },
|
|
{ key: 'note', type: 'text', default: '', maxLength: 5, label: 'note' },
|
|
{ key: 'patterns', type: 'list', default: [], maxItems: 2, label: 'patterns' },
|
|
];
|
|
|
|
test('settings schema: stored values are normalized to the schema, whatever was stored', () => {
|
|
assert.deepEqual(validateSchema(SCHEMA), []);
|
|
assert.deepEqual(normalizeSettings(SCHEMA, null), { enabled: true, days: 30, mode: 'calm', note: '', patterns: [] });
|
|
assert.deepEqual(normalizeSettings(SCHEMA, { enabled: 'no', days: 9999, mode: 'loud', note: 'too long', patterns: ['a', '', 3, 'b', 'c'], extra: 1 }),
|
|
{ enabled: true, days: 365, mode: 'calm', note: 'too l', patterns: ['a', 'b'] });
|
|
assert.deepEqual(normalizeSettings(SCHEMA, { enabled: false, days: 7, mode: 'zen' }), { enabled: false, days: 7, mode: 'zen', note: '', patterns: [] });
|
|
const bad: SettingsSchema = [{ key: 'a', type: 'toggle', default: true, label: 'a' }, { key: 'a', type: 'number', default: 1, min: 5, max: 2, label: '' }, { key: '1x', type: 'select', default: 'z', options: [], label: 'x' }];
|
|
const problems = validateSchema(bad).join('\n');
|
|
for (const expected of ['a: listed twice', 'a: a label is required', 'a: min is above max', 'invalid key "1x"', '1x: the default is not an option']) assert.ok(problems.includes(expected), `${expected}\n${problems}`);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// A small page: one document, a body that can hold matching elements, counted observers.
|
|
|
|
type Listener = (event: unknown) => void;
|
|
class FakeTarget {
|
|
listeners = new Map<string, Set<Listener>>();
|
|
addEventListener(type: string, fn: Listener) { (this.listeners.get(type) ?? this.listeners.set(type, new Set()).get(type)!).add(fn); }
|
|
removeEventListener(type: string, fn: Listener) { this.listeners.get(type)?.delete(fn); }
|
|
emit(type: string, event: unknown = {}) { for (const fn of [...(this.listeners.get(type) ?? [])]) fn(event); }
|
|
count(type: string) { return this.listeners.get(type)?.size ?? 0; }
|
|
}
|
|
|
|
const SITE: PluginApiSite = {
|
|
name: 'Demo', product: 'wayBetter for Test', storagePrefix: 'demo-plugin:', styleDataKey: 'demoPlugin',
|
|
toastId: 'demo-toast', toastBackground: '#111', itemSelector: 'article', chordKeys: [],
|
|
};
|
|
|
|
function page() {
|
|
const styles: { dataset: Record<string, string>; textContent: string; isConnected: boolean; remove(): void }[] = [];
|
|
const matches = new Map<string, object[]>();
|
|
const observers: Array<() => void> = [];
|
|
const store = new Map<string, string>();
|
|
const doc = new FakeTarget() as FakeTarget & Record<string, unknown>;
|
|
Object.assign(doc, {
|
|
activeElement: null,
|
|
documentElement: { getAttribute: () => 'en' },
|
|
body: { firstElementChild: {} },
|
|
head: { appendChild(node: { isConnected: boolean }) { node.isConnected = true; } },
|
|
querySelectorAll: (selector: string) => matches.get(selector) ?? [],
|
|
querySelector: (selector: string) => (matches.get(selector) ?? [])[0] ?? null,
|
|
createElement: () => {
|
|
const node = { dataset: {} as Record<string, string>, textContent: '', isConnected: false, remove() { node.isConnected = false; } };
|
|
styles.push(node);
|
|
return node;
|
|
},
|
|
});
|
|
const win = new FakeTarget() as FakeTarget & Record<string, unknown>;
|
|
const location = { href: 'https://site.test/home', pathname: '/home' };
|
|
Object.assign(win, {
|
|
document: doc,
|
|
location,
|
|
localStorage: { get length() { return store.size; }, key: (i: number) => [...store.keys()][i] ?? null, getItem: (k: string) => store.get(k) ?? null, setItem: (k: string, v: string) => { store.set(k, v); }, removeItem: (k: string) => { store.delete(k); } },
|
|
requestAnimationFrame: (fn: () => void) => { fn(); return 1; },
|
|
cancelAnimationFrame: () => {},
|
|
MutationObserver: class { constructor(fn: () => void) { observers.push(() => fn()); } observe() {} disconnect() {} },
|
|
});
|
|
const api = createCorePluginApi(win as unknown as Window & typeof globalThis, SITE);
|
|
return {
|
|
api, win: win as unknown as Window & typeof globalThis, doc, styles, matches, observers, store, location,
|
|
/** The site rendered something: every observer fires (one frame). */
|
|
mutate() { for (const fire of observers) fire(); },
|
|
go(path: string) { location.href = `https://site.test${path}`; location.pathname = path; win.emit('popstate'); },
|
|
liveStyles: () => styles.filter(style => style.isConnected && style.textContent).map(style => style.textContent),
|
|
};
|
|
}
|
|
|
|
type Timer = { fn: () => void; ms: number; id: number; at: number };
|
|
function fakeTimers() {
|
|
let now = 0;
|
|
let next = 1;
|
|
let queue: Timer[] = [];
|
|
return {
|
|
setTimeout(fn: () => void, ms: number) { const id = next++; queue.push({ fn, ms, id, at: now + ms }); return id; },
|
|
clearTimeout(id: unknown) { queue = queue.filter(timer => timer.id !== id); },
|
|
advance(ms: number) {
|
|
const end = now + ms;
|
|
for (;;) {
|
|
const due = queue.filter(timer => timer.at <= end).sort((a, b) => a.at - b.at)[0];
|
|
if (!due) break;
|
|
queue = queue.filter(timer => timer !== due);
|
|
now = due.at;
|
|
due.fn();
|
|
}
|
|
now = end;
|
|
},
|
|
pending: () => queue.length,
|
|
};
|
|
}
|
|
|
|
function registryFor(env: ReturnType<typeof page>, extra: { surfaces?: () => string[]; hotfix?: () => HotfixData; timers?: ReturnType<typeof fakeTimers> } = {}) {
|
|
return createFeatureRegistry<CorePluginApi>({
|
|
win: env.win, table: TABLE, api: env.api, label: 'Demo',
|
|
surfaces: extra.surfaces ?? (() => (env.location.pathname === '/home' ? ['timeline', 'home'] : ['profile'])),
|
|
hotfix: extra.hotfix, timers: extra.timers, settleMs: 3000, pollMs: 500,
|
|
});
|
|
}
|
|
|
|
test('registry: a module runs only while its flag is on and the page is on one of its surfaces, and leaves nothing behind', () => {
|
|
const env = page();
|
|
const registry = registryFor(env);
|
|
const log: string[] = [];
|
|
registry.define({ id: 'hideTrends', start(ctx) { log.push('+trends'); ctx.css('.trends{display:none}'); ctx.defer(() => log.push('-trends')); } });
|
|
registry.define({ id: 'navigation.keys', start(ctx) { log.push('+keys'); ctx.onKey(() => {}); ctx.defer(() => log.push('-keys')); } });
|
|
assert.deepEqual(registry.active(), [], 'off until the flags say so');
|
|
assert.equal(env.doc.count('keydown'), 0, 'an idle feature holds no listener');
|
|
|
|
registry.setFlags(effectiveFlags(TABLE, { ...tableDefaults(TABLE), hideTrends: true, 'navigation.keys': true }));
|
|
assert.deepEqual(registry.active().sort(), ['hideTrends', 'navigation.keys']);
|
|
assert.deepEqual(env.liveStyles(), ['.trends{display:none}']);
|
|
assert.equal(env.styles.at(-1)!.dataset.demoPlugin, 'wbf-hideTrends');
|
|
assert.equal(env.doc.count('keydown'), 1);
|
|
|
|
env.go('/someone');
|
|
assert.deepEqual(registry.active(), ['navigation.keys'], 'not a timeline: the surface-bound feature stopped');
|
|
assert.deepEqual(env.liveStyles(), []);
|
|
env.go('/home');
|
|
assert.deepEqual(registry.active().sort(), ['hideTrends', 'navigation.keys']);
|
|
|
|
// The area's switch off: everything it owns stops, without touching the features' own flags.
|
|
registry.setFlags(effectiveFlags(TABLE, { ...tableDefaults(TABLE), hideTrends: true, 'navigation.keys': true, 'area.navigation': false }));
|
|
assert.deepEqual(registry.active(), []);
|
|
assert.deepEqual(env.liveStyles(), []);
|
|
assert.equal(env.doc.count('keydown'), 0);
|
|
assert.deepEqual(log, ['+trends', '+keys', '-trends', '+trends', '-trends', '-keys']);
|
|
|
|
assert.throws(() => registry.define({ id: 'hideAds', start() {} }), /hideAds is not a feature module in the table/);
|
|
assert.throws(() => registry.define({ id: 'hideTrends', start() {} }), /defined twice/);
|
|
registry.dispose();
|
|
});
|
|
|
|
test('registry: a feature stopped by a route change is not told about that route (nothing re-added after stop)', () => {
|
|
const env = page();
|
|
const registry = registryFor(env, { surfaces: () => (env.location.pathname === '/someone' ? ['profile'] : ['timeline']) });
|
|
const routes: string[] = [];
|
|
registry.define({ id: 'hideTrends', start(ctx) { ctx.css('.trends{display:none}'); ctx.onRouteChange(href => { routes.push(href); ctx.css('.trends{opacity:0}'); }); } });
|
|
registry.setFlags(effectiveFlags(TABLE, { ...tableDefaults(TABLE), hideTrends: true }));
|
|
env.go('/explore');
|
|
assert.deepEqual(routes.length, 1, 'still on a timeline: the feature hears the route');
|
|
assert.deepEqual(env.liveStyles(), ['.trends{opacity:0}']);
|
|
env.go('/someone');
|
|
assert.deepEqual(registry.active(), []);
|
|
assert.equal(routes.length, 1, 'stopped in this round: its listener is not called');
|
|
assert.deepEqual(env.liveStyles(), []);
|
|
registry.dispose();
|
|
});
|
|
|
|
test('registry: one MutationObserver however many features observe; plugins share it too', () => {
|
|
const env = page();
|
|
const registry = registryFor(env);
|
|
const seen: string[] = [];
|
|
const first = {};
|
|
env.matches.set('article', [first]);
|
|
registry.define({ id: 'hideTrends', start(ctx) { ctx.observe('article', () => seen.push('a')); ctx.observe('aside', () => seen.push('aside')); } });
|
|
registry.define({ id: 'navigation.keys', start(ctx) { ctx.observe('article', () => seen.push('b')); ctx.watch(() => seen.push('frame')); } });
|
|
registry.setFlags({ hideTrends: true, 'navigation.keys': true });
|
|
const stopPlugin = env.api.observe('article', () => seen.push('plugin'));
|
|
assert.equal(env.observers.length, 1, 'one observer for three selector observers, a raw watch and a plugin');
|
|
assert.deepEqual(seen, ['a', 'b', 'plugin'], 'what is already there is reported at once, once');
|
|
env.matches.set('article', [first, {}]);
|
|
env.matches.set('aside', [{}]);
|
|
env.mutate();
|
|
assert.deepEqual(seen.slice(3).sort(), ['a', 'aside', 'b', 'frame', 'plugin'], 'only the new elements');
|
|
stopPlugin();
|
|
registry.dispose();
|
|
const before = seen.length;
|
|
env.mutate();
|
|
assert.equal(seen.length, before, 'nothing runs after dispose');
|
|
});
|
|
|
|
test('registry: a failing module is contained; heavy pages pause features that did not ask for them', () => {
|
|
const env = page();
|
|
const errors: unknown[] = [];
|
|
const original = console.error;
|
|
console.error = (...args: unknown[]) => { errors.push(args[0]); };
|
|
try {
|
|
const registry = registryFor(env, { surfaces: () => ['timeline'] });
|
|
registry.define({ id: 'hideTrends', start(ctx) { ctx.css('x{}'); throw new Error('markup changed'); } });
|
|
registry.define({ id: 'navigation.keys', start() {} });
|
|
registry.setFlags({ hideTrends: true, 'navigation.keys': true });
|
|
assert.deepEqual(registry.active(), ['navigation.keys']);
|
|
assert.match(String(errors[0]), /\[Demo\] hideTrends failed/);
|
|
assert.deepEqual(env.liveStyles(), [], 'what it did before failing is undone');
|
|
registry.dispose();
|
|
} finally { console.error = original; }
|
|
});
|
|
|
|
test('registry: settings come from the schema, are stored under the module\'s namespace and reach the running feature', () => {
|
|
const env = page();
|
|
const registry = registryFor(env);
|
|
const applied: unknown[] = [];
|
|
const module: FeatureModule<CorePluginApi> = {
|
|
id: 'navigation.focusMode',
|
|
// A feature ported from a plugin keeps that plugin's words and storage.
|
|
words: 'focus-mode',
|
|
storage: 'focus-mode',
|
|
settings: [{ key: 'enabled', type: 'toggle', default: true, label: 'enabled' }, { key: 'trends', type: 'toggle', default: true, label: 'trends' }],
|
|
start(ctx) {
|
|
applied.push(ctx.settings.get());
|
|
ctx.settings.onChange(values => applied.push(values));
|
|
ctx.command({ id: 'focusMode.toggle', title: ctx.text('toggle'), run: () => { ctx.settings.set({ enabled: !ctx.settings.get().enabled }); } });
|
|
},
|
|
};
|
|
env.api.i18n.namespace('focus-mode').register({ en: { toggle: 'Toggle Focus Mode' } });
|
|
env.api.i18n.namespace('navigation-plus').register({ en: { 'keys.hello': 'Hello {name}' } });
|
|
env.store.set('demo-plugin:focus-mode:settings', JSON.stringify({ enabled: true, trends: false, gone: 1 }));
|
|
registry.define(module);
|
|
assert.throws(() => registryFor(env).define({ id: 'navigation.keys', settings: [{ key: 'a', type: 'toggle', default: true, label: 'a' }], start() {} }), /the table's "settings" and the module's schema disagree/);
|
|
registry.setFlags({ 'navigation.focusMode': true });
|
|
assert.deepEqual(applied, [{ enabled: true, trends: false }], 'an old plugin\'s stored settings are read as they are');
|
|
// The area got its one settings command next to the feature's own; both belong to the area's owner.
|
|
assert.deepEqual(env.api.commands.list().map(command => command.id).sort(), ['wb-navigation-plus.plugin.js:focusMode.toggle', 'wb-navigation-plus.plugin.js:settings']);
|
|
assert.equal(env.api.commands.list().find(command => command.id.endsWith(':settings'))!.title, 'Navigation+ settings');
|
|
assert.equal(env.api.commands.list().find(command => command.id.endsWith('focusMode.toggle'))!.title, 'Toggle Focus Mode', 'its words are under the namespace of the plugin it came from');
|
|
// Without `words`, a feature's words are under its area: plugin.<area namespace>.<name>.<key>.
|
|
let greeting = '';
|
|
registry.define({ id: 'navigation.keys', start(ctx) { greeting = ctx.text('hello', { name: 'you' }); } });
|
|
registry.setFlags({ 'navigation.focusMode': true, 'navigation.keys': true });
|
|
assert.equal(greeting, 'Hello you');
|
|
env.api.commands.run('wb-navigation-plus.plugin.js:focusMode.toggle');
|
|
assert.deepEqual(applied.at(-1), { enabled: false, trends: false });
|
|
assert.equal(env.store.get('demo-plugin:focus-mode:settings'), '{"enabled":false,"trends":false}');
|
|
registry.setFlags({});
|
|
assert.deepEqual(env.api.commands.list(), [], 'commands go with the feature');
|
|
registry.dispose();
|
|
});
|
|
|
|
test(`registry: an area registers at most ${AREA_COMMAND_BUDGET} palette commands`, () => {
|
|
const env = page();
|
|
const errors: unknown[] = [];
|
|
const original = console.error;
|
|
console.error = (...args: unknown[]) => { errors.push(args[1]); };
|
|
try {
|
|
const registry = registryFor(env);
|
|
// Navigation+ has a feature with settings, so one of the five is kept for its settings command.
|
|
registry.define({ id: 'navigation.keys', start(ctx) { for (let n = 0; n < AREA_COMMAND_BUDGET; n += 1) ctx.command({ id: `keys.${n}`, title: `Key ${n}`, run() {} }); } });
|
|
registry.setFlags({ 'navigation.keys': true });
|
|
assert.match(String(errors[0]), /at most 5 palette commands per area/);
|
|
assert.deepEqual(registry.active(), [], 'the feature that went over the budget does not run');
|
|
assert.deepEqual(env.api.commands.list(), []);
|
|
registry.dispose();
|
|
} finally { console.error = original; }
|
|
});
|
|
|
|
test(`registry: a probe that matches nothing ${PROBE_STRIKES} visits in a row pauses that one feature; a hotfix selector or kill flag applies`, () => {
|
|
const env = page();
|
|
const timers = fakeTimers();
|
|
let hotfix: HotfixData = {};
|
|
const warnings: unknown[] = [];
|
|
const original = console.warn;
|
|
console.warn = (...args: unknown[]) => { warnings.push(args[0]); };
|
|
try {
|
|
const registry = registryFor(env, { timers, hotfix: () => hotfix, surfaces: () => ['timeline'] });
|
|
const used: string[] = [];
|
|
registry.define({ id: 'hideTrends', probes: { module: '[data-testid="trend"]', label: { selector: '[aria-label="Trending"]', english: true } }, start(ctx) { used.push(ctx.selector('module')); } });
|
|
registry.define({ id: 'navigation.keys', start() {} });
|
|
env.matches.set('[aria-label="Trending"]', [{}]);
|
|
registry.setFlags({ hideTrends: true, 'navigation.keys': true });
|
|
const visit = () => { env.go(env.location.pathname === '/home' ? '/explore' : '/home'); timers.advance(500 + 3000); };
|
|
|
|
timers.advance(3500);
|
|
assert.deepEqual(registry.health(), [], 'one miss is not a verdict');
|
|
visit();
|
|
// The selector comes back for a visit: the count starts over.
|
|
env.matches.set('[data-testid="trend"]', [{}]);
|
|
visit();
|
|
env.matches.delete('[data-testid="trend"]');
|
|
visit();
|
|
visit();
|
|
assert.deepEqual(registry.active().sort(), ['hideTrends', 'navigation.keys']);
|
|
visit();
|
|
assert.deepEqual(registry.active(), ['navigation.keys'], 'the feature paused itself; the rest of the area runs');
|
|
assert.deepEqual(registry.health(), [{ id: 'hideTrends', area: 'navigation', state: 'paused', probes: ['module'] }]);
|
|
assert.match(String(warnings[0]), /\[Demo\] hideTrends paused: nothing matches module/);
|
|
// The area says so in the palette; the panel reads which features from the same entry.
|
|
const health = env.api.commands.list().filter(command => command.id.endsWith(':health'));
|
|
assert.deepEqual(health, [{ id: 'wb-navigation-plus.plugin.js:health', title: 'Paused features of Navigation+', keywords: 'paused hideTrends' }]);
|
|
assert.deepEqual([...pausedFeatures(TABLE, env.api.commands.list())], ['hideTrends']);
|
|
assert.deepEqual([...pausedFeatures(TABLE, [{ id: 'wb-navigation-plus.plugin.js:health', keywords: 'paused hideAds nope navigation.keys' }])], ['navigation.keys'], 'only flags of that area');
|
|
assert.deepEqual([...pausedFeatures(TABLE, [{ id: 'other.plugin.js:health', keywords: 'paused hideTrends' }]), ...pausedFeatures(TABLE, null)], []);
|
|
registry.setFlags({ hideTrends: true, 'navigation.keys': true });
|
|
assert.deepEqual(registry.active(), ['navigation.keys'], 'paused for the session');
|
|
// Turned off by its owner: nothing to report about it any more.
|
|
registry.setFlags({ 'navigation.keys': true });
|
|
assert.deepEqual(env.api.commands.list(), []);
|
|
registry.setFlags({ hideTrends: true, 'navigation.keys': true });
|
|
assert.equal(env.api.commands.list().length, 1);
|
|
assert.equal(timers.pending(), 0, 'no timer left behind');
|
|
registry.dispose();
|
|
|
|
// A signed hotfix replaces the selector, or switches the feature off.
|
|
hotfix = { selectors: { 'hideTrends.module': '[data-testid="trendV2"]' } };
|
|
const patched = registryFor(env, { timers, hotfix: () => hotfix, surfaces: () => ['timeline'] });
|
|
patched.define({ id: 'hideTrends', probes: { module: '[data-testid="trend"]' }, start(ctx) { used.push(ctx.selector('module')); } });
|
|
patched.setFlags({ hideTrends: true });
|
|
assert.deepEqual(used, ['[data-testid="trend"]', '[data-testid="trendV2"]']);
|
|
hotfix = { flags: { hideTrends: false } };
|
|
patched.refresh();
|
|
assert.deepEqual(patched.active(), []);
|
|
patched.dispose();
|
|
} finally { console.warn = original; }
|
|
});
|
|
|
|
test('registry: a probe that reads an English label waits for an English page instead of counting misses', () => {
|
|
const env = page();
|
|
const timers = fakeTimers();
|
|
(env.doc.documentElement as { getAttribute(): string }).getAttribute = () => 'tr';
|
|
const registry = registryFor(env, { timers, surfaces: () => ['timeline'] });
|
|
registry.define({ id: 'hideTrends', probes: { label: { selector: '[aria-label="Trending"]', english: true } }, start() {} });
|
|
registry.setFlags({ hideTrends: true });
|
|
for (let n = 0; n < 5; n += 1) { env.go(n % 2 ? '/home' : '/explore'); timers.advance(3500); }
|
|
assert.deepEqual(registry.active(), ['hideTrends']);
|
|
assert.deepEqual(registry.health(), [{ id: 'hideTrends', area: 'navigation', state: 'needs-english', probes: ['label'] }]);
|
|
registry.dispose();
|
|
});
|
|
|
|
test('registry: a probe with `when` only counts while the part of the page it lives in is there', () => {
|
|
const env = page();
|
|
const timers = fakeTimers();
|
|
const registry = registryFor(env, { timers, surfaces: () => ['timeline'] });
|
|
registry.define({ id: 'hideTrends', probes: { trend: { selector: '[data-testid="trend"]', when: '[data-testid="sidebarColumn"]' } }, start() {} });
|
|
registry.setFlags({ hideTrends: true });
|
|
// A narrow window: no right column, so no verdict, however often.
|
|
for (let n = 0; n < 4; n += 1) { env.go(n % 2 ? '/home' : '/explore'); timers.advance(3500); }
|
|
assert.deepEqual(registry.active(), ['hideTrends']);
|
|
// The column is there and the selector still matches nothing: that is the site's markup changing.
|
|
env.matches.set('[data-testid="sidebarColumn"]', [{}]);
|
|
for (let n = 0; n < 3; n += 1) { env.go(n % 2 ? '/home' : '/explore'); timers.advance(3500); }
|
|
assert.deepEqual(registry.active(), []);
|
|
assert.equal(registry.health()[0]!.state, 'paused');
|
|
registry.dispose();
|
|
});
|
|
|
|
test('observeSelector: heavy pages skip observers that ask for it', () => {
|
|
const env = page();
|
|
const seen: string[] = [];
|
|
env.matches.set('article', [{}]);
|
|
env.location.pathname = '/home';
|
|
const stop = observeSelector(env.win as unknown as DomWindow, 'article', () => seen.push('x'), { offOnHeavy: true });
|
|
assert.deepEqual(seen, ['x']);
|
|
stop();
|
|
stop();
|
|
});
|