AdGuard's JavaScript library of Scriptlets and Redirect resources, providing extended capabilities for content blocking. Scriptlets are small JavaScript functions injected into web pages via declarative filter rules. Redirect resources substitute network requests with local no-op or stub responses. The library also exposes APIs for rule validation, conversion between AdGuard/uBO/ABP syntaxes, and compatibility metadata.
- Technical context
- Project structure
- Build and test commands
- Contribution instructions
- Code guidelines
- Language and version: TypeScript 5.x and JavaScript (mixed codebase), compiled via Babel + Rollup
- Primary dependencies:
@adguard/agtree(rule parsing),js-yaml(redirect manifests) - Storage: None
- Testing: QUnit (scriptlets, redirects, helpers — browser-level via Puppeteer) and Vitest (API, validators, converters, helpers, scriptlets — jsdom)
- Target platform: Browser extension and Corelibs
- Project type: single
- Performance goals: N/A
- Constraints: Array destructuring is forbidden in
src/— Babel's_slicedToArrayhelper is not available in the bundled runtime - Scale/scope: Used by AdGuard products (CoreLibs, Browser Extension, Safari, iOS) and filter list maintainers
All
dependenciesanddevDependenciesmust be pinned to exact versions (no caret^, etc.).
scriptlets/
├── .github/
│ └── workflows/ # GitHub Actions: ci, mirror, prepare/publish release
├── scripts/ # Build and utility scripts (build, test, wiki)
├── src/
│ ├── converters/ # Rule syntax converters (ADG ↔ UBO ↔ ABP)
│ ├── helpers/ # Shared utilities used by scriptlets/redirects
│ ├── redirects/ # Redirect resource source files + YAML manifests
│ │ └── blocking-redirects/ # Blocking redirect resources (click2load)
│ ├── scriptlets/ # Individual scriptlet source files (.js/.ts)
│ ├── validators/ # Rule validation functions
│ └── index.ts # Main public API entry point
├── tests/
│ ├── api/ # Vitest tests for converters and validators
│ ├── helpers/ # QUnit and Vitest tests for helper utilities
│ ├── redirects/ # QUnit tests for redirect resources
│ ├── scriptlets/ # QUnit and Vitest tests for scriptlets
│ ├── smoke/ # Smoke tests for ESM exports
│ └── vitest-helpers.ts # Vitest-only test utilities (e.g. jsdom workarounds)
├── types/ # Ambient type declarations
├── wiki/ # Auto-generated documentation (scriptlet/redirect docs, compatibility table)
├── .dockerignore # Docker build context exclusions
├── .eslintrc.cjs # ESLint configuration
├── .markdownlint.json # Markdownlint configuration
├── rollup.config.js # Rollup bundle configuration
├── tsconfig.json # TypeScript configuration
├── vitest.config.ts # Vitest configuration
├── DEPLOYMENT.md # Release and npm publish runbook
├── DEVELOPMENT.md # Development guide
└── package.json # Package manifest and scripts
pnpm install— install dependenciespnpm build— cleandist/and build all bundlespnpm test— run all tests (Vitest + smoke + QUnit)pnpm test:vitest— run Vitest tests only (API, validators, converters, helpers, scriptlets)pnpm test:qunit scriptlets— run QUnit tests for all scriptletspnpm test:qunit redirects— run QUnit tests for all redirectspnpm test:qunit helpers— run QUnit tests for helperspnpm test:qunit scriptlets --name <name>— rebuild and run a single scriptlet testpnpm test:qunit scriptlets --name <name> --build— only rebuild a single scriptlet test, without running it, e.g. while it is open with--guipnpm test:qunit:build— build the QUnit test bundles without running them (CI split-stage equivalent oftest:qunit --build; paired withtest:qunit:run)pnpm test:qunit:run— run the QUnit tests without rebuilding (CI split-stage; expectstest:qunit:buildto have run first)pnpm tgz— pack@adguard/scriptletsintoscriptlets.tgz(requires a version; CI stamps it viaset-dev-version, local builds must stamp first)pnpm lint— run all linters (lint:code+lint:types+lint:md)pnpm lint:code— run ESLintpnpm lint:types— run TypeScript type checking (tsc --noEmit)pnpm lint:md— run markdownlintpnpm wiki:build-table— regenerate compatibility tablepnpm wiki:build-docs— regenerate scriptlet/redirect wiki docs from JSDoc
You MUST follow the following rules for EVERY task that you perform:
-
You MUST verify your changes pass all static analysis checks before completing a task:
pnpm lint:codeto run ESLintpnpm lint:typesto check TypeScript typespnpm lint:mdto check Markdown formatting
-
You MUST update or add unit tests for any changed code.
-
You MUST run the test suite to verify your changes do not break existing functionality. For scriptlet/redirect changes use
pnpm test:qunit scriptlets --name <name>(orredirects). For API/validator/converter and helper changes, and for scriptlets which have Vitest specs intests/scriptlets/, usepnpm test:vitest, or e.g.pnpm test:vitest tests/scriptlets/<name>.spec.jsfor a single spec. -
When making changes to the project structure, ensure the Project structure section in
AGENTS.mdis updated and remains valid. -
When the task is finished update
CHANGELOG.mdfile and explain changes in theUnreleasedsection. Add entries to the appropriate subsection (Added,Changed, orFixed) if it already exists; do not create duplicate subsections. Changes limited to tests (e.g.tests/) or CI configuration (e.g..github/workflows/orDockerfile) MUST NOT add CHANGELOG entries — they are internal infrastructure and do not affect the published library. -
CI/CD runs on GitHub Actions (
.github/workflows/):ci.ymluses the sharedset-dev-versionaction before Docker builds, whilemirror.yml,prepare-release.yml, andpublish-release.ymldelegate to shared reusable workflows.package.jsonMUST NOT contain a committedversionfield. Clean local builds derive the next patch-devversion from the latest releasedCHANGELOG.mdheading. CI stamps that same development version, and release builds stamp the manually selected release version. The build MUST propagate the resolved value unchanged intoSCRIPTLETS_VERSION,dist/redirects.yml, anddist/scriptlets.corelibs.json. -
All CI
docker buildinvocations land on a single shared BuildKit instance, so Docker build steps MUST run strictly one after another (lint→vitest→qunit→smoke-tests→build). Concurrent builds can crash the builder (error reading from server: EOF) and fail the entire job at once. They are sequential steps within a singlecijob (not separate jobs withneeds), so GitHub Actions executes them in order by default. Cross-run serialization is provided by the self-hosted runner (team-extensions): if only one runner exists, GitHub assigns jobs one at a time so Docker builds never overlap. Theci.ymlconcurrency group is per-ref (ci-ext-scriptlets-${{ github.head_ref || github.ref }}) withcancel-in-progress: true, which cancels redundant same-ref pushes (only the latest run per ref survives) while allowing different PRs to run independently. The Dockerfile also caps the Node heap (NODE_OPTIONS=--max-old-space-size=1536, most of the 1800m buildx memory cap, with headroom for non-heap RSS) and skips the Chromium download in the smoke-test stage to keep per-build memory low. -
Docker builds MUST NOT pass cache-busting
--build-argvalues (e.g.BUILD_RUN_ID) whose only effect is to invalidate cached layers. The previousBUILD_RUN_IDARG wrote/tmp/.build-run-id(a file no script ever read) inside every build stage, busting the cache on every CI run and ballooning the shared BuildKit cache to >12 GB. Sibling repos (ext-disable-amp,ext-userscripts-wrapper) do without it. If you need to embed build metadata into artifacts, do it via build-args consumed by a dedicated final stage, or stamp it intopackage.json/dist files directly. -
CI workflows MUST NOT hardcode the remote BuildKit host address (e.g.
buildkit-extensions-0.buildkit-extensions-hl.github-runners.svc.cluster.local). That cluster hostname can change over time, and embedding it couples CI to a specific Kubernetes deployment. Hackingdocker buildx pruneinto a per-run workflow step is not recommended. -
Do NOT add an aggregate "all checks passed" job to
ci.yml: the org-wideAdGuardSoftwareLimited/actions/.github/workflows/check-master.yml("Branch up-to-date check", required by branch protection) already waits for every check run on the PR head SHA and blocks the merge until all succeed. -
If the prompt essentially asks you to refactor or improve existing code, check if you can phrase it as a code guideline. If it's possible, add it to the relevant Code guidelines section in
AGENTS.md. -
After completing the task you MUST verify that the code you've written follows the Code guidelines in this file.
-
When adding a new scriptlet or redirect resource, you MUST update
scripts/compatibility-table.jsonaccordingly (except for trusted scriptlets). -
Use
pnpmas the package manager. Do not usenpmoryarn. -
Commit messages MUST start with the ticket number (
AG-XXX) so they auto-link with the task tracker, followed by a short description in the present tense (e.g.AG-1234 Fix login redirect). Automated commits made by CI (e.g. the wiki regeneration during the release flow) use a Conventional Commits prefix such asdocs:instead.
Non-trivial changes MUST be preceded by a spec created with the SDD slash commands, which should be available globally (preferred).
Specs are local-only and never committed — .sdd/ contents are
gitignored (see .gitignore).
The library is organized into four public entry points, each exposed via
package.json exports:
@adguard/scriptlets(src/index.ts) — main API:invoke(),getScriptletFunction(),SCRIPTLETS_VERSION.@adguard/scriptlets/redirects(src/redirects/) —Redirectsclass,getRedirectFilename(),isBlocking().@adguard/scriptlets/converters(src/converters/) — rule syntax converters (ADG ↔ UBO ↔ ABP).@adguard/scriptlets/validators(src/validators/) — rule validation functions.
Each scriptlet is a single file in src/scriptlets/ with a JSDoc header
containing @scriptlet or @trustedScriptlet and @description tags.
These tags drive auto-generated wiki documentation.
Shared logic lives in src/helpers/. Helpers are bundled into each scriptlet
at build time — they MUST NOT have side effects or rely on module-level state.
Rationale: Scriptlets are inlined into web pages individually; they cannot share runtime modules so all dependencies must be statically bundleable.
Each scriptlet (and redirect) declares an injections array — a flat list of
helper functions that are stringified and concatenated to the scriptlet code at
build time (see attachDependencies() in src/helpers/injector.ts).
Injection resolution is NOT transitive. Although helpers may import other
helpers at the TypeScript/module level (e.g. getDescriptorAddon imports
randomId), those transitive imports are NOT automatically included in the
built scriptlet output. The build system stringifies each function listed in
injections individually via .toString() — it does not follow or resolve
import statements inside those functions.
Therefore, if a scriptlet uses helper A and helper A internally calls
helper B, the scriptlet MUST list both A and B in its injections
array. Omitting B will cause a ReferenceError at runtime because the
helper's code will reference a function that was never concatenated into the
output.
Example from abort-current-inline-script.js — both getDescriptorAddon and
its dependency randomId are listed explicitly:
abortCurrentInlineScript.injections = [
randomId, // required by getDescriptorAddon and createOnErrorHandler
setPropertyAccess,
getPropertyInChain,
toRegExp,
createOnErrorHandler, // uses randomId internally
hit,
logMessage,
isEmptyObject,
getDescriptorAddon, // uses randomId internally
];When adding or modifying helpers in a scriptlet's injections list, always
verify that every helper-of-helper dependency is also present in the array.
General code style guidelines are available via link: https://github.com/AdguardTeam/CodeGuidelines/blob/master/JavaScript/Javascript.md.
Project-specific rules:
-
You MUST NOT use array destructuring in
src/files. Use indexed access instead (e.g.,const first = arr[0];notconst [first] = arr;).Rationale: Babel's
_slicedToArrayhelper is unavailable in the bundled scriptlet runtime, causingReferenceError. -
TypeScript is preferred for new files. Existing
.jsfiles MAY remain as-is. -
All scriptlet and redirect source files MUST include JSDoc with
@scriptlet(or@trustedScriptlet/@redirect) and@descriptiontags.Rationale: The
wiki:build-docsscript generates documentation from these tags. -
Imports MUST use
typequalifier for type-only imports (import { type Foo }).Rationale: Enforced by
@typescript-eslint/consistent-type-imports. -
Max line length is 120 characters (code and markdown).
-
Indentation is 4 spaces (no tabs).
-
External and internal imports MUST be separated by an empty line.
-
TypeScript tuple type annotations with 3 or more elements MUST be formatted as multiline, with each element on its own line.
Good:
args: [ method: string, url: string, async?: boolean, user?: string, password?: string, ],
Bad:
args: [ method: string, url: string, async?: boolean, user?: string, password?: string],
Rationale: Improves readability and makes diffs cleaner when parameters are added or modified.
-
package.jsonMUST remain versionless in source control. Build code MUST usegetBuildVersion(); workflows that package npm artifacts MUST stamp a version before packaging.Rationale: The Prepare release tag and
CHANGELOG.mdare the version sources of truth. -
Scriptlets which re-apply themselves on DOM changes (e.g. via
observeDOMChanges) MUST NOT write a value the target already has (compare beforesetAttribute()etc.) and MUST callhit()only if something has actually changed. Invalid arguments (e.g. selector) SHOULD be validated and logged once, before the observer is started; use theisValidSelector(),isValidXpath()andisValidAttributeName()helpers, andgetShadowSelectorError()for selectors ofqueryShadowSelector(), i.e. with>>>combinator orxpath(...), as they do not query or change the page DOM. Such arguments SHOULD be parsed once as well, and passed to helpers in the parsed form, e.g. selector parts split by>>>withsplitSelectors()toqueryShadowSelector(), instead of being parsed again on each DOM change. A failure to process an element SHOULD be logged once and not again until the processed value of the element changes. Such a failure SHOULD be detected from the processed value and remembered per element, not inferred from whether the write has changed the target.Rationale:
observeDOMChangesonly ignores the scriptlet's own mutations. A write of the same value still produces a mutation record which wakes up observers of other rules, so two such rules re-trigger each other infinitely, and ahit(), an argument error or an element failure logged on each callback floods the console on every unrelated DOM change. The target may already hold the result of the failed processing, e.g. in a copy of a processed element, so a failure logged only on change may be never logged for it. -
To check whether an element is still matched by the scriptlet selector, e.g. after the scriptlet has changed it, use
element.matches()with:scopereplaced by:root(outside quoted strings), instead of querying the whole document again.Rationale:
:scoperefers to the root element indocument.querySelectorAll(), but to the element itself inelement.matches(), so e.g.:scope anever matches the link itself. -
Use the URL parser for values explicitly selected from URL attributes, parameters or encoded destinations. Limit prose checks to visible link text. When coordinating DOM writes between rules, track actual writes instead of inferring their origin from similarities between values, and accept another rule's write only if it continues the write the rule has seen, i.e. compare write records by identity, not by the written value. Keep shared per-element records on the affected elements rather than in a
windowregistry; use non-enumerable symbol properties, not HTML attributes.Rationale: Valid URLs can contain spaces, parentheses and other URLs. Page edits can resemble a rule's output, so guessing can reject valid values or prevent a rule from correcting a page edit. The written value alone cannot tell whether another rule continued this rule's write or changed a page edit. Element metadata avoids global storage and does not trigger DOM mutation observers.
-
Scriptlets which process matched elements on DOM changes MUST catch errors per element, log such an error once per element, and continue with other elements.
Rationale: An error for one element, e.g. caused by the page, would otherwise stop processing of the other elements on each DOM change and be reported as uncaught on the page each time.
observeDOMChangesonly connects the observer again after its callback throws, so that the scriptlet does not stop working on the page. -
Non-trivial parsing logic of a scriptlet which depends only on its arguments and has many edge cases, e.g. parsing of a URL parameter, SHOULD be a helper in
src/helpers/covered by a Vitest spec intests/helpers/, rather than a function inside the scriptlet. Constants of such a helper, e.g. regular expressions, MUST be defined inside the function, see Helper injection mechanism.Rationale: Edge cases can be tested directly and quickly, without a browser page, and the helper can be reused by other scriptlets. Helpers are stringified one by one, so module-level constants are not in the built code.
-
Event listener hooks which spoof or proxy events, e.g. the
isTrustedspoofing oftrusted-click-element, MUST deliver every other event unchanged: only the events which the scriptlet dispatches itself, and the ones which the browser dispatches directly in response, e.g. the click which a label forwards to its control, are spoofed, unless the rule explicitly opts in to spoofing all events of the page, e.g.isTrusted:all. Trusted events MUST be delivered unchanged. All listeners of a spoofed event, including inlineon...handlers, MUST receive the same proxy, so they MUST resolve the delivered event with one shared function, e.g.getDeliveredEvent()ofcreateSpoofedClicks(). Listener wrappers shared across targets MUST remain stable when a listener is removed from one target; native registration handles deduplication,once, andAbortSignalcleanup, so wrapper lookups MUST convertcapturethe same way asaddEventListenerdoes, and a hook which reads the options MUST read them once and pass the values read to the native method. Hooks which withhold events on purpose, e.g. ofprevent-addEventListenerorprevent-element-src-loading, are not covered by this rule. Howtrusted-click-elementrecognizes the clicks which labels forward, e.g. through slots and closed shadow roots, is described in the JSDoc ofclickElement()insrc/helpers/click-utils.tsand of the functions inside it.Rationale: Popup guards compare event references, including
window.event, so replacing page or browser events breaks them, see #582, and so spoofing all events of the page is only an opt-in fallback. Removing a shared wrapper mapping breaks removal and deduplication on other targets. Options may be getters which return a different value on each read, so a wrapper looked up by onecapturevalue and registered with another cannot be removed and is not deduplicated. -
Scriptlets which intercept an arbitrary method and process its return value as JSON, e.g.
trusted-json-set, MUST process the value which a returned promise is fulfilled with, not the promise itself. Detect it withisNativePromise()against thePromisesaved when the scriptlet runs, return the promise chained with the saved nativePromise.prototype.then, or the original promise if its value is only logged, and keep other results, including promises of polyfills and other thenables, synchronous.stackMUST be matched once, when the method is called, and the intercepted method MUST NOT be called again if it throws. The same applies to scriptlets which process a response after it is awaited, e.g.json-prune-fetch-responseor theResponse.prototype.jsonhook ofjson-prune:stackis matched in the wrapper before the response is awaited, calls which do not match it are passed through as is, and an emptystackis passed to the helpers which process the response, e.g.jsonPruner()orjsonPath(). In logging mode ofjson-prune-fetch-responseand of theResponse.prototype.jsonhook, i.e. without properties to remove,stackis not matched, asjsonPruner()logs the content regardless of it, e.g. forJSON.parse()ofjson-prune, and the stack trace read in the wrapper is passed to it to be logged. TheResponse.prototype.jsonhook chains a promise which is not an instance of the savedPromisewith its ownthen(), asjson()always returns a promise, but the savedPromiseand itsthen()are not native ones if the page has replaced them before the scriptlet has run, e.g. by zone.js, and itsthen()may not support a native promise.fetch()is called once, outside oftry, so its rejection is passed to the page as is, while an error of processing, logging or forging the response which it is fulfilled with, e.g. of serializing a too deeply nested value, is caught and logged, and the original response is returned.Rationale: Path setters create missing properties on the promise itself, see #585. The caller is no longer in the stack when the promise is fulfilled, and the page may replace
Promiseorthenof the promise. Native APIs and async functions return native promises even then, while callingthenof any other object would turn its synchronous result into a promise for any page object with athen()method. Calling the method again repeats its side effects, e.g. consuming the body of aResponse, or sending a failed POST request again. The request of a response which cannot be processed has succeeded, so the page should not get a rejection for it. -
If a function which the page calls, e.g. a wrapper of the intercepted method or its Proxy trap, creates a stack trace for
matchStackTrace()itself, the stack trace MUST be passed throughaddStackTraceMessageLine()before it is matched, and only ifstackis set, e.g.matchStackTrace(stack, addStackTraceMessageLine(String(new Error().stack || ''))). If it is read for rules withoutstackas well, e.g. for logs, an error of creating or reading it MUST NOT stop such rules, i.e. bothnew Error()and reading itsstackare in their owntryat the call site, e.g.try { stackTrace = String(new Error().stack || ''); ... } catch { ... }, and rules withstackare not applied if it fails. Helpers which create the stack trace forstackthemselves, e.g.jsonPath(), MUST do it the same way, and only ifstackis set or it is logged. If it is read only forstackbut after the intercepted method is called, e.g. intrusted-replace-outbound-text, it MUST be guarded the same way, and the result of the call returned as is if it fails.matchStackTrace()throws ifstackis an invalid regular expression, so a scriptlet which matches it in a wrapper SHOULD check it once when it runs, withisValidStackPattern(), and not apply the rule then, which is logged once. Otherwise the wrapper MUST catch the error and pass the call through, as the helpers which matchstackthemselves do it, i.e.jsonPruner(),jsonSetter()andjsonPath(). A new function MUST NOT be added only to create the stack trace one call deeper instead, and neither a helper which only reads the stack trace of an error created by the caller, as creating it would not be guarded then. A scriptlet which suspends matching while it processes a call, e.g. byisMatchingSuspended, MUST resume it on every return path, e.g. bytry/finally, including a stack trace which does not match and an error thrown by the intercepted method.Rationale:
matchStackTrace()removes the first two lines of the stack trace as the scriptlet's own ones. In Firefox and Safari the stack trace has no error message line, so the frame of the page function which calls the wrapper would be removed there. Corelibs builds are minified with Terser, which may inline a function added only to create the stack trace, so its frame is not in the stack trace there. Stack traces which existing scriptlets create in a function called by the wrapper, e.g.checkArgument()oftrusted-replace-argumentor the getter handler ofset-constant, keep the frame of the caller while that function is not inlined. The page may make the stack trace a non-string value, or make reading it throw, e.g. byError.prepareStackTracein Chrome or a getter ofError.prototype.stackin Firefox, which should not affect rules withoutstack. The page may also replaceErrorwith a constructor which throws. Such an error would replace the result of a call which has already succeeded otherwise. A call from another function, or an error of the intercepted method, would disable the rule for the next calls otherwise, e.g. intrusted-replace-outbound-text. The helpers which matchstackcatch the error of an invalid one, so the content is left unmodified, and a wrapper should not throw it to the page instead. An invalidstackchecked on each call would be matched and logged on each call, e.g. ofJSON.parse(). -
Scriptlets which intercept a method by its path, e.g.
localStorage.setItem, MUST replace it in the object returned bygetMethodOwner()for the base object and the name of the method, and process only the calls whosethisis the base object if that object is not the base one, e.g.if (methodOwner !== base && thisArg !== base)in the wrapper. Such calls MUST be passed through before any processing, e.g. outside of atrywhosecatchcalls the method again. The intercepted method itself MUST be called once, outside of suchtry, so its error is passed to the page as is and its side effects are not repeated.Rationale: Assigning a property of a storage, i.e.
localStorageorsessionStorage, stores an item in Firefox, even if it is a method, e.g.localStorage.setItem = proxystores the itemsetItem. So the method is not replaced, and the storage of the page is changed.getMethodOwner()returnsStorage.prototypefor a storage, in which the method is replaced for all storages, unless the storage has an own method, e.g. a wrapper which the page has assigned in Chrome, as it hides the method ofStorage.prototype. Calls on another storage must behave as if they were not intercepted. Defining a getter or setter of a storage property throws in all browsers, soset-constantandtrusted-set-constantdo not support storage properties. -
Scriptlets which send a forged
XMLHttpRequestinstead of the request of the page, to modify its response, SHOULD useinterceptXhrResponses(), and otherwise MUST follow it.trusted-replace-xhr-response,xml-pruneandm3u-prunedo not follow it yet, and SHOULD be moved to the helper when they are changed. The state of a request, i.e. whether it is matched, its headers and its forged request, is reset when its object is opened again, and the forged request of the previous one is aborted, so its events are not dispatched, also when the page callsabort(), which dispatches the events of an aborted request instead. Once the events of a response are dispatched,loadandloadendfollowreadystatechangeeven if its listener opens the object again or aborts it, as in the specification and in Firefox, although Chromium-based browsers do not dispatch them then, as a page which waits for them would never finish. Events of a synchronous request are dispatched beforesend()returns, as by a native one, while the ones of an asynchronous request are deferred. An error of modifying or defining the response is caught, and the events are dispatched anyway, with the original text of the response if it cannot be modified, so the request of the page is always completed. Headers are collected by one hook ofXMLHttpRequest.prototype.setRequestHeader, and by a wrapper of another method of a matched object, e.g. its own one, or the hook of another rule, which the rules do not recognize, as the method of each object is wrapped only once by each rule, so the wrappers of multiple rules do not wrap each other again, and only the outermost wrapper of each rule collects a header. The headers of the forged response are returned by the hooks ofgetResponseHeader()andgetAllResponseHeaders()of the prototype, which replace only the result of the object without headers, as it is not sent, but other methods of an object are not wrapped for them, so a value which a method of the page changes or hides on purpose is kept. The forged request is created by the native constructor saved when the scriptlet runs, opened with the arguments ofopen()of the page, and sent once, not in atrywhosecatchsends it again. If it fails, e.g. a synchronous request with a network error, its error is thrown to the page, and the object is done with the network error, as a native one, while it would be still opened otherwise.Rationale: The page may reuse the object for the next request, or abort it, e.g. on each input of a search, and
open()andabort()of the object of the page change nothing, as it is never sent. A response of the previous request would be delivered as the response of the next one otherwise, and a wrapper created for each request of an object wraps the previous one again, so each header is sent once more with each next request. The page may wrap the method of an object, and call the native method saved before the scriptlet has run, so the hook of the prototype alone would miss such headers, e.g. of authentication. The object of the page has no response headers otherwise, while the page may check them, e.g.Content-Type, see #419.asyncistrueif it is omitted, butfalseif it isundefined. A failed synchronous request throws a network error, and sending it again repeats its side effects. -
Errors thrown by functions of the page, e.g. by the intercepted method, a getter of its argument or
toJSON()of a value, MUST be logged withgetThrownValueMessage(), neither withgetErrorMessage()nor by reading theirmessagedirectly.Rationale:
getErrorMessage()converts a value without a message byJSON.stringify(), which callstoJSON()of the page, even if the message is not logged, and it may be the method intercepted by the scriptlet itself, e.g.JSON.stringifyofcall-nothrow, which would then be called again, with the same error, until the call stack is exceeded. It also creates an error byError, which the page may replace with a constructor which throws. The page may throw any value, e.g.nullorundefined, so reading itsmessagethrows another error, which replaces the logged one. -
A Proxy which replaces a function of the page, e.g. a method intercepted by a scriptlet, SHOULD use
getFunctionProxyProperty()as itsgettrap, instead of a copy of it, so itstoString()returns the same string as the function.Rationale:
toString()of a Proxy of a function returns the string of a native function without its name, e.g.function () { [native code] }, instead of e.g.function open() { [native code] }, or the source of a function of the page, which the page may check. The trap returns a non-configurable owntoStringof the function as it is, as a Proxy throws when it is read otherwise, e.g. if the page has defined it as not writable on its own function. -
JSON content which a scriptlet only logs, e.g. in logging mode or with
verbose, SHOULD be logged withlogJsonContent(), which logs it only as an object if it cannot be serialized, instead of serializing it at the call site, e.g. with thelabelOriginal contentorModified content. An error of serializing content for a log MUST NOT be thrown to the page, nor stop the intercepted call, the modification of the content, or the delivery of the response.Rationale: Logged content may be circular, e.g. a result of
eval()ofevaldata-pruneor an argument oftrusted-prune-inbound-object, or too deeply nested, whichJSON.stringify()with indentation cannot serialize, even thoughJSON.parse()has parsed it. Logging should not change the behavior of the page.
-
QUnit tests (
tests/scriptlets/,tests/redirects/,tests/helpers/): test files are named<name>.test.js. QUnit tests run in a real browser environment via Puppeteer. Use these for scriptlet and redirect behavior testing. -
Vitest tests (
tests/api/,tests/helpers/,tests/scriptlets/, root*.spec.js/*.spec.ts): test files are named*.spec.jsor*.spec.ts. Use these for API-level, converter, validator and helper testing, and for scriptlet behavior which needs no real browser, e.g. by calling the scriptlet function directly. Environment is jsdom. -
Tests of clicks which a label forwards to its control MUST make the label forward them as untrusted, e.g. with
forwardUntrustedLabelClicks()intests/scriptlets/trusted-click-element.test.js. Some browsers forward them as untrusted, e.g. Firefox, but others as trusted, e.g. the Chrome version which Puppeteer runs the tests in, where the spoofing of forwarded clicks would not be tested otherwise. -
Tests which install the click hook of
trusted-click-elementagain, e.g. after deleting it between tests, MUST callallowSpoofedClicksReset()fromtests/helpers.js, as the property which stores the shared spoofed clicks is not configurable outside of tests, so the page cannot replace it. It is tested without the wrapper intests/helpers/spoofed-clicks-property.spec.ts. -
Every new scriptlet or redirect MUST have a corresponding
.test.jsfile in the appropriatetests/subdirectory. -
Test file naming convention:
.test.jsfor QUnit,.spec.js/.spec.tsfor Vitest. This separation ensures QUnit tests are not picked up by Vitest and vice versa. -
tests/is a pnpm workspace package (@adguard/scriptlets-tests) that owns the QUnit/Puppeteer runtime dependencies (qunit,sinon,js-reporters,node-qunit-puppeteer,puppeteer); browser-asset copy paths inscripts/build-tests.jstherefore point attests/node_modules/…. CI's non-browser Docker stages install only the root package (pnpm install --filter @adguard/scriptlets). -
The QUnit test runner (
tests/index.js) MUST launch Chrome with--disable-dev-shm-usageand MUST restart the browser periodically (BROWSER_RESTART_INTERVAL). Chrome's/dev/shmis tiny inside the CI Docker container (exhausting it makes page creation extremely slow), and a single long-lived browser accumulates V8 heap until the QUnit timeout fires before the test page finishes loading. If you change the runner, keep both mitigations in place. -
The test server (
tests/server.js) MUST NOT assume its fixed port (54136) is free.start()falls back to an ephemeral port onEADDRINUSE, because the shared CI BuildKit builder can hold that port via a concurrent or leftover process — without the fallback the entire QUnit stage crashes with an unhandled'error'event. Callers MUST use the portstart()resolves with (not the module-levelportconstant) when building test page URLs.
-
The
wiki/directory contains auto-generated Markdown files. Do NOT edit them manually — they are regenerated bypnpm wiki:build-docs. -
scripts/compatibility-table.jsonis the source of truth for cross-blocker compatibility data. Update it when adding new scriptlets or redirects (except trusted scriptlets).