Testing declarativeNetRequest Rules Offline

Verify declarativeNetRequest rulesets without a browser: schema-validating rule files, a small evaluator for urlFilter and regexFilter matching, priority and action precedence tests, rule-count limits, and confirming results with testMatchOutcome.

Published October 2, 2026 Updated October 2, 2026 8 min read
Table of Contents

A content blocker ships 4,000 declarativeNetRequest rules generated from filter lists. A change to the generator flips one allow rule’s priority, and a popular site breaks for every user; another change produces a regexFilter that Chrome rejects, so the whole static ruleset silently fails to load. DNR rules are data, not code, and Chrome evaluates them inside the browser where they are hard to observe. But most mistakes are detectable before the browser ever sees the rules: invalid structure, regexes Chrome won’t accept, rule-count limits, and precedence errors that make the wrong rule win. This guide tests rulesets offline in CI, then confirms the critical cases in the browser. It belongs to unit and integration testing.

What can be tested without Chrome

DNR rules have a well-defined shape: id, priority, action (block, allow, allowAllRequests, redirect, upgradeScheme, modifyHeaders) and condition (urlFilter or regexFilter, domain and initiator lists, resource types, request methods). A JSON schema check catches structural errors. Chrome’s regex engine (RE2) rejects lookaheads and backreferences and limits regex size, which a test can approximate. Precedence follows documented rules: the highest priority wins, and at equal priority allow > allowAllRequests > block > upgradeScheme > redirect. A small evaluator implementing urlFilter syntax and these rules answers “which rule wins for this URL?” for a table of test cases. Chrome’s own testMatchOutcome API then confirms a sample in the real engine.

Offline then online rule testingGenerated rule files are schema-validated, regex-checked against RE2 restrictions, counted against limits, and evaluated against a table of URL test cases with a local matcher; a smaller set of critical cases is confirmed in Chromium with testMatchOutcome.rules/*.jsongeneratedSchema + RE2 checkstructure, regexLimitscounts per rulesetbehaviourLocal evaluatorURL cases → winning ruleGolden tableexpected outcomestestMatchOutcomeChromium, critical cases
Catch most errors in milliseconds; confirm the important ones in the real engine.

Step-by-step: offline DNR tests

1. Validate structure with a schema

 1// tests/dnr-schema.test.js
 2import Ajv from "ajv";
 3import rules from "../rules/blocklist.json" with { type: "json" };
 4const schema = {
 5  type: "array",
 6  items: {
 7    type: "object", required: ["id", "action", "condition"], additionalProperties: false,
 8    properties: {
 9      id: { type: "integer", minimum: 1 },
10      priority: { type: "integer", minimum: 1 },
11      action: { type: "object", required: ["type"], properties: { type: { enum: ["block", "allow", "allowAllRequests", "redirect", "upgradeScheme", "modifyHeaders"] } } },
12      condition: { type: "object", properties: {
13        urlFilter: { type: "string", minLength: 1 }, regexFilter: { type: "string" },
14        resourceTypes: { type: "array", items: { enum: ["main_frame","sub_frame","stylesheet","script","image","font","object","xmlhttprequest","ping","csp_report","media","websocket","webtransport","webbundle","other"] } },
15        initiatorDomains: { type: "array", items: { type: "string" } }, requestDomains: { type: "array", items: { type: "string" } },
16      }, not: { required: ["urlFilter", "regexFilter"] } },
17    },
18  },
19};
20test("blocklist matches the DNR schema", () => {
21  const validate = new Ajv({ allErrors: true, strict: false }).compile(schema);
22  expect(validate(rules), JSON.stringify(validate.errors?.slice(0, 5), null, 2)).toBe(true);
23});
24test("rule ids are unique", () => {
25  expect(new Set(rules.map((r) => r.id)).size).toBe(rules.length);
26});

Execution context: Vitest or Jest in Node. additionalProperties: false catches typos like resourceType that Chrome would reject; the not clause forbids using urlFilter and regexFilter together. Duplicate IDs make a ruleset fail to load. allowAllRequests is only valid with main_frame/sub_frame resource types — add that as a custom check.

2. Check regexes against RE2 restrictions

1import RE2 from "re2";
2test("regexFilters are RE2-compatible and small", () => {
3  for (const r of rules.filter((r) => r.condition.regexFilter)) {
4    expect(() => new RE2(r.condition.regexFilter), `rule ${r.id}`).not.toThrow();
5    expect(r.condition.regexFilter.length, `rule ${r.id}`).toBeLessThan(2000);
6  }
7  expect(rules.filter((r) => r.condition.regexFilter).length).toBeLessThanOrEqual(1000);
8});

Execution context: Node with the re2 package. Chrome compiles regexFilter with RE2, which rejects lookaround and backreferences; compiling with the same library catches them. Chrome also caps regex rules (1,000 per extension) and regex memory; chrome.declarativeNetRequest.isRegexSupported is the authoritative check in the browser. See using regex filters within DNR limits.

Rule errors and where tests catch themDuplicate ids, unknown fields, invalid regex, too many rules, wrong precedence and broken urlFilter syntax, with the offline test that catches each and whether the browser check is still needed.ErrorOffline testBrowser checkDuplicate idsUniqueness testNot neededUnknown fieldsSchemaNot neededInvalid regexRE2 compileisRegexSupportedOver limitsCount testNot neededWrong winnerEvaluator + golden tabletestMatchOutcome
Offline tests catch structure and precedence; the browser confirms the engine agrees.

3. Write a small evaluator for urlFilter and precedence

 1// tests/dnr-eval.js — covers the common subset of urlFilter syntax
 2function urlFilterToRegExp(f) {
 3  let s = f.replace(/[.+?${}()[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\^/g, "(?:[^\\w.%-]|$)");
 4  if (s.startsWith("\\|\\|")) s = "^[a-z]+:\\/\\/(?:[^/]*\\.)?" + s.slice(4);
 5  else if (s.startsWith("\\|")) s = "^" + s.slice(2);
 6  if (s.endsWith("\\|")) s = s.slice(0, -2) + "$";
 7  return new RegExp(s, "i");
 8}
 9const ORDER = { allow: 5, allowAllRequests: 4, block: 3, upgradeScheme: 2, redirect: 1 };
10
11export function evaluate(rules, { url, type = "script", initiator }) {
12  const host = new URL(url).hostname;
13  const matches = rules.filter((r) => {
14    const c = r.condition;
15    if (c.resourceTypes && !c.resourceTypes.includes(type)) return false;
16    if (c.requestDomains && !c.requestDomains.some((d) => host === d || host.endsWith("." + d))) return false;
17    if (c.initiatorDomains && !(initiator && c.initiatorDomains.some((d) => initiator === d || initiator.endsWith("." + d)))) return false;
18    if (c.urlFilter && !urlFilterToRegExp(c.urlFilter).test(url)) return false;
19    if (c.regexFilter && !new RegExp(c.regexFilter).test(url)) return false;
20    return true;
21  });
22  matches.sort((a, b) => (b.priority ?? 1) - (a.priority ?? 1) || ORDER[b.action.type] - ORDER[a.action.type]);
23  return matches[0] ?? null;
24}

Execution context: a test helper in Node. It implements the common urlFilter anchors (|| domain, | start/end, *, ^ separator) and documented precedence. It is not a full reimplementation of Chrome’s matcher — it is a model good enough to test the decisions your ruleset makes, confirmed by step 6. See rule priority and action precedence explained.

A precedence regression caught in CIA generator change lowers the priority of an allow rule for cdn.shop.example; the golden-table test evaluates the URL and finds the block rule now wins; CI fails with the URL, expected allow rule and actual block rule.GeneratorGolden-table testCIrules: allow #812 priority 2 → 1cdn.shop.example/app.js → block #33expected allow #812, got block #33 ✗
Golden tables turn precedence mistakes into readable test failures.

4. Keep a golden table of URL outcomes

 1// tests/dnr-golden.test.js
 2import { evaluate } from "./dnr-eval.js";
 3const CASES = [
 4  { url: "https://ads.tracker.example/pixel.gif", type: "image", initiator: "news.example", expect: { type: "block" } },
 5  { url: "https://cdn.shop.example/app.js", type: "script", initiator: "shop.example", expect: { type: "allow", id: 812 } },
 6  { url: "https://news.example/", type: "main_frame", expect: null },
 7  { url: "http://login.bank.example/", type: "main_frame", expect: { type: "upgradeScheme" } },
 8];
 9test.each(CASES)("$url → $expect.type", ({ expect: want, ...req }) => {
10  const r = evaluate(allRules, req);
11  if (!want) return expect(r).toBeNull();
12  expect(r?.action.type).toBe(want.type);
13  if (want.id) expect(r.id).toBe(want.id);
14});

Execution context: Vitest. The table documents intent: these URLs must be blocked, these allowed, these untouched. Every site-breakage bug report adds a row. When a rule list update changes an outcome, the failure names the URL and the rules involved.

5. Enforce rule-count limits

1test("rulesets stay within limits", () => {
2  const manifest = JSON.parse(readFileSync("dist/chrome/manifest.json", "utf8"));
3  const sets = manifest.declarative_net_request.rule_resources;
4  const enabled = sets.filter((s) => s.enabled).map((s) => JSON.parse(readFileSync(`dist/chrome/${s.path}`, "utf8")).length);
5  expect(sets.length).toBeLessThanOrEqual(100);                  // static rulesets declared
6  expect(sets.filter((s) => s.enabled).length).toBeLessThanOrEqual(50);
7  expect(enabled.reduce((a, b) => a + b, 0)).toBeLessThanOrEqual(30_000);   // guaranteed minimum static rules
8});

Execution context: Vitest against the built output. Chrome guarantees each extension at least 30,000 enabled static rules (more may be available from a shared pool), with caps on declared and enabled rulesets; check current values in the documentation and encode them once. Exceeding them makes rulesets fail to enable.

6. Confirm critical cases in Chromium

1// Playwright, in the service worker of an unpacked build (declarativeNetRequestFeedback permission in test build)
2const outcome = await sw.evaluate((req) => chrome.declarativeNetRequest.testMatchOutcome(req), {
3  url: "https://cdn.shop.example/app.js", type: "script", initiator: "https://shop.example", tabId: -1,
4});
5expect(outcome.matchedRules.map((m) => m.ruleId)).toContain(812);

Execution context: an end-to-end test with the unpacked extension. testMatchOutcome (unpacked extensions only, requires declarativeNetRequestFeedback) runs a hypothetical request through Chrome’s real matcher and returns the matched rules. Running the golden table’s critical rows through it guards against differences between your evaluator and Chrome. See loading an unpacked extension in Playwright.

Common mistakes

  • No tests for generated rules. One generator bug breaks every user.
  • JavaScript regex semantics. Chrome uses RE2; lookaheads fail.
  • Testing precedence by eye. Use a golden table.
  • Ignoring limits. Rulesets silently fail to enable.
  • Trusting the local evaluator alone. Confirm with testMatchOutcome.

Cross-browser variation

  • Chrome / Edge: testMatchOutcome and isRegexSupported available for verification.
  • Firefox: supports DNR with some differences in limits and supported actions; keep a Firefox column in the golden table if behaviour differs.
  • Safari: supports DNR with its own limits and fewer features; validate the subset you ship.

Verification

  1. Introduce a lookahead regex and confirm the RE2 test fails.
  2. Swap two priorities and confirm the golden table reports the wrong winner.
  3. Duplicate an ID and confirm the uniqueness test fails.
  4. Run the testMatchOutcome subset and confirm it matches the local evaluator.

FAQ

Can I run Chrome’s real matcher in Node?

No. Use testMatchOutcome in a browser for authoritative checks.

Should dynamic rules be tested the same way?

Yes — generate them in a testable function and run the same schema, limit and golden-table tests.

How many golden cases are enough?

One per rule category plus every regression you’ve ever shipped. Dozens is typical.

Does the evaluator need to support every condition?

Only the conditions your rules use. Add support (and tests for the evaluator itself) when a rule starts using a new condition such as requestMethods or excludedInitiatorDomains.

Other Testing, Debugging & Performance Optimization Resources