waybetter/packages/runtime/tests/features.test.ts
CRT 8dd5eee99b Feature health on the area cards: a feature that paused itself is named by its area's "health" palette entry, and the panel reads it for the row and the card (no new report to the host)
The entry opens the notes in the page; its keywords carry the paused flags of that area. English
for the three new words; the other catalogs follow with the words step.
2026-09-30 22:00:04 +07:00

440 lines
26 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, 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('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();
});