diff --git a/extension/src/lib/__tests__/selector-hide-rule.test.ts b/extension/src/lib/__tests__/selector-hide-rule.test.ts index 6b09cfb..93875f9 100644 --- a/extension/src/lib/__tests__/selector-hide-rule.test.ts +++ b/extension/src/lib/__tests__/selector-hide-rule.test.ts @@ -11,14 +11,29 @@ import { URLPattern } from "urlpattern-polyfill"; import { HIDDEN_ATTR } from "../dom-markers"; import { PLACEHOLDER_CLASS } from "../placeholder"; +import { __resetRouteChangeForTesting } from "../route-change"; import { createSelectorHideRule } from "../selector-hide-rule"; +import { __resetSelectorTokenIndexForTesting } from "../selector-token-index"; import type { RuleId } from "../storage"; +import { __resetSubtreeWatcherForTesting } from "../subtree-watcher"; const RULE_ID = "footer-redact" as RuleId; const HIDE_LABEL = "[hidden — click to reveal]"; beforeEach(() => { document.body.innerHTML = ""; + // Reset the shared dispatcher / watcher / route-change subscription so + // tests that build watchSubtrees=true rules don't leak registrations + // across cases. + __resetSelectorTokenIndexForTesting(); + __resetSubtreeWatcherForTesting(); + __resetRouteChangeForTesting(); +}); + +afterEach(() => { + __resetSelectorTokenIndexForTesting(); + __resetSubtreeWatcherForTesting(); + __resetRouteChangeForTesting(); }); describe("createSelectorHideRule constructor", () => { @@ -497,3 +512,228 @@ describe("REVEALED_ATTR ancestor skip", () => { expect(document.querySelector(`.${PLACEHOLDER_CLASS}`)).toBeNull(); }); }); + +describe("scan from inserted root", () => { + // Once the token-index dispatcher is online, the watcher hands each + // rule the added subtree root — not document.body. The scan has to + // match the root itself, not just its descendants; otherwise a + // widget whose top-level container is the match (HubSpot, OneTrust, + // Cookiebot) slips through every batch. + + it("matches the root element itself when its id matches a selector", () => { + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: ["#hubspot-messages-iframe-container"], + removeEntirely: true, + }); + const widget = document.createElement("div"); + widget.id = "hubspot-messages-iframe-container"; + document.body.append(widget); + + // Scan from the widget itself (the dispatcher's call shape). + rule.apply(widget); + + expect(widget.style.display).toBe("none"); + expect(widget.getAttribute(HIDDEN_ATTR)).toBe(RULE_ID); + }); + + it("matches the root element itself when one of its classes matches", () => { + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: [".cookie-banner"], + removeEntirely: true, + }); + const banner = document.createElement("div"); + banner.className = "cookie-banner sticky"; + document.body.append(banner); + + rule.apply(banner); + + expect(banner.style.display).toBe("none"); + }); + + it("still matches descendants of the root", () => { + // The root itself doesn't match; a descendant does. The previous + // behavior (scan from document.body) handled this; the new behavior + // (scan from added root) must still walk descendants via QSA. + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: [".inner-target"], + hideLabel: HIDE_LABEL, + }); + const wrapper = document.createElement("section"); + const inner = document.createElement("div"); + inner.className = "inner-target"; + inner.textContent = "x"; + wrapper.append(inner); + document.body.append(wrapper); + + rule.apply(wrapper); + + expect(wrapper.querySelector(".inner-target")).toBeNull(); + expect(wrapper.querySelector(`.${PLACEHOLDER_CLASS}`)).not.toBeNull(); + }); + + it("matches both root and a descendant when both qualify (outermost dedupe wins)", () => { + // The outermost-match filter must still apply when scanning from + // an added root that itself matches and contains a deeper match. + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: ["footer"], + hideLabel: HIDE_LABEL, + }); + const outer = document.createElement("footer"); + outer.id = "outer"; + const inner = document.createElement("footer"); + inner.id = "inner"; + outer.append(inner); + document.body.append(outer); + + rule.apply(outer); + + // Only one placeholder lands — outer subsumed inner. + expect(document.querySelector("#outer")).toBeNull(); + expect(document.querySelector("#inner")).toBeNull(); + expect(document.querySelectorAll(`.${PLACEHOLDER_CLASS}`)).toHaveLength(1); + }); +}); + +describe("subtree dispatcher integration", () => { + // Verifies the end-to-end path: watchSubtrees=true rule's apply + // registers with the token index, a mutation lands on document.body, + // the shared dispatcher routes to this rule, scan runs on the added + // root. + + const THROTTLE_MS = 250; + + async function flushMutations(): Promise { + await Promise.resolve(); + } + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("hides a top-level container injected after apply", async () => { + // The classic chat-widget shape: rule mounts first, then the + // vendor script injects the widget container as a direct child of + // document.body. With watchSubtrees=true the dispatcher catches it. + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: ["#late-widget"], + removeEntirely: true, + watchSubtrees: true, + }); + + rule.apply(document.body); + + const widget = document.createElement("div"); + widget.id = "late-widget"; + document.body.append(widget); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(widget.style.display).toBe("none"); + + rule.teardown?.(); + }); + + it("ignores additions whose tokens don't appear in this rule's selectors", async () => { + // Index dispatch means we shouldn't even run the rule's scan + // (no QSA on the added subtree) when no token matches. + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: ["#target"], + removeEntirely: true, + watchSubtrees: true, + }); + + rule.apply(document.body); + + const unrelated = document.createElement("div"); + unrelated.id = "something-else"; + document.body.append(unrelated); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(unrelated.style.display).toBe(""); + + rule.teardown?.(); + }); + + it("teardown unregisters from the dispatcher", async () => { + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: ["#target"], + removeEntirely: true, + watchSubtrees: true, + }); + + rule.apply(document.body); + rule.teardown?.(); + + const widget = document.createElement("div"); + widget.id = "target"; + document.body.append(widget); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + // Teardown ran — the dispatcher no longer routes to this rule, + // so the post-teardown injection stays untouched. + expect(widget.style.display).toBe(""); + }); + + it("registers siteRule selectors in the token index too", async () => { + // Without this, a URL-gated selector would never be triggered by + // the dispatcher even when the URL matches — the rule's scan would + // run only via complex-fallback (if it landed there at all). + const { rule } = createSelectorHideRule({ + id: RULE_ID, + label: "test", + description: "test", + alwaysOnSelectors: [], + siteRules: [ + { + patterns: [new URLPattern({ pathname: "/*" })], + selectors: ["#site-specific"], + }, + ], + removeEntirely: true, + watchSubtrees: true, + }); + + rule.apply(document.body); + + const widget = document.createElement("div"); + widget.id = "site-specific"; + document.body.append(widget); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(widget.style.display).toBe("none"); + + rule.teardown?.(); + }); +}); diff --git a/extension/src/lib/__tests__/selector-token-index.property.test.ts b/extension/src/lib/__tests__/selector-token-index.property.test.ts new file mode 100644 index 0000000..1b5e5ad --- /dev/null +++ b/extension/src/lib/__tests__/selector-token-index.property.test.ts @@ -0,0 +1,435 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Property tests for findTriggeredRules. The example tests pin down a +// handful of canonical shapes (root match, descendant match, no-match, +// complex fallback). The dispatcher's correctness rests on two +// structural invariants that fuzz better than they assert: +// +// - Subtree monotonicity: triggered(parent) ⊇ triggered(child) for +// every parent/child pair in the tree. A bug like "only descend N +// levels" or "miss the root itself" violates this. +// - Token soundness: rules registered with `#X` (or `.X`) appear in +// triggered(root) iff some element in the subtree has that id +// (or class). Catches "we look at the wrong DOM property" and +// "we only enumerate the first classList token" style mistakes. +// +// Tree generator mirrors selector-hide-rule.property.test.ts: flat +// `(size, parents[])` encoding so fast-check's shrinker can reduce +// random trees without losing structural validity. + +import fc from "fast-check"; + +import { + __resetSelectorTokenIndexForTesting, + findTriggeredRules, + registerRule, +} from "../selector-token-index"; +import type { RuleId } from "../storage"; +import { __resetSubtreeWatcherForTesting } from "../subtree-watcher"; + +// Small alphabets force collisions — a 3-symbol id alphabet means +// roughly one in four nodes shares an id with the registered rule's +// token. Otherwise the random space is too sparse for the +// "rule triggers iff a node has the id" property to fire its iff +// branch in both directions within fast-check's budget. +const ID_ALPHABET = ["a", "b", "c"] as const; +const CLASS_ALPHABET = ["x", "y", "z"] as const; + +// `""` in the id alphabet stands for "no id attribute" — about a quarter +// of nodes end up unannotated, which keeps the iff-direction of the +// soundness property exercised in both branches. +const idArb = fc.constantFrom(...ID_ALPHABET, ""); +const classesArb = fc.subarray([...CLASS_ALPHABET], { + minLength: 0, + maxLength: 3, +}); + +interface NodeSpec { + id: string; + classes: readonly string[]; +} + +const nodeSpecArb: fc.Arbitrary = fc.record({ + id: idArb, + classes: classesArb, +}); + +interface FlatTree { + size: number; + parents: readonly number[]; + specs: readonly NodeSpec[]; +} + +const flatTreeArb: fc.Arbitrary = fc + .integer({ min: 1, max: 12 }) + .chain((size) => { + const specsArb = fc.array(nodeSpecArb, { + minLength: size, + maxLength: size, + }); + if (size === 1) { + return specsArb.map((specs) => ({ size, parents: [], specs })); + } + const parentArbs = Array.from({ length: size - 1 }, (_, index) => + fc.integer({ min: 0, max: index }), + ); + return fc + .tuple(fc.tuple(...parentArbs), specsArb) + .map(([parents, specs]) => ({ size, parents, specs })); + }); + +interface BuiltTree { + root: HTMLElement; + nodes: HTMLElement[]; + parents: readonly number[]; +} + +function buildTree({ size, parents, specs }: FlatTree): BuiltTree { + const nodes: HTMLElement[] = Array.from({ length: size }, (_, index) => { + const element = document.createElement("div"); + const spec = specs[index] as NodeSpec; + if (spec.id !== "") { + element.id = spec.id; + } + for (const cls of spec.classes) { + element.classList.add(cls); + } + return element; + }); + for (let i = 1; i < size; i++) { + const parentIndex = parents[i - 1]; + if (parentIndex === undefined) { + throw new Error("parents array shorter than expected"); + } + nodes[parentIndex]?.append(nodes[i] as HTMLElement); + } + const root = nodes[0]; + if (!root) { + throw new Error("empty tree (size should be >= 1)"); + } + return { root, nodes, parents }; +} + +// Resolve a node's full ancestor chain back to the root, including +// the node itself. Used to verify that every (descendant, ancestor) +// pair satisfies the monotonicity invariant. +function ancestorChain(index: number, parents: readonly number[]): number[] { + const chain = [index]; + let current = index; + while (current !== 0) { + const parent = parents[current - 1]; + if (parent === undefined) { + throw new Error("invalid parent chain"); + } + chain.push(parent); + current = parent; + } + return chain; +} + +const RULE_FOOTER = "footer-redact" as RuleId; +const RULE_COMMENTS = "comments-redact" as RuleId; +const RULE_REVIEWS = "reviews-redact" as RuleId; +const RULE_COOKIE = "cookie-banner-hide" as RuleId; +const RULE_CHAT = "chat-widget-hide" as RuleId; +const RULE_NEWSLETTER = "newsletter-modal-hide" as RuleId; +const RULE_FALLBACK = "ads-hide" as RuleId; + +beforeEach(() => { + document.body.innerHTML = ""; + __resetSelectorTokenIndexForTesting(); + __resetSubtreeWatcherForTesting(); +}); + +afterEach(() => { + __resetSelectorTokenIndexForTesting(); + __resetSubtreeWatcherForTesting(); +}); + +describe("findTriggeredRules — structural invariants", () => { + it("triggered(parent) ⊇ triggered(child) for every parent/child pair", () => { + fc.assert( + fc.property(flatTreeArb, (tree) => { + __resetSelectorTokenIndexForTesting(); + // One rule per id token and per class token — covers both + // index axes in a single property run. + registerRule({ + ruleId: RULE_FOOTER, + selectors: ["#a"], + dispatchScan: () => undefined, + }); + registerRule({ + ruleId: RULE_COMMENTS, + selectors: ["#b"], + dispatchScan: () => undefined, + }); + registerRule({ + ruleId: RULE_REVIEWS, + selectors: [".x"], + dispatchScan: () => undefined, + }); + registerRule({ + ruleId: RULE_COOKIE, + selectors: [".y"], + dispatchScan: () => undefined, + }); + + const built = buildTree(tree); + document.body.append(built.root); + + const triggeredByIndex = built.nodes.map((node) => + findTriggeredRules(node), + ); + + for (let i = 1; i < tree.size; i++) { + const parentIndex = tree.parents[i - 1] as number; + const childTriggered = triggeredByIndex[i] as Set; + const parentTriggered = triggeredByIndex[parentIndex] as Set; + for (const ruleId of childTriggered) { + // Parent's subtree contains child's subtree by construction, + // so anything child triggers, parent must trigger. + expect(parentTriggered.has(ruleId)).toBe(true); + } + } + }), + ); + }); + + it("triggered(root) is the union of all per-node triggers (no rule lost to ancestry)", () => { + fc.assert( + fc.property(flatTreeArb, (tree) => { + __resetSelectorTokenIndexForTesting(); + registerRule({ + ruleId: RULE_FOOTER, + selectors: ["#a"], + dispatchScan: () => undefined, + }); + registerRule({ + ruleId: RULE_REVIEWS, + selectors: [".x"], + dispatchScan: () => undefined, + }); + + const built = buildTree(tree); + document.body.append(built.root); + + const rootTriggered = findTriggeredRules(built.root); + const perNodeUnion = new Set(); + for (const node of built.nodes) { + for (const ruleId of findTriggeredRules(node)) { + perNodeUnion.add(ruleId); + } + } + + expect(rootTriggered).toEqual(perNodeUnion); + }), + ); + }); + + it("is idempotent — repeated calls produce equal sets", () => { + fc.assert( + fc.property(flatTreeArb, (tree) => { + __resetSelectorTokenIndexForTesting(); + registerRule({ + ruleId: RULE_FOOTER, + selectors: ["#a", ".x"], + dispatchScan: () => undefined, + }); + + const built = buildTree(tree); + document.body.append(built.root); + + const first = findTriggeredRules(built.root); + const second = findTriggeredRules(built.root); + expect(second).toEqual(first); + }), + ); + }); +}); + +describe("findTriggeredRules — token soundness", () => { + it("id-keyed rule fires iff some element in the subtree has the matching id", () => { + fc.assert( + fc.property( + flatTreeArb, + fc.constantFrom(...ID_ALPHABET), + (tree, targetId) => { + __resetSelectorTokenIndexForTesting(); + registerRule({ + ruleId: RULE_FOOTER, + selectors: [`#${targetId}`], + dispatchScan: () => undefined, + }); + + const built = buildTree(tree); + document.body.append(built.root); + + // For each node, the rule should fire iff the subtree rooted + // at that node contains an element with id=targetId. + for (let i = 0; i < built.nodes.length; i++) { + const node = built.nodes[i] as HTMLElement; + const chain = ancestorChain(i, tree.parents); + const subtreeIndexes = new Set([i]); + // A subtree contains every node whose ancestor chain passes + // through `i`. Walk all nodes and check. + for (let j = 0; j < built.nodes.length; j++) { + if (ancestorChain(j, tree.parents).includes(i)) { + subtreeIndexes.add(j); + } + } + const hasMatch = [...subtreeIndexes].some( + (index) => (tree.specs[index] as NodeSpec).id === targetId, + ); + const triggered = findTriggeredRules(node); + expect(triggered.has(RULE_FOOTER)).toBe(hasMatch); + // `chain` only exists to anchor i within the larger tree; + // touching it in a property assertion would let fast-check + // shrink past the case we're checking. + expect(chain.at(-1)).toBe(0); + } + }, + ), + ); + }); + + it("class-keyed rule fires iff some element in the subtree has the matching class", () => { + fc.assert( + fc.property( + flatTreeArb, + fc.constantFrom(...CLASS_ALPHABET), + (tree, targetClass) => { + __resetSelectorTokenIndexForTesting(); + registerRule({ + ruleId: RULE_REVIEWS, + selectors: [`.${targetClass}`], + dispatchScan: () => undefined, + }); + + const built = buildTree(tree); + document.body.append(built.root); + + for (let i = 0; i < built.nodes.length; i++) { + const node = built.nodes[i] as HTMLElement; + const subtreeIndexes = new Set([i]); + for (let j = 0; j < built.nodes.length; j++) { + if (ancestorChain(j, tree.parents).includes(i)) { + subtreeIndexes.add(j); + } + } + const hasMatch = [...subtreeIndexes].some((index) => + (tree.specs[index] as NodeSpec).classes.includes(targetClass), + ); + const triggered = findTriggeredRules(node); + expect(triggered.has(RULE_REVIEWS)).toBe(hasMatch); + } + }, + ), + ); + }); + + it("multiple class tokens on one element all surface their rules", () => { + fc.assert( + fc.property( + fc.subarray([...CLASS_ALPHABET], { + minLength: 1, + maxLength: 3, + }), + (classes) => { + __resetSelectorTokenIndexForTesting(); + const ruleByClass: Record = { + x: RULE_FOOTER, + y: RULE_COMMENTS, + z: RULE_REVIEWS, + }; + for (const cls of CLASS_ALPHABET) { + registerRule({ + ruleId: ruleByClass[cls] as RuleId, + selectors: [`.${cls}`], + dispatchScan: () => undefined, + }); + } + + const node = document.createElement("div"); + for (const cls of classes) { + node.classList.add(cls); + } + document.body.append(node); + + const triggered = findTriggeredRules(node); + // Every class the node carries should surface its rule; + // classes it doesn't carry should not. + for (const cls of CLASS_ALPHABET) { + expect(triggered.has(ruleByClass[cls] as RuleId)).toBe( + classes.includes(cls), + ); + } + }, + ), + ); + }); +}); + +describe("findTriggeredRules — complex fallback", () => { + it("complex-fallback rules appear in triggered(root) for every tree", () => { + fc.assert( + fc.property(flatTreeArb, (tree) => { + __resetSelectorTokenIndexForTesting(); + // No id/class tokens on the rule — it can only run via the + // complex-fallback bucket, so every dispatched root must + // trigger it regardless of contents. + registerRule({ + ruleId: RULE_FALLBACK, + selectors: ['[role="dialog"]'], + dispatchScan: () => undefined, + }); + // A non-fallback rule for contrast: should only fire when a + // matching token is present. + registerRule({ + ruleId: RULE_FOOTER, + selectors: ["#a"], + dispatchScan: () => undefined, + }); + + const built = buildTree(tree); + document.body.append(built.root); + + for (const node of built.nodes) { + const triggered = findTriggeredRules(node); + expect(triggered.has(RULE_FALLBACK)).toBe(true); + } + }), + ); + }); + + it("mixed id+complex rule still fires on the id path when the complex bucket also applies", () => { + fc.assert( + fc.property(flatTreeArb, (tree) => { + __resetSelectorTokenIndexForTesting(); + // The rule has both an id selector and a complex selector — + // it lands in *both* idIndex["a"] and complexFallback, so it + // should always fire (via fallback). The non-complex rule + // is a control: it should only fire when its id is present. + registerRule({ + ruleId: RULE_CHAT, + selectors: ["#a", '[role="dialog"]'], + dispatchScan: () => undefined, + }); + registerRule({ + ruleId: RULE_NEWSLETTER, + selectors: ["#b"], + dispatchScan: () => undefined, + }); + + const built = buildTree(tree); + document.body.append(built.root); + + const rootTriggered = findTriggeredRules(built.root); + expect(rootTriggered.has(RULE_CHAT)).toBe(true); + + const hasB = tree.specs.some((spec) => spec.id === "b"); + expect(rootTriggered.has(RULE_NEWSLETTER)).toBe(hasB); + }), + ); + }); +}); diff --git a/extension/src/lib/__tests__/selector-token-index.test.ts b/extension/src/lib/__tests__/selector-token-index.test.ts new file mode 100644 index 0000000..5928108 --- /dev/null +++ b/extension/src/lib/__tests__/selector-token-index.test.ts @@ -0,0 +1,505 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Tests for the token index that drives subtree dispatch. Two layers: +// - parseSelector / registerRule / findTriggeredRules: pure data, no +// DOM mutations needed. +// - Shared-watcher dispatch: register two rules, mutate the DOM, advance +// the throttle, assert each rule's dispatchScan only fires when its +// tokens (or complex-fallback bucket) match. + +import { + __getIndexSnapshotForTesting, + __resetSelectorTokenIndexForTesting, + findTriggeredRules, + parseSelector, + registerRule, +} from "../selector-token-index"; +import type { RuleId } from "../storage"; +import { __resetSubtreeWatcherForTesting } from "../subtree-watcher"; + +const THROTTLE_MS = 250; +const RULE_A = "footer-redact" as RuleId; +const RULE_B = "comments-redact" as RuleId; +const RULE_C = "reviews-redact" as RuleId; + +async function flushMutations(): Promise { + await Promise.resolve(); +} + +beforeEach(() => { + document.body.innerHTML = ""; + jest.useFakeTimers(); + __resetSelectorTokenIndexForTesting(); + __resetSubtreeWatcherForTesting(); +}); + +afterEach(() => { + jest.useRealTimers(); + __resetSelectorTokenIndexForTesting(); + __resetSubtreeWatcherForTesting(); +}); + +describe("parseSelector", () => { + it("returns kind=id for a bare #identifier", () => { + expect(parseSelector("#hubspot")).toEqual({ + kind: "id", + token: "hubspot", + }); + }); + + it("returns kind=class for a bare .identifier", () => { + expect(parseSelector(".cookie-banner")).toEqual({ + kind: "class", + token: "cookie-banner", + }); + }); + + it("trims surrounding whitespace before matching", () => { + expect(parseSelector(" #foo ")).toEqual({ kind: "id", token: "foo" }); + }); + + it("accepts identifiers containing hyphens, underscores, digits", () => { + expect(parseSelector("#a-b_c-9")).toEqual({ + kind: "id", + token: "a-b_c-9", + }); + expect(parseSelector(".one_two-3")).toEqual({ + kind: "class", + token: "one_two-3", + }); + }); + + it("treats tag selectors as complex (no token bucket)", () => { + expect(parseSelector("footer")).toEqual({ kind: "complex", token: "" }); + expect(parseSelector("div")).toEqual({ kind: "complex", token: "" }); + }); + + it("treats compounds, combinators, attributes, pseudos as complex", () => { + expect(parseSelector("div#foo")).toEqual({ kind: "complex", token: "" }); + expect(parseSelector(".a.b")).toEqual({ kind: "complex", token: "" }); + expect(parseSelector("#foo .child")).toEqual({ + kind: "complex", + token: "", + }); + expect(parseSelector('[role="dialog"]')).toEqual({ + kind: "complex", + token: "", + }); + expect(parseSelector('[id^="sp_message_"]')).toEqual({ + kind: "complex", + token: "", + }); + expect(parseSelector("a:hover")).toEqual({ kind: "complex", token: "" }); + }); + + it("treats wildcard / empty selectors as complex", () => { + expect(parseSelector("*")).toEqual({ kind: "complex", token: "" }); + expect(parseSelector("")).toEqual({ kind: "complex", token: "" }); + }); +}); + +describe("registerRule index population", () => { + it("buckets id and class selectors separately", () => { + registerRule({ + ruleId: RULE_A, + selectors: ["#nav-footer", ".site-footer", "#site-footer"], + dispatchScan: jest.fn(), + }); + + const snap = __getIndexSnapshotForTesting(); + expect(snap.idIndex.get("nav-footer")).toEqual(new Set([RULE_A])); + expect(snap.idIndex.get("site-footer")).toEqual(new Set([RULE_A])); + expect(snap.classIndex.get("site-footer")).toEqual(new Set([RULE_A])); + // No complex selectors among these — the rule should NOT land in + // complexFallback, otherwise we'd over-trigger it on every batch. + expect(snap.complexFallback.has(RULE_A)).toBe(false); + }); + + it("puts rules with any complex selector in the fallback bucket", () => { + registerRule({ + ruleId: RULE_A, + // Mixed: ".foo" is indexable, but "[role=dialog]" is not — we have + // to fall back so the complex selector can still run. + selectors: [".foo", '[role="dialog"]'], + dispatchScan: jest.fn(), + }); + + const snap = __getIndexSnapshotForTesting(); + expect(snap.complexFallback.has(RULE_A)).toBe(true); + expect(snap.classIndex.get("foo")).toEqual(new Set([RULE_A])); + }); + + it("puts a rule with no selectors at all in the fallback bucket", () => { + // Defensive: a rule that registers an empty list (e.g., URL-gated + // siteRules only, no alwaysOnSelectors) must still hear about every + // batch so URL gating can pick up dispatched calls later. + registerRule({ + ruleId: RULE_A, + selectors: [], + dispatchScan: jest.fn(), + }); + + expect(__getIndexSnapshotForTesting().complexFallback.has(RULE_A)).toBe( + true, + ); + }); + + it("merges multiple rules under the same token", () => { + registerRule({ + ruleId: RULE_A, + selectors: [".overlay"], + dispatchScan: jest.fn(), + }); + registerRule({ + ruleId: RULE_B, + selectors: [".overlay"], + dispatchScan: jest.fn(), + }); + + expect(__getIndexSnapshotForTesting().classIndex.get("overlay")).toEqual( + new Set([RULE_A, RULE_B]), + ); + }); + + it("unregister removes the rule from every bucket and the registry", () => { + const cleanup = registerRule({ + ruleId: RULE_A, + selectors: ["#nav-footer", ".site-footer", '[role="dialog"]'], + dispatchScan: jest.fn(), + }); + + cleanup(); + const snap = __getIndexSnapshotForTesting(); + expect(snap.idIndex.size).toBe(0); + expect(snap.classIndex.size).toBe(0); + expect(snap.complexFallback.size).toBe(0); + }); + + it("unregister leaves the other rule sharing the same token intact", () => { + const cleanupA = registerRule({ + ruleId: RULE_A, + selectors: [".overlay"], + dispatchScan: jest.fn(), + }); + registerRule({ + ruleId: RULE_B, + selectors: [".overlay"], + dispatchScan: jest.fn(), + }); + cleanupA(); + + expect(__getIndexSnapshotForTesting().classIndex.get("overlay")).toEqual( + new Set([RULE_B]), + ); + }); + + it("re-registering the same ruleId drops the older entry", () => { + registerRule({ + ruleId: RULE_A, + selectors: ["#one"], + dispatchScan: jest.fn(), + }); + registerRule({ + ruleId: RULE_A, + selectors: ["#two"], + dispatchScan: jest.fn(), + }); + + const snap = __getIndexSnapshotForTesting(); + expect(snap.idIndex.get("one")).toBeUndefined(); + expect(snap.idIndex.get("two")).toEqual(new Set([RULE_A])); + }); +}); + +describe("findTriggeredRules", () => { + it("returns only the complex-fallback rules for a token-less element", () => { + registerRule({ + ruleId: RULE_A, + selectors: [".overlay"], + dispatchScan: jest.fn(), + }); + registerRule({ + ruleId: RULE_B, + selectors: ['[role="dialog"]'], + dispatchScan: jest.fn(), + }); + + const root = document.createElement("div"); + document.body.append(root); + + expect(findTriggeredRules(root)).toEqual(new Set([RULE_B])); + }); + + it("triggers a rule via the element's own id", () => { + registerRule({ + ruleId: RULE_A, + selectors: ["#hubspot"], + dispatchScan: jest.fn(), + }); + + const root = document.createElement("div"); + root.id = "hubspot"; + document.body.append(root); + + expect(findTriggeredRules(root).has(RULE_A)).toBe(true); + }); + + it("triggers a rule via the element's own class", () => { + registerRule({ + ruleId: RULE_A, + selectors: [".overlay"], + dispatchScan: jest.fn(), + }); + + const root = document.createElement("div"); + root.className = "overlay banner"; + document.body.append(root); + + expect(findTriggeredRules(root).has(RULE_A)).toBe(true); + }); + + it("triggers a rule via a descendant's token", () => { + registerRule({ + ruleId: RULE_A, + selectors: [".comment"], + dispatchScan: jest.fn(), + }); + + const root = document.createElement("section"); + const child = document.createElement("article"); + child.className = "comment"; + root.append(child); + document.body.append(root); + + expect(findTriggeredRules(root).has(RULE_A)).toBe(true); + }); + + it("does not trigger a non-matching rule even with descendants present", () => { + registerRule({ + ruleId: RULE_A, + selectors: [".not-here"], + dispatchScan: jest.fn(), + }); + registerRule({ + ruleId: RULE_B, + selectors: ["#also-not-here"], + dispatchScan: jest.fn(), + }); + + const root = document.createElement("div"); + root.className = "unrelated"; + const child = document.createElement("span"); + child.id = "other"; + root.append(child); + document.body.append(root); + + expect(findTriggeredRules(root).size).toBe(0); + }); + + it("collects multiple class tokens on the same element", () => { + registerRule({ + ruleId: RULE_A, + selectors: [".a"], + dispatchScan: jest.fn(), + }); + registerRule({ + ruleId: RULE_B, + selectors: [".b"], + dispatchScan: jest.fn(), + }); + + const root = document.createElement("div"); + root.className = "a b"; + document.body.append(root); + + expect(findTriggeredRules(root)).toEqual(new Set([RULE_A, RULE_B])); + }); + + it("complex-fallback rules trigger even when no descendant has any token", () => { + registerRule({ + ruleId: RULE_A, + selectors: ['[role="contentinfo"]'], + dispatchScan: jest.fn(), + }); + + const root = document.createElement("div"); + document.body.append(root); + + expect(findTriggeredRules(root)).toEqual(new Set([RULE_A])); + }); +}); + +describe("dispatch via the shared subtree watcher", () => { + // End-to-end: register two rules, append elements that should only + // trigger one of them, advance the throttle, and assert each rule's + // dispatchScan fired (or didn't) with the right root. + + it("invokes a rule's dispatchScan with the added subtree root", async () => { + const scanA = jest.fn(); + registerRule({ + ruleId: RULE_A, + selectors: ["#hubspot"], + dispatchScan: scanA, + }); + + const widget = document.createElement("div"); + widget.id = "hubspot"; + document.body.append(widget); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(scanA).toHaveBeenCalledTimes(1); + expect(scanA).toHaveBeenCalledWith(widget); + }); + + it("skips a rule whose tokens do not appear in the added subtree", async () => { + const scanA = jest.fn(); + const scanB = jest.fn(); + registerRule({ + ruleId: RULE_A, + selectors: ["#hubspot"], + dispatchScan: scanA, + }); + registerRule({ + ruleId: RULE_B, + selectors: ["#onetrust"], + dispatchScan: scanB, + }); + + const widget = document.createElement("div"); + widget.id = "hubspot"; + document.body.append(widget); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(scanA).toHaveBeenCalledTimes(1); + expect(scanB).not.toHaveBeenCalled(); + }); + + it("always dispatches to complex-fallback rules on every batch", async () => { + const fallbackScan = jest.fn(); + const tokenScan = jest.fn(); + registerRule({ + ruleId: RULE_A, + selectors: ['[role="dialog"]'], + dispatchScan: fallbackScan, + }); + registerRule({ + ruleId: RULE_B, + selectors: ["#hubspot"], + dispatchScan: tokenScan, + }); + + // Add a node whose id doesn't match RULE_B; RULE_A's complex selector + // can only be checked at scan time, so it must always fire. + const unrelated = document.createElement("div"); + document.body.append(unrelated); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(fallbackScan).toHaveBeenCalledTimes(1); + expect(tokenScan).not.toHaveBeenCalled(); + }); + + it("dispatches to multiple triggered rules with the same root", async () => { + const scanA = jest.fn(); + const scanB = jest.fn(); + const scanC = jest.fn(); + registerRule({ + ruleId: RULE_A, + selectors: [".overlay"], + dispatchScan: scanA, + }); + registerRule({ + ruleId: RULE_B, + selectors: [".overlay"], + dispatchScan: scanB, + }); + registerRule({ + ruleId: RULE_C, + selectors: ["#other"], + dispatchScan: scanC, + }); + + const div = document.createElement("div"); + div.className = "overlay"; + document.body.append(div); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(scanA).toHaveBeenCalledWith(div); + expect(scanB).toHaveBeenCalledWith(div); + expect(scanC).not.toHaveBeenCalled(); + }); + + it("triggers via a descendant token: scan still gets the added root", async () => { + const scanA = jest.fn(); + registerRule({ + ruleId: RULE_A, + selectors: [".comment"], + dispatchScan: scanA, + }); + + const wrapper = document.createElement("section"); + const comment = document.createElement("article"); + comment.className = "comment"; + wrapper.append(comment); + document.body.append(wrapper); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + // The rule's scan receives the *outer* added root — its own QSA + // will descend to find the .comment match. + expect(scanA).toHaveBeenCalledWith(wrapper); + }); + + it("stops dispatching after unregister, even mid-burst", async () => { + const scanA = jest.fn(); + const cleanup = registerRule({ + ruleId: RULE_A, + selectors: [".overlay"], + dispatchScan: scanA, + }); + + cleanup(); + + const div = document.createElement("div"); + div.className = "overlay"; + document.body.append(div); + + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(scanA).not.toHaveBeenCalled(); + }); + + it("body-rooted sweep triggers every registered rule (route-change path)", () => { + const scanA = jest.fn(); + const scanB = jest.fn(); + registerRule({ + ruleId: RULE_A, + selectors: ["#nope-a"], + dispatchScan: scanA, + }); + registerRule({ + ruleId: RULE_B, + selectors: ["#nope-b"], + dispatchScan: scanB, + }); + + // Simulate the shared router's route-change sweep: subscriber + // receives [document.body] regardless of pending mutations. + history.replaceState(null, "", "/route-b"); + globalThis.dispatchEvent(new Event("popstate")); + jest.advanceTimersToNextFrame(); + + expect(scanA).toHaveBeenCalledWith(document.body); + expect(scanB).toHaveBeenCalledWith(document.body); + }); +}); diff --git a/extension/src/lib/selector-hide-rule.ts b/extension/src/lib/selector-hide-rule.ts index 8df56ce..2511eb0 100644 --- a/extension/src/lib/selector-hide-rule.ts +++ b/extension/src/lib/selector-hide-rule.ts @@ -18,8 +18,8 @@ import type { Rule } from "../rules/types"; import { HIDDEN_ATTR, REVEALED_ATTR } from "./dom-markers"; import { filterToOutermost } from "./dom-utils"; import { PLACEHOLDER_CLASS, replaceWithBlockPlaceholder } from "./placeholder"; +import { registerRule as registerWithTokenIndex } from "./selector-token-index"; import type { RuleId } from "./storage"; -import { createSubtreeWatcher } from "./subtree-watcher"; export interface SiteRule { patterns: URLPattern[]; @@ -116,7 +116,21 @@ export function createSelectorHideRule( return; } - let candidates = [...root.querySelectorAll(memoJoined)]; + // Include `root` itself when it's an Element that matches — the + // shared dispatcher now hands us inserted subtree roots directly, + // and querySelectorAll only matches descendants. Without this, + // top-level container insertions (HubSpot's + // #hubspot-messages-iframe-container, OneTrust's + // #onetrust-banner-sdk) would slip through every batch where + // they're the added root. + let candidates: HTMLElement[] = []; + if ( + root.nodeType === Node.ELEMENT_NODE && + (root as Element).matches(memoJoined) + ) { + candidates.push(root as HTMLElement); + } + candidates.push(...root.querySelectorAll(memoJoined)); if (candidateFilter) { candidates = candidates.filter(candidateFilter); } @@ -165,29 +179,38 @@ export function createSelectorHideRule( } } - // When the watcher is enabled, rescan from document.body on every batch - // rather than from the added subtree roots. MutationObserver hands us the - // newly-inserted element itself, but querySelectorAll on that element does - // not match the element itself — so a widget whose top-level container is - // appended directly (e.g., HubSpot's #hubspot-messages-iframe-container) - // would be missed. Scanning from body is idempotent thanks to the - // placeholder-skip in `scan`, and the throttle inside the watcher coalesces - // bursts of mutations into a single pass. - const watcher = watchSubtrees - ? createSubtreeWatcher({ - skipPlaceholderSubtrees: true, - onSubtrees: () => { - scan(document.body); - }, - }) - : null; + // Build the union selector list passed to the token index. Indexing + // siteRule selectors alongside alwaysOn means rules see no surprise + // misses when a URL-gated id/class appears on the current page — + // even though the rule's effective `memoJoined` for that URL may + // include only a subset. Over-trigger cost is bounded: the dispatch + // costs one no-op scan call against the added root, not a full-doc + // QSA against all selectors. + const allSelectors: string[] = [...alwaysOnSelectors]; + for (const siteRule of siteRules) { + allSelectors.push(...siteRule.selectors); + } + + let unregisterFromTokenIndex: (() => void) | null = null; function apply(root: ParentNode): void { scan(root); - watcher?.start(root); + if (watchSubtrees && !unregisterFromTokenIndex) { + // Lazy-register on first apply so module-load doesn't touch the + // document body before the rule engine asks for it. The shared + // dispatcher owns the watcher and fans out per added subtree + // root — this rule's dispatchScan only fires when the token + // index says one of `id` / `class` tokens appeared, or when the + // rule landed in complex-fallback. + unregisterFromTokenIndex = registerWithTokenIndex({ + ruleId: id, + selectors: allSelectors, + dispatchScan: scan, + }); + } } - const rule: Rule = watcher + const rule: Rule = watchSubtrees ? { id, label, @@ -195,7 +218,8 @@ export function createSelectorHideRule( topFrameOnly, apply, teardown: () => { - watcher.stop(); + unregisterFromTokenIndex?.(); + unregisterFromTokenIndex = null; }, } : { id, label, description, topFrameOnly, apply }; diff --git a/extension/src/lib/selector-token-index.ts b/extension/src/lib/selector-token-index.ts new file mode 100644 index 0000000..0798837 --- /dev/null +++ b/extension/src/lib/selector-token-index.ts @@ -0,0 +1,260 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Reverse index from id / class tokens to the rules that contain a +// selector keyed on them. Lets the subtree dispatcher run constant-time +// per-added-node lookup instead of full-document querySelectorAll for +// every rule. +// +// Each selector is parsed into a "primary token" if it reduces to a +// trivial `#id` or `.class` form. Selectors with combinators, compounds, +// attribute filters, or pseudos fall into the complex-fallback bucket — +// every added subtree triggers them, but they're a minority of the +// rule set (footers via `[role="contentinfo"]`, generic role markers). +// +// uBO, Brave Shields, and Ghostery's adblocker all converge on this +// pattern independently: on token-dense scroll feeds it's how cosmetic +// filter engines avoid quadratic dispatch. + +import type { RuleId } from "./storage"; +import type { SubtreeWatcher } from "./subtree-watcher"; +import { createSubtreeWatcher } from "./subtree-watcher"; + +export type SelectorKind = "id" | "class" | "complex"; + +export interface ParsedSelector { + kind: SelectorKind; + // Token value when kind is "id" or "class"; empty when kind is "complex". + token: string; +} + +// Strict shape: bare `#ident` or `.ident` only. Anything with whitespace, +// attribute brackets, combinators, pseudo-classes, multiple compound +// segments, or tag prefixes is complex. +const ID_SELECTOR = /^#([\w-]+)$/; +const CLASS_SELECTOR = /^\.([\w-]+)$/; + +export function parseSelector(selector: string): ParsedSelector { + const trimmed = selector.trim(); + const idMatch = ID_SELECTOR.exec(trimmed); + if (idMatch) { + return { kind: "id", token: idMatch[1] as string }; + } + const classMatch = CLASS_SELECTOR.exec(trimmed); + if (classMatch) { + return { kind: "class", token: classMatch[1] as string }; + } + return { kind: "complex", token: "" }; +} + +interface Registration { + ruleId: RuleId; + dispatchScan: (root: Element) => void; +} + +const idIndex = new Map>(); +const classIndex = new Map>(); +const complexFallback = new Set(); +const registrations = new Map(); + +let sharedWatcher: SubtreeWatcher | null = null; + +function addToBucket( + map: Map>, + key: string, + ruleId: RuleId, +): void { + let bucket = map.get(key); + if (!bucket) { + bucket = new Set(); + map.set(key, bucket); + } + bucket.add(ruleId); +} + +function removeFromBucket(map: Map>, ruleId: RuleId): void { + for (const [key, bucket] of map) { + if (bucket.delete(ruleId) && bucket.size === 0) { + map.delete(key); + } + } +} + +function collectTokens(element: Element, into: Set): void { + if (element.id !== "" && idIndex.size > 0) { + const bucket = idIndex.get(element.id); + if (bucket) { + for (const ruleId of bucket) { + into.add(ruleId); + } + } + } + if (classIndex.size > 0 && element.classList.length > 0) { + for (const cls of element.classList) { + const bucket = classIndex.get(cls); + if (bucket) { + for (const ruleId of bucket) { + into.add(ruleId); + } + } + } + } +} + +// Walk root + its descendants, collecting rule IDs whose tokens (id or +// class) appear on any element in the subtree. Always includes +// complexFallback — attribute/combinator/pseudo selectors can't be +// filtered with the token index, so they have to run against every +// added subtree. +export function findTriggeredRules(root: Element): Set { + const triggered = new Set(complexFallback); + // Walking the added subtree's elements once (rather than N times) + // amortizes the token check across every registered rule — the win the + // index is named for. + collectTokens(root, triggered); + if (idIndex.size > 0 || classIndex.size > 0) { + const descendants = root.querySelectorAll("*"); + for (const descendant of descendants) { + collectTokens(descendant, triggered); + } + } + return triggered; +} + +// Walk added roots, look up triggered rule IDs via the token index, +// and fan out scan calls. Each rule's dispatchScan receives the added +// subtree root — not document.body — so the rule's own querySelectorAll +// runs against just the new subtree. +// +// One special case: when the router signals a full-body sweep (route +// change, see subtree-watcher.handleRouteChange), the root is the body +// itself. Walking every body descendant just to compute the triggered +// set defeats the purpose, so we shortcut to "every registered rule." +function dispatchToRules(roots: Element[]): void { + for (const root of roots) { + const triggered = + root === document.body + ? new Set(registrations.keys()) + : findTriggeredRules(root); + for (const ruleId of triggered) { + const registration = registrations.get(ruleId); + + if (!registration) { + continue; + } + registration.dispatchScan(root); + } + } +} + +function ensureWatcherStarted(): void { + if (sharedWatcher) { + return; + } + sharedWatcher = createSubtreeWatcher({ + skipPlaceholderSubtrees: true, + onSubtrees: dispatchToRules, + }); + sharedWatcher.start(document.body); +} + +function maybeStopWatcher(): void { + if (registrations.size === 0 && sharedWatcher) { + sharedWatcher.stop(); + sharedWatcher = null; + } +} + +export interface RegisterOptions { + ruleId: RuleId; + // Full selector list — the union of `alwaysOnSelectors` and every + // siteRule's `selectors`. Indexed once at registration; URL-gating is + // handled inside the rule's scan, not at dispatch time. Over-triggering + // for off-URL siteRule tokens is OK: the rule's scan will refresh its + // memo, find no matches against the current URL's effective selectors, + // and bail. + selectors: readonly string[]; + // Invoked when the dispatcher decides this rule may match an added + // subtree root. The rule's own scan handles candidateFilter, + // outermost-match, placeholder skips, and URL gating. + dispatchScan: (root: Element) => void; +} + +export function registerRule(options: RegisterOptions): () => void { + const { ruleId, selectors, dispatchScan } = options; + // Duplicate registration shouldn't happen at module load (each rule + // is built once), but tests may re-construct rules. Drop the older + // entry first to keep the index consistent. + if (registrations.has(ruleId)) { + unregisterRule(ruleId); + } + + let hasIdOrClass = false; + let hasComplex = false; + for (const selector of selectors) { + const parsed = parseSelector(selector); + if (parsed.kind === "id") { + addToBucket(idIndex, parsed.token, ruleId); + hasIdOrClass = true; + } else if (parsed.kind === "class") { + addToBucket(classIndex, parsed.token, ruleId); + hasIdOrClass = true; + } else { + hasComplex = true; + } + } + // A rule with no selectors at all (empty alwaysOn, no siteRules) + // still goes into the fallback bucket so a future call site that + // toggles selectors on at runtime doesn't get silently dropped. + if (hasComplex || !hasIdOrClass) { + complexFallback.add(ruleId); + } + + registrations.set(ruleId, { ruleId, dispatchScan }); + ensureWatcherStarted(); + + return () => { + unregisterRule(ruleId); + }; +} + +function unregisterRule(ruleId: RuleId): void { + registrations.delete(ruleId); + complexFallback.delete(ruleId); + removeFromBucket(idIndex, ruleId); + removeFromBucket(classIndex, ruleId); + maybeStopWatcher(); +} + +// Test-only: clear all registrations and stop the shared watcher. Tests +// build per-rule fixtures and would otherwise see indexes leak across +// cases. +export function __resetSelectorTokenIndexForTesting(): void { + idIndex.clear(); + classIndex.clear(); + complexFallback.clear(); + registrations.clear(); + if (sharedWatcher) { + sharedWatcher.stop(); + sharedWatcher = null; + } +} + +// Test-only: introspect index contents. Lets unit tests assert that +// registration produced the right bucket shape without needing to +// trigger a full dispatch round-trip. +export interface IndexSnapshot { + idIndex: ReadonlyMap>; + classIndex: ReadonlyMap>; + complexFallback: ReadonlySet; +} + +export function __getIndexSnapshotForTesting(): IndexSnapshot { + return { + idIndex: new Map([...idIndex].map(([key, value]) => [key, new Set(value)])), + classIndex: new Map( + [...classIndex].map(([key, value]) => [key, new Set(value)]), + ), + complexFallback: new Set(complexFallback), + }; +}