Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions extension/src/lib/RuleList.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
// Copyright (c) 2026 PixieBrix, Inc.
// Licensed under PolyForm Shield 1.0.0 — see LICENSE.

import type { Rule, RuleId } from "../rules";
import type { CatalogRule, RuleId } from "../rules";
import { RULES } from "../rules";
import type { RuleAvailabilityStates } from "./availability";
import { RULE_GROUPS } from "./rule-groups";
import type { RuleStates } from "./storage";
import { setRuleEnabled } from "./storage";

const RULES_BY_ID = new Map<RuleId, Rule>(RULES.map((rule) => [rule.id, rule]));
const RULES_BY_ID = new Map<RuleId, CatalogRule>(
RULES.map((rule) => [rule.id, rule]),
);

// The catalog invariant test (`places every rule in exactly one group`)
// guarantees every group id resolves; this throws loudly if anyone ever
// breaks that invariant by adding a rule without updating `RULE_GROUPS`.
function ruleById(id: RuleId): Rule {
function ruleById(id: RuleId): CatalogRule {
const rule = RULES_BY_ID.get(id);
if (!rule) {
throw new Error(`Rule ${id} listed in RULE_GROUPS but not in RULES`);
Expand Down Expand Up @@ -43,7 +45,7 @@ export function RuleList({
{group.ruleIds.map((ruleId) => {
const rule = ruleById(ruleId);
const snapshot = availability[rule.id];
const unavailable = !snapshot?.available;
const unavailable = !snapshot.available;
return (
<li
key={rule.id}
Expand All @@ -65,7 +67,7 @@ export function RuleList({
<span className="badge">Unavailable</span>
)}
</strong>
{unavailable && snapshot?.reason && (
{unavailable && snapshot.reason && (
<p className="unavailable-reason">{snapshot.reason}</p>
)}
<p>{rule.description}</p>
Expand Down
52 changes: 33 additions & 19 deletions extension/src/lib/__tests__/rule-engine.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* @jest-environment jsdom
*/
import type { Rule } from "../../rules/types";
import type { AvailabilitySnapshot, Rule } from "../../rules/types";

// Storage and RULES are mocked because the real engine pulls in the full rule
// catalog (and via that, the EasyList stylesheet — multi-MB of CSS text). The
Expand Down Expand Up @@ -79,8 +79,19 @@ import { subscribeEffectiveEnforcement } from "../effective-enforcement";
import { isTopFrame } from "../frame";
import { revealAll } from "../placeholder";
import { start } from "../rule-engine";
import type { RuleStates } from "../storage";
import { getRuleStates, subscribe } from "../storage";

// The engine is unit-tested against a synthetic 3-rule catalog (FAKE_RULES), so
// these fixtures use ids outside the real `RuleId` union. Route them through
// these helpers to satisfy the strict `RuleStates` / `RuleAvailabilityStates`
// shapes without scattering casts at every call site.
const asStates = (states: Record<string, boolean>): RuleStates =>
states as RuleStates;
const asAvailability = (
availability: Record<string, AvailabilitySnapshot>,
): RuleAvailabilityStates => availability as RuleAvailabilityStates;

const getRuleStatesMock = getRuleStates as jest.MockedFunction<
typeof getRuleStates
>;
Expand All @@ -99,17 +110,17 @@ const subscribeAvailabilityMock =
>;
const revealAllMock = revealAll as jest.MockedFunction<typeof revealAll>;

const ALL_AVAILABLE: RuleAvailabilityStates = {
const ALL_AVAILABLE = asAvailability({
"all-frame-rule": { available: true },
"top-only-rule": { available: true },
"unavailable-rule": { available: false, reason: "unavailable" },
};
});

const allEnabled = {
const allEnabled = asStates({
"all-frame-rule": true,
"top-only-rule": true,
"unavailable-rule": true,
};
});

function setFrame(isTop: boolean): void {
// jsdom locks `window.top` as a non-configurable getter, so we mock the
Expand Down Expand Up @@ -182,10 +193,9 @@ describe("rule engine — reconciliation", () => {
}

it("applies a rule when storage flips it on", async () => {
getRuleStatesMock.mockResolvedValue({
...allEnabled,
"all-frame-rule": false,
});
getRuleStatesMock.mockResolvedValue(
asStates({ ...allEnabled, "all-frame-rule": false }),
);
const { onStorageChange } = await startAndCaptureListeners();
expect(allFrameRule.apply).not.toHaveBeenCalled();

Expand All @@ -199,7 +209,7 @@ describe("rule engine — reconciliation", () => {
const { onStorageChange } = await startAndCaptureListeners();
expect(allFrameRule.apply).toHaveBeenCalledTimes(1);

onStorageChange({ ...allEnabled, "all-frame-rule": false });
onStorageChange(asStates({ ...allEnabled, "all-frame-rule": false }));

expect(revealAllMock).toHaveBeenCalledWith("all-frame-rule");
expect(allFrameRule.teardown).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -243,10 +253,12 @@ describe("rule engine — reconciliation", () => {
const { onAvailabilityChange } = await startAndCaptureListeners();
expect(allFrameRule.apply).toHaveBeenCalledTimes(1);

onAvailabilityChange({
...ALL_AVAILABLE,
"all-frame-rule": { available: false, reason: "now unavailable" },
});
onAvailabilityChange(
asAvailability({
...ALL_AVAILABLE,
"all-frame-rule": { available: false, reason: "now unavailable" },
}),
);

expect(allFrameRule.teardown).toHaveBeenCalledTimes(1);
});
Expand All @@ -256,10 +268,12 @@ describe("rule engine — reconciliation", () => {
// unavailable-rule started unavailable → never applied at start().
expect(unavailableRule.apply).not.toHaveBeenCalled();

onAvailabilityChange({
...ALL_AVAILABLE,
"unavailable-rule": { available: true },
});
onAvailabilityChange(
asAvailability({
...ALL_AVAILABLE,
"unavailable-rule": { available: true },
}),
);

expect(unavailableRule.apply).toHaveBeenCalledTimes(1);
});
Expand Down Expand Up @@ -288,7 +302,7 @@ describe("rule engine — missing document.body", () => {
(allFrameRule.teardown as jest.Mock).mockClear();

document.body.remove();
onStorageChange({ ...allEnabled, "all-frame-rule": false });
onStorageChange(asStates({ ...allEnabled, "all-frame-rule": false }));

expect(allFrameRule.apply).not.toHaveBeenCalled();
expect(allFrameRule.teardown).not.toHaveBeenCalled();
Expand Down
43 changes: 25 additions & 18 deletions extension/src/lib/__tests__/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
// the freshly-derived defaults instead of any stale stored value from a
// previous case.

import type { RuleId } from "../../rules/rule-metadata";
import { RULE_DEFAULTS as RAW_RULE_DEFAULTS } from "../../rules/rule-metadata";
import type * as Storage from "../storage";

Expand Down Expand Up @@ -66,10 +67,10 @@ describe("storage default states", () => {
// defaults happen to lean.
const trueRule = Object.entries(RULE_DEFAULTS).find(
([, value]) => value,
)?.[0];
)?.[0] as RuleId | undefined;
const falseRule = Object.entries(RULE_DEFAULTS).find(
([, value]) => !value,
)?.[0];
)?.[0] as RuleId | undefined;
if (trueRule === undefined || falseRule === undefined) {
throw new Error(
"Expected RULE_DEFAULTS to include at least one true and one false default for this test.",
Expand All @@ -88,7 +89,7 @@ describe("storage default states", () => {
if (id === trueRule || id === falseRule) {
continue;
}
expect(states[id]).toBe(value);
expect(states[id as RuleId]).toBe(value);
}
});

Expand All @@ -100,7 +101,7 @@ describe("storage default states", () => {
});

it("ignores non-boolean override values without throwing", async () => {
const someRule = Object.keys(RULE_DEFAULTS)[0] as string;
const someRule = Object.keys(RULE_DEFAULTS)[0] as RuleId;
process.env.EXTENSION_DEFAULT_OVERRIDES = JSON.stringify({
[someRule]: "yes",
});
Expand Down Expand Up @@ -136,8 +137,8 @@ describe("storage default states", () => {
describe("normalize via getRuleStates", () => {
// Pick stable rule ids off the live catalog so the test doesn't tie itself
// to the iteration order of RULE_DEFAULTS.
function pickRuleIds(): { first: string; second: string } {
const ids = Object.keys(RULE_DEFAULTS);
function pickRuleIds(): { first: RuleId; second: RuleId } {
const ids = Object.keys(RULE_DEFAULTS) as RuleId[];
const first = ids[0];
const second = ids[1];
if (!first || !second) {
Expand All @@ -164,7 +165,11 @@ describe("normalize via getRuleStates", () => {
it("fills in missing rule ids from defaults when stored state is partial", async () => {
const { getRuleStates, ruleStatesStorage } = await loadStorage();
const { first } = pickRuleIds();
await ruleStatesStorage.set({ [first]: !RULE_DEFAULTS[first] });
// A single-key object types as `{ [x: string]: boolean }`; cast to the full
// shape (the stub stores it verbatim, then `normalize()` fills the rest).
await ruleStatesStorage.set({
[first]: !RULE_DEFAULTS[first],
} as Storage.RuleStates);

const states = await getRuleStates();
expect(states[first]).toBe(!RULE_DEFAULTS[first]);
Expand All @@ -173,15 +178,15 @@ describe("normalize via getRuleStates", () => {
if (id === first) {
continue;
}
expect(states[id]).toBe(value);
expect(states[id as RuleId]).toBe(value);
}
});
});

describe("setRuleEnabled", () => {
it("flips one rule while preserving the others", async () => {
const { getRuleStates, setRuleEnabled } = await loadStorage();
const target = Object.keys(RULE_DEFAULTS)[0];
const target = Object.keys(RULE_DEFAULTS)[0] as RuleId | undefined;
if (!target) {
throw new Error("RULE_DEFAULTS must have at least one entry");
}
Expand All @@ -195,13 +200,13 @@ describe("setRuleEnabled", () => {
if (id === target) {
continue;
}
expect(states[id]).toBe(value);
expect(states[id as RuleId]).toBe(value);
}
});

it("notifies subscribers when state changes", async () => {
const { setRuleEnabled, subscribe } = await loadStorage();
const target = Object.keys(RULE_DEFAULTS)[0];
const target = Object.keys(RULE_DEFAULTS)[0] as RuleId | undefined;
if (!target) {
throw new Error("RULE_DEFAULTS must have at least one entry");
}
Expand All @@ -220,7 +225,7 @@ describe("setRuleEnabled", () => {
describe("setAllRuleStates", () => {
it("normalizes partial input before writing — missing ids fill from defaults", async () => {
const { getRuleStates, setAllRuleStates } = await loadStorage();
const target = Object.keys(RULE_DEFAULTS)[0];
const target = Object.keys(RULE_DEFAULTS)[0] as RuleId | undefined;
if (!target) {
throw new Error("RULE_DEFAULTS must have at least one entry");
}
Expand All @@ -233,22 +238,24 @@ describe("setAllRuleStates", () => {
if (id === target) {
continue;
}
expect(states[id]).toBe(value);
expect(states[id as RuleId]).toBe(value);
}
});

it("drops non-boolean values via normalize before writing", async () => {
const { getRuleStates, setAllRuleStates } = await loadStorage();
const target = Object.keys(RULE_DEFAULTS)[0];
const target = Object.keys(RULE_DEFAULTS)[0] as RuleId | undefined;
if (!target) {
throw new Error("RULE_DEFAULTS must have at least one entry");
}

// Cast to bypass the typed shape — normalize() inside setAllRuleStates
// is responsible for dropping the corrupt string value.
// `"junk"` is a deliberately corrupt (non-boolean) value; normalize()
// inside setAllRuleStates is responsible for dropping it. (A computed
// `[target]` key widens the literal away, so this needs no cast to pass the
// `Partial<RuleStates>` shape.)
await setAllRuleStates({
[target]: "junk",
} as unknown as Partial<Storage.RuleStates>);
});

// junk replaced with default → stored map equals defaults.
await expect(getRuleStates()).resolves.toEqual(RULE_DEFAULTS);
Expand All @@ -258,7 +265,7 @@ describe("setAllRuleStates", () => {
describe("subscribe", () => {
it("stops invoking the listener after the returned cleanup runs", async () => {
const { setRuleEnabled, subscribe } = await loadStorage();
const target = Object.keys(RULE_DEFAULTS)[0];
const target = Object.keys(RULE_DEFAULTS)[0] as RuleId | undefined;
if (!target) {
throw new Error("RULE_DEFAULTS must have at least one entry");
}
Expand Down
4 changes: 3 additions & 1 deletion extension/src/lib/availability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ export async function getRuleAvailabilityStates(): Promise<RuleAvailabilityState
async (rule) => [rule.id, await resolveAvailability(rule)] as const,
),
);
return Object.fromEntries(entries);
// Entries cover every `RuleId` (keyed off `RULES`); `Object.fromEntries`
// widens the key back to `string`, so cast to the exact map shape.
return Object.fromEntries(entries) as RuleAvailabilityStates;
}

// Subscribe to changes in any rule's reactive availability. The listener is
Expand Down
9 changes: 6 additions & 3 deletions extension/src/lib/background/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
// doesn't own — session-store writes and the content broadcast (content scripts
// can't observe the session area, so the background bridges every change).

// `rules/rule-metadata` (not `rules/index`) keeps this worker module free of
// rule-file DOM access — see `check-background-purity.ts`.
import type { RuleId } from "../../rules/rule-metadata";
import { debugTraceStorage } from "../debug-trace";
import {
appendEvent as appendDebugTraceEvent,
Expand All @@ -32,7 +35,7 @@ const DETECTION_KIND_TO_RULE_ID = {
"roach-motel": "roach-motel-annotate",
"webdriver-probe": "webdriver-probe-annotate",
"closed-shadow-root": "closed-shadow-root-annotate",
} as const satisfies Record<DetectionKind, string>;
} as const satisfies Record<DetectionKind, RuleId>;

export function startBackgroundLifecycle(tracker: TabTracker): void {
chrome.tabs.onRemoved.addListener((tabId) => {
Expand Down Expand Up @@ -199,9 +202,9 @@ export function startBackgroundLifecycle(tracker: TabTracker): void {
}
for (const [kind, ruleId] of Object.entries(DETECTION_KIND_TO_RULE_ID) as [
DetectionKind,
string,
RuleId,
][]) {
if (previous[ruleId] === true && next[ruleId] === false) {
if (previous[ruleId] && !next[ruleId]) {
tracker.clearDetectionsOfKind(kind);
}
}
Expand Down
20 changes: 13 additions & 7 deletions extension/src/lib/inline-text-redact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,11 @@ import type { RuleId } from "./storage";
import { createSubtreeWatcher } from "./subtree-watcher";
import { walkTextNodeGroupsChunked } from "./yielding-text-walk";

export interface InlineTextRedactRuleOptions {
id: RuleId;
export interface InlineTextRedactRuleOptions<Id extends RuleId = RuleId> {
// Generic over the literal id so `rule.id` keeps its narrow `RuleId` literal
// (inferred from the call site) rather than widening to `RuleId`. The rule
// catalog's compile-time agreement check in `rules/index.ts` relies on it.
id: Id;
label: string;
description: string;
// Skip text nodes shorter than this — cheap per-node early-out before
Expand All @@ -56,11 +59,14 @@ export interface InlineTextRedactRuleOptions {
// Tighter than `Rule` — the factory always installs a teardown, so callers
// (notably tests that call `rule.teardown()` directly in `afterEach`) don't
// need to widen with `?.()` or assertion.
export type InlineTextRedactRule = Rule & { teardown: () => void };
export type InlineTextRedactRule<Id extends RuleId = RuleId> = Rule & {
id: Id;
teardown: () => void;
};

export function defineInlineTextRedactRule(
options: InlineTextRedactRuleOptions,
): InlineTextRedactRule {
export function defineInlineTextRedactRule<Id extends RuleId>(
options: InlineTextRedactRuleOptions<Id>,
): InlineTextRedactRule<Id> {
const { id, label, description, minLength, collectMatches } = options;

const lifecycle = new ReusableAbortController();
Expand Down Expand Up @@ -110,7 +116,7 @@ export function defineInlineTextRedactRule(
unsubscribeRouteChange?.();
unsubscribeRouteChange = null;
},
} satisfies InlineTextRedactRule;
} satisfies InlineTextRedactRule<Id>;
}

// Bucket a chunk of group-tagged text nodes into runs of consecutive
Expand Down
2 changes: 1 addition & 1 deletion extension/src/lib/page-world-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function ruleEnabledAndEnforced(ruleId: RuleId): () => Promise<boolean> {
getRuleStates(),
getEnforcementEnabled(),
]);
return enforcementEnabled && Boolean(states[ruleId]);
return enforcementEnabled && states[ruleId];
};
}

Expand Down
Loading
Loading