adblock-rs
Version:
Very fast, Rust-based, native implementation of ad-blocker engine for Node
687 lines (609 loc) • 28.7 kB
JavaScript
/**
* Integration tests for the adblock-rs native Node.js addon (js/index.js).
*
* These tests document the current behaviour of the Neon binding layer and
* serve as a regression baseline for the NAPI-RS migration.
*
* Run: node --test js/test/bindings.test.mjs
* (requires the native addon to be built first: npm run build)
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const { FilterSet, Engine, FilterFormat, RuleTypes, uBlockResources } =
createRequire(import.meta.url)(join(__dirname, '..', 'index.js'));
// ---------------------------------------------------------------------------
// FilterSet.addFilters
// ---------------------------------------------------------------------------
describe('FilterSet.addFilters', () => {
it('parses metadata comments (title, homepage, expires, redirect)', () => {
const fs = new FilterSet();
const { source_index, metadata } = fs.addFilters([
'! Title: Test List',
'! Homepage: https://example.com',
'! Expires: 2 days',
'! Redirect: https://example.com/new-list.txt',
'||ads.com^',
].join('\n'));
assert.equal(metadata.title, 'Test List');
assert.equal(source_index, 0);
assert.equal(metadata.homepage, 'https://example.com');
assert.ok(metadata.expires != null);
assert.equal(metadata.redirect, 'https://example.com/new-list.txt');
});
it('hosts format parses IP-hostname entries', () => {
const fs = new FilterSet();
fs.addFilters('127.0.0.1 ads.example.com', { format: FilterFormat.HOSTS });
const engine = new Engine(fs);
assert.equal(
engine.check('https://ads.example.com/', 'https://pub.com', 'script'),
true,
);
});
it('NETWORK_ONLY skips cosmetic rules', () => {
const fs = new FilterSet();
fs.addFilters(
['||example.com^', 'example.com##.ad'].join('\n'),
{ rule_types: RuleTypes.NETWORK_ONLY },
);
const engine = new Engine(fs);
assert.equal(
engine.check('https://example.com/test.js', 'https://pub.com', 'script'),
true,
);
const cosmetic = engine.urlCosmeticResources('https://example.com/page');
assert.equal(cosmetic.hide_selectors.length, 0);
});
it("Legacy addFilters syntax works", () => {
const fs = new FilterSet();
fs.addFilters(["||example.com^", "example.com##.ad"], {
rule_types: RuleTypes.NETWORK_ONLY,
});
const engine = new Engine(fs);
assert.equal(
engine.check(
"https://example.com/test.js",
"https://pub.com",
"script",
),
true,
);
});
it('COSMETIC_ONLY skips network rules', () => {
const fs = new FilterSet();
fs.addFilters(
['||example.com^', 'example.com##.ad'].join('\n'),
{ rule_types: RuleTypes.COSMETIC_ONLY },
);
const engine = new Engine(fs);
assert.equal(
engine.check('https://example.com/test.js', 'https://pub.com', 'script'),
false,
);
const cosmetic = engine.urlCosmeticResources('https://example.com/page');
assert.ok(cosmetic.hide_selectors.includes('.ad'));
});
});
// ---------------------------------------------------------------------------
// FilterSet.intoContentBlocking
// ---------------------------------------------------------------------------
describe('FilterSet.intoContentBlocking', () => {
it('converts network filters to content blocking rules with trigger/action', () => {
const fs = new FilterSet(true);
fs.addFilters('||ads.example.com^');
const result = fs.intoContentBlocking();
assert.notEqual(result, undefined);
assert.ok(result.contentBlockingRules.length > 0);
assert.ok('trigger' in result.contentBlockingRules[0]);
assert.ok('action' in result.contentBlockingRules[0]);
assert.ok(result.filtersUsed.includes('||ads.example.com^'));
});
it('returns undefined when debug=false', () => {
const fs = new FilterSet(false);
fs.addFilters('||ads.example.com^');
assert.equal(fs.intoContentBlocking(), undefined);
});
});
// ---------------------------------------------------------------------------
// FilterSet clone semantics
// ---------------------------------------------------------------------------
describe('FilterSet survives Engine construction (clone semantics)', () => {
it('FilterSet is still usable after being passed to Engine constructor', () => {
const fs = new FilterSet();
fs.addFilters('||example.com^');
const _engine = new Engine(fs);
assert.doesNotThrow(() => fs.addFilters('||another.com^'));
});
});
// ---------------------------------------------------------------------------
// Engine.check — basic blocking
// ---------------------------------------------------------------------------
describe('Engine.check — basic blocking', () => {
it('blocks matching requests, allows non-matching', () => {
const fs = new FilterSet();
fs.addFilters('||ads.example.com^');
const engine = new Engine(fs);
assert.equal(engine.check('https://ads.example.com/t.js', 'https://pub.com', 'script'), true);
assert.equal(engine.check('https://safe.com/t.js', 'https://pub.com', 'script'), false);
});
it('debug=true returns BlockerResult with filter text', () => {
const fs = new FilterSet(true);
fs.addFilters('||ads.example.com^');
const engine = new Engine(fs);
const result = engine.check(
'https://ads.example.com/t.js', 'https://pub.com', 'script', 'get', true
);
assert.equal(result.should_block, true);
assert.deepEqual(result.filter, {
raw_line: '||ads.example.com^',
source_location: {
line_number: 0,
source_index: 0
}
});
});
it('throws for an invalid URL', () => {
const engine = new Engine(new FilterSet());
assert.throws(() => engine.check('not a url', 'https://publisher.com', 'script'));
});
it('ignores legacy optimize constructor argument', () => {
const fs = new FilterSet();
fs.addFilters('||blocked.com^');
const engine = new Engine(fs, true);
assert.equal(
engine.check('https://blocked.com/img.png', 'https://pub.com', 'image'),
true,
);
});
});
// ---------------------------------------------------------------------------
// Engine.check — exception rules
// ---------------------------------------------------------------------------
describe('Engine.check — exception rules', () => {
it('exception rule prevents blocking, populates exception field', () => {
const fs = new FilterSet(true);
fs.addFilters(['||ads.example.com^', '@@||ads.example.com^$domain=publisher.com'].join('\n'));
const engine = new Engine(fs);
const result = engine.check(
'https://ads.example.com/tracker.js', 'https://publisher.com', 'script', 'get', true,
);
assert.equal(result.should_block, false);
assert.deepEqual(result.exception, {
raw_line: '@@||ads.example.com^$domain=publisher.com',
source_location: {
line_number: 1,
source_index: 0
}
});
});
it('$important overrides exception rules', () => {
const fs = new FilterSet(true);
fs.addFilters(['||ads.example.com^$important', '@@||ads.example.com^'].join('\n'));
const engine = new Engine(fs);
const result = engine.check(
'https://ads.example.com/t.js', 'https://pub.com', 'script', 'get', true,
);
assert.equal(result.should_block, true);
assert.equal(result.important, true);
assert.deepEqual(result.filter, {
raw_line: '||ads.example.com^$important',
source_location: {
line_number: 0,
source_index: 0
}
});
});
});
// ---------------------------------------------------------------------------
// Engine.check — $third-party and $1p modifiers
// ---------------------------------------------------------------------------
describe('Engine.check — $third-party and $1p modifiers', () => {
it('$third-party rule blocks 3p, allows 1p', () => {
const fs = new FilterSet();
fs.addFilters('||tracker.com^$third-party');
const engine = new Engine(fs);
assert.equal(engine.check('https://tracker.com/t.js', 'https://other.com', 'script'), true);
assert.equal(engine.check('https://tracker.com/t.js', 'https://tracker.com', 'script'), false);
});
it('$1p rule blocks 1p, allows 3p', () => {
const fs = new FilterSet();
fs.addFilters('/bad-path$1p');
const engine = new Engine(fs);
assert.equal(engine.check('https://example.com/bad-path', 'https://example.com', 'script'), true);
assert.equal(engine.check('https://example.com/bad-path', 'https://other.com', 'script'), false);
});
});
// ---------------------------------------------------------------------------
// Engine.check — type-specific rules
// ---------------------------------------------------------------------------
describe('Engine.check — type-specific rules', () => {
it('$script blocks only script requests', () => {
const fs = new FilterSet();
fs.addFilters('||ads.example.com^$script');
const engine = new Engine(fs);
assert.equal(engine.check('https://ads.example.com/t.js', 'https://pub.com', 'script'), true);
assert.equal(engine.check('https://ads.example.com/t.png', 'https://pub.com', 'image'), false);
});
it('$image blocks only image requests', () => {
const fs = new FilterSet();
fs.addFilters('||ads.example.com^$image');
const engine = new Engine(fs);
assert.equal(engine.check('https://ads.example.com/t.png', 'https://pub.com', 'image'), true);
assert.equal(engine.check('https://ads.example.com/t.js', 'https://pub.com', 'script'), false);
});
it('$stylesheet blocks only stylesheet requests', () => {
const fs = new FilterSet();
fs.addFilters('||ads.example.com^$stylesheet');
const engine = new Engine(fs);
assert.equal(engine.check('https://ads.example.com/s.css', 'https://pub.com', 'stylesheet'), true);
assert.equal(engine.check('https://ads.example.com/s.js', 'https://pub.com', 'script'), false);
});
it('$xmlhttprequest blocks only XHR requests', () => {
const fs = new FilterSet();
fs.addFilters('||ads.example.com^$xmlhttprequest');
const engine = new Engine(fs);
assert.equal(engine.check('https://ads.example.com/api', 'https://pub.com', 'xmlhttprequest'), true);
assert.equal(engine.check('https://ads.example.com/api', 'https://pub.com', 'image'), false);
});
it('$method=post blocks only POST requests', () => {
const fs = new FilterSet();
fs.addFilters('||api.example.com^$xhr,method=post');
const engine = new Engine(fs);
const url = 'https://api.example.com/collect';
const source = 'https://pub.com';
assert.equal(engine.check(url, source, 'xhr', 'post'), true);
assert.equal(engine.check(url, source, 'xhr', 'get'), false);
assert.equal(engine.check(url, source, 'xhr'), false);
});
it('debug=true remains backward compatible as 4th boolean arg', () => {
const fs = new FilterSet(true);
fs.addFilters('||api.example.com^$xhr,method=post');
const engine = new Engine(fs, true);
const result = engine.check(
'https://api.example.com/collect', 'https://pub.com', 'xhr', true,
);
assert.equal(result.should_block, false);
});
});
// ---------------------------------------------------------------------------
// Engine.check — $domain modifier
// ---------------------------------------------------------------------------
describe('Engine.check — $domain modifier', () => {
it('blocks only when source matches the domain option', () => {
const fs = new FilterSet();
fs.addFilters('/ads.js$domain=publisher.com');
const engine = new Engine(fs);
assert.equal(engine.check('https://cdn.example.com/ads.js', 'https://publisher.com', 'script'), true);
assert.equal(engine.check('https://cdn.example.com/ads.js', 'https://other.com', 'script'), false);
});
it('~domain excludes the specified domain', () => {
const fs = new FilterSet();
fs.addFilters('/ads.js$domain=~safe.com');
const engine = new Engine(fs);
assert.equal(engine.check('https://cdn.example.com/ads.js', 'https://safe.com', 'script'), false);
assert.equal(engine.check('https://cdn.example.com/ads.js', 'https://other.com', 'script'), true);
});
});
// ---------------------------------------------------------------------------
// Engine.check — $badfilter modifier
// ---------------------------------------------------------------------------
describe('Engine.check — $badfilter modifier', () => {
it('cancels a matching blocking rule', () => {
const fs = new FilterSet();
fs.addFilters(['||ads.example.com^', '||ads.example.com^$badfilter'].join('\n'));
const engine = new Engine(fs);
assert.equal(engine.check('https://ads.example.com/t.js', 'https://pub.com', 'script'), false);
});
it('does not cancel a dissimilar rule', () => {
const fs = new FilterSet();
fs.addFilters(['||ads.example.com^', '||other.com^$badfilter'].join('\n'));
const engine = new Engine(fs);
assert.equal(engine.check('https://ads.example.com/t.js', 'https://pub.com', 'script'), true);
});
});
// ---------------------------------------------------------------------------
// Engine.check — redirect rules
// ---------------------------------------------------------------------------
describe('Engine.check — redirect rules', () => {
it('redirect field is set when $redirect rule matches and resource is loaded', () => {
const fs = new FilterSet(true);
fs.addFilters('||ads.example.com^$script,redirect=noopjs');
const engine = new Engine(fs);
engine.useResources([{
name: 'noopjs',
aliases: [],
kind: { mime: 'application/javascript' },
content: btoa('(function(){})()'),
}]);
const result = engine.check(
'https://ads.example.com/t.js', 'https://pub.com', 'script', 'get', true,
);
assert.equal(result.should_block, true);
assert.deepEqual(result.filter, {
raw_line: '||ads.example.com^$script,redirect=noopjs',
source_location: {
line_number: 0,
source_index: 0
}
});
assert.ok(result.redirect.length > 0);
});
it('redirect is null when no redirect rule applies', () => {
const fs = new FilterSet(true);
fs.addFilters('||ads.example.com^');
const engine = new Engine(fs);
const result = engine.check(
'https://ads.example.com/t.js', 'https://pub.com', 'script', 'get', true,
);
assert.equal(result.should_block, true);
assert.deepEqual(result.filter, {
raw_line: '||ads.example.com^',
source_location: {
line_number: 0,
source_index: 0
}
});
assert.ok(result.redirect == null);
});
});
// ---------------------------------------------------------------------------
// Engine.check — $removeparam modifier
// ---------------------------------------------------------------------------
describe('Engine.check — $removeparam modifier', () => {
it('strips the specified parameter, preserves others', () => {
const fs = new FilterSet(true);
fs.addFilters('||example.com^$removeparam=tracking_id');
const engine = new Engine(fs);
const result = engine.check(
'https://example.com/page?tracking_id=abc&keep=1',
'https://other.com', 'xmlhttprequest', 'get', true,
);
assert.ok(result.rewritten_url != null);
assert.ok(!result.rewritten_url.includes('tracking_id'));
assert.ok(result.rewritten_url.includes('keep=1'));
});
it('rewritten_url is null when the parameter is absent', () => {
const fs = new FilterSet(true);
fs.addFilters('||example.com^$removeparam=tracking_id');
const engine = new Engine(fs);
const result = engine.check(
'https://example.com/page?unrelated=1',
'https://other.com', 'xmlhttprequest', 'get', true,
);
assert.ok(result.rewritten_url == null);
});
});
// ---------------------------------------------------------------------------
// Engine.check — exception rules with tags
// ---------------------------------------------------------------------------
describe('Engine.check — exception rules with tags', () => {
it('tagged exception activates only after enableTag', () => {
const fs = new FilterSet(true);
fs.addFilters(['||ads.example.com^', '@@||ads.example.com^$tag=unbreak'].join('\n'));
const engine = new Engine(fs);
const before = engine.check(
'https://ads.example.com/t.js', 'https://pub.com', 'script', 'get', true,
);
assert.equal(before.should_block, true);
assert.ok(before.exception == null);
engine.enableTag('unbreak');
const after = engine.check(
'https://ads.example.com/t.js', 'https://pub.com', 'script', 'get', true,
);
assert.equal(after.should_block, false);
assert.deepEqual(after.exception, {
raw_line: '@@||ads.example.com^$tag=unbreak',
source_location: {
line_number: 1,
source_index: 0
}
});
});
});
// ---------------------------------------------------------------------------
// Engine.urlCosmeticResources
// ---------------------------------------------------------------------------
describe('Engine.urlCosmeticResources', () => {
it('returns matching hide_selectors for the URL', () => {
const fs = new FilterSet();
fs.addFilters(['example.com##.ad-banner', 'example.com##.sponsored-post'].join('\n'));
const engine = new Engine(fs);
const result = engine.urlCosmeticResources('https://example.com/page');
assert.ok(result.hide_selectors.includes('.ad-banner'));
assert.ok(result.hide_selectors.includes('.sponsored-post'));
});
it('returns empty hide_selectors for an unmatched URL', () => {
const fs = new FilterSet();
fs.addFilters('example.com##.ad-banner');
const engine = new Engine(fs);
const result = engine.urlCosmeticResources('https://other-site.com/page');
assert.equal(result.hide_selectors.length, 0);
});
it('generichide exception sets generichide=true', () => {
const fs = new FilterSet();
fs.addFilters(['##.generic-ad', '@@||example.com^$generichide'].join('\n'));
const engine = new Engine(fs);
assert.equal(engine.urlCosmeticResources('https://example.com/page').generichide, true);
assert.equal(engine.urlCosmeticResources('https://other.com/page').generichide, false);
});
it('site-specific unhide (#@#) prevents selector from appearing', () => {
const fs = new FilterSet();
fs.addFilters(['example.com##.ad-banner', 'example.com#@#.ad-banner'].join('\n'));
const engine = new Engine(fs);
const result = engine.urlCosmeticResources('https://example.com/page');
assert.ok(!result.hide_selectors.includes('.ad-banner'));
});
it('generic unhide (#@#) adds to exceptions list', () => {
const fs = new FilterSet();
fs.addFilters(['##.generic-ad', 'example.com#@#.generic-ad'].join('\n'));
const engine = new Engine(fs);
const result = engine.urlCosmeticResources('https://example.com/page');
assert.ok(result.exceptions.includes('.generic-ad'));
});
it('negated domain (~sub.example.com) excludes subdomain', () => {
const fs = new FilterSet();
fs.addFilters('example.com,~sub.example.com##.ad');
const engine = new Engine(fs);
assert.ok(engine.urlCosmeticResources('https://example.com/page').hide_selectors.includes('.ad'));
assert.ok(!engine.urlCosmeticResources('https://sub.example.com/page').hide_selectors.includes('.ad'));
});
it('procedural_actions populated for :has-text() and :remove() filters', () => {
const fs = new FilterSet();
fs.addFilters([
'example.com##.items:has-text(Sponsored)',
'example.com##.ad-banner:remove()',
].join('\n'));
const engine = new Engine(fs);
const result = engine.urlCosmeticResources('https://example.com/page');
assert.ok(result.procedural_actions.length >= 2);
});
it('scriptlet injection populates injected_script', () => {
const fs = new FilterSet();
fs.addFilters('example.com##+js(noopjs)');
const engine = new Engine(fs);
// ##+js(noopjs) looks up "noopjs.js"; scriptlets use kind: "template"
engine.useResources([{
name: 'noopjs.js',
aliases: [],
kind: 'template',
content: btoa('(function(){})()'),
}]);
const result = engine.urlCosmeticResources('https://example.com/page');
assert.ok(result.injected_script.length > 0);
});
});
// ---------------------------------------------------------------------------
// Engine.hiddenClassIdSelectors
// ---------------------------------------------------------------------------
describe('Engine.hiddenClassIdSelectors', () => {
it('returns selectors matching class and id names', () => {
const fs = new FilterSet();
fs.addFilters(['##.a-class', '###simple-id'].join('\n'));
const engine = new Engine(fs);
assert.ok(engine.hiddenClassIdSelectors(['a-class'], [], []).includes('.a-class'));
assert.ok(engine.hiddenClassIdSelectors([], ['simple-id'], []).includes('#simple-id'));
});
it('returns empty for unknown class/id names', () => {
const fs = new FilterSet();
fs.addFilters('##.a-class');
const engine = new Engine(fs);
assert.deepEqual(engine.hiddenClassIdSelectors(['unknown'], ['unknown'], []), []);
});
it('exceptions array filters out results', () => {
const fs = new FilterSet();
fs.addFilters('##.a-class');
const engine = new Engine(fs);
assert.ok(engine.hiddenClassIdSelectors(['a-class'], [], []).includes('.a-class'));
assert.ok(!engine.hiddenClassIdSelectors(['a-class'], [], ['.a-class']).includes('.a-class'));
});
});
// ---------------------------------------------------------------------------
// Engine serialization
// ---------------------------------------------------------------------------
describe('Engine serialization', () => {
it('roundtrip preserves blocking and exception rules', () => {
const fs = new FilterSet();
fs.addFilters(['||blocked.com^', '@@||exception.blocked.com^'].join('\n'));
const src = new Engine(fs);
const buf = src.serialize();
const dst = new Engine(new FilterSet());
dst.deserialize(buf);
assert.equal(dst.check('https://blocked.com/img.png', 'https://pub.com', 'image'), true);
assert.equal(dst.check('https://safe.com/img.png', 'https://pub.com', 'image'), false);
assert.equal(dst.check('https://exception.blocked.com/', 'https://pub.com', 'other'), false);
});
it('tag enablement is NOT serialized — must re-enable after deserialize', () => {
const fs = new FilterSet();
fs.addFilters(['adv$tag=stuff', '||blocked.com^'].join('\n'));
const src = new Engine(fs);
src.enableTag('stuff');
const buf = src.serialize();
const dst = new Engine(new FilterSet());
dst.deserialize(buf);
// Untagged filter works immediately
assert.equal(dst.check('https://blocked.com/t.js', 'https://pub.com', 'script'), true);
// Tagged filter inactive until re-enabled
assert.equal(dst.check('https://example.com/adv', 'https://example.com', 'other'), false);
dst.enableTag('stuff');
assert.equal(dst.check('https://example.com/adv', 'https://example.com', 'other'), true);
});
it('resources are NOT serialized — must reload after deserialize', () => {
const fs = new FilterSet(true);
fs.addFilters(['||ads.example.com^$script,redirect=noopjs'].join('\n'));
const resource = {
name: 'noopjs',
aliases: [],
kind: { mime: 'application/javascript' },
content: btoa('(function(){})()'),
};
const src = new Engine(fs);
src.useResources([resource]);
const buf = src.serialize();
const dst = new Engine(new FilterSet());
dst.deserialize(buf);
// Without reloading: redirect absent
const without = dst.check(
'https://ads.example.com/t.js', 'https://pub.com', 'script', 'get', true,
);
assert.equal(without.should_block, true);
assert.ok(without.redirect == null);
// After reloading: redirect works
dst.useResources([resource]);
const withRes = dst.check(
'https://ads.example.com/t.js', 'https://pub.com', 'script', 'get', true,
);
assert.ok(withRes.redirect.length > 0);
});
});
// ---------------------------------------------------------------------------
// Engine tags
// ---------------------------------------------------------------------------
describe('Engine tags', () => {
it('tagged filter is inactive before enableTag, active after', () => {
const fs = new FilterSet();
fs.addFilters(['adv$tag=stuff'].join('\n'));
const engine = new Engine(fs);
assert.equal(engine.check('https://example.com/adv', 'https://example.com', 'other'), false);
engine.enableTag('stuff');
assert.equal(engine.check('https://example.com/adv', 'https://example.com', 'other'), true);
assert.equal(engine.tagExists('stuff'), true);
});
it('clearTags deactivates all enabled tags', () => {
const fs = new FilterSet();
fs.addFilters(['adv$tag=stuff', '||brianbondy.com/$tag=brian'].join('\n'));
const engine = new Engine(fs);
engine.enableTag('stuff');
engine.enableTag('brian');
assert.equal(engine.check('https://example.com/adv', 'https://example.com', 'other'), true);
engine.clearTags();
assert.equal(engine.tagExists('stuff'), false);
assert.equal(engine.tagExists('brian'), false);
assert.equal(engine.check('https://example.com/adv', 'https://example.com', 'other'), false);
});
});
// ---------------------------------------------------------------------------
// uBlockResources
// ---------------------------------------------------------------------------
describe('uBlockResources', () => {
const dataDir = join(__dirname, '..', '..', 'data', 'test', 'fake-uBO-files');
it('returns resources that work with engine.useResources', () => {
const resources = uBlockResources(
join(dataDir, 'web_accessible_resources'),
join(dataDir, 'redirect-resources.js'),
);
assert.ok(resources.length > 0);
const engine = new Engine(new FilterSet());
assert.doesNotThrow(() => engine.useResources(resources));
});
it('accepts optional scriptlets path', () => {
const resources = uBlockResources(
join(dataDir, 'web_accessible_resources'),
join(dataDir, 'redirect-resources.js'),
join(dataDir, 'scriptlets.js'),
);
assert.ok(resources.length > 0);
});
});