waybetter/packages/runtime/tests/features.test.ts
CRT 7d30df6aaa Modes: a mode that no longer matches the features reads "Custom (based on <mode>)" and offers "Re-apply <mode>"
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.
2026-10-01 03:33:41 +07:00

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();
});