// 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 { configureDomHub, 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>(); 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; textContent: string; isConnected: boolean; remove(): void }[] = []; const matches = new Map(); const observers: Array<() => void> = []; const store = new Map(); const doc = new FakeTarget() as FakeTarget & Record; 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, textContent: '', isConnected: false, remove() { node.isConnected = false; } }; styles.push(node); return node; }, }); const win = new FakeTarget() as FakeTarget & Record; 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, extra: { surfaces?: () => string[]; hotfix?: () => HotfixData; timers?: ReturnType } = {}) { return createFeatureRegistry({ 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 = { 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.... 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(); }); test('registry: a configured open shadow root gets the features\' sheets and counts for probes (a site part rendered inside one)', () => { configureDomHub({ shadowHosts: ['[data-host]'] }); try { const env = page(); const timers = fakeTimers(); class Sheet { css = ''; replaceSync(css: string) { this.css = css; } } const inside = new Map([['[data-row]', [{}]]]); const root = { nodeType: 11, adoptedStyleSheets: [] as Sheet[], ownerDocument: { defaultView: { CSSStyleSheet: Sheet } }, querySelector: (selector: string) => (inside.get(selector) ?? [])[0] ?? null, querySelectorAll: (selector: string) => inside.get(selector) ?? [], }; env.matches.set('[data-host]', [{ shadowRoot: root }]); const registry = registryFor(env, { timers, surfaces: () => ['timeline'] }); registry.define({ id: 'hideTrends', probes: { row: '[data-row]' }, start(ctx) { ctx.css('[data-row]{outline:1px solid}'); } }); registry.setFlags({ hideTrends: true }); assert.deepEqual(root.adoptedStyleSheets.map(sheet => sheet.css), ['[data-row]{outline:1px solid}'], 'the sheet is adopted by the root'); assert.deepEqual(env.liveStyles(), ['[data-row]{outline:1px solid}'], 'and the page keeps its own'); // The probe's element exists only inside the root: never a miss, however many visits. for (let visit = 0; visit < 4; visit++) { env.go(visit % 2 ? '/home' : '/explore'); timers.advance(4000); } assert.deepEqual(registry.health(), []); assert.deepEqual(registry.active(), ['hideTrends']); registry.setFlags({ hideTrends: false }); assert.deepEqual(root.adoptedStyleSheets, [], 'stopping takes the sheet off the root'); registry.dispose(); } finally { configureDomHub({ shadowHosts: [] }); } });